@zerotal/arch 1.7.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 +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
package/docs/ai.md
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: AI
|
|
3
|
+
description: Text, streaming, structured output, typed tools, and an agent loop behind one provider-agnostic facade.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# AI
|
|
7
|
+
|
|
8
|
+
`@zerotal/ai` gives an application one way to talk to a language model: `Ai.text()`
|
|
9
|
+
for a completion, `Ai.stream()` for tokens as they arrive, `Ai.object()` for a value
|
|
10
|
+
that satisfies a validator schema, and `Ai.agent()` for a loop that calls your tools
|
|
11
|
+
until the model is done. The provider is chosen in `config/ai.ts`, not at each call
|
|
12
|
+
site, so moving from Claude to a local model is a config change.
|
|
13
|
+
|
|
14
|
+
::: warning Experimental
|
|
15
|
+
This package is `experimental`. Its API may change in a minor release — treat it as
|
|
16
|
+
a preview and pin the version if that matters to you.
|
|
17
|
+
:::
|
|
18
|
+
|
|
19
|
+
## Getting Started
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bun add @zerotal/ai
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The provider SDKs are **optional peers**, imported lazily. Install only the one you
|
|
26
|
+
use:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
bun add @anthropic-ai/sdk # only for the anthropic driver
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The OpenAI and Ollama drivers use `fetch` directly and need nothing extra.
|
|
33
|
+
|
|
34
|
+
## Register the provider
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// bootstrap/providers.ts
|
|
38
|
+
import { AiProvider } from "@zerotal/ai";
|
|
39
|
+
|
|
40
|
+
const providers = [
|
|
41
|
+
// …your other providers
|
|
42
|
+
AiProvider,
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export default providers;
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Registering the provider switches on the following:
|
|
49
|
+
|
|
50
|
+
- `onRegister` — binds an `AiManager` as a lazy singleton on the `"ai"` container key.
|
|
51
|
+
- `onBooted` — subscribes the observability bridges, contributes the monitor's **AI**
|
|
52
|
+
section, and registers the `ai:test` and `ai:spend` commands.
|
|
53
|
+
- `onStopping` — unsubscribes the bridges.
|
|
54
|
+
|
|
55
|
+
With no `config/ai.ts` at all, the provider falls back to an Anthropic driver built
|
|
56
|
+
from `ANTHROPIC_API_KEY`. That is enough to try the package; everything below assumes
|
|
57
|
+
a real config file.
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// config/ai.ts
|
|
63
|
+
import { AiConfig } from "@zerotal/ai";
|
|
64
|
+
|
|
65
|
+
export default AiConfig({
|
|
66
|
+
default: "anthropic",
|
|
67
|
+
|
|
68
|
+
drivers: {
|
|
69
|
+
anthropic: {
|
|
70
|
+
apiKey: Bun.env["ANTHROPIC_API_KEY"] ?? "",
|
|
71
|
+
model: "claude-opus-5",
|
|
72
|
+
effort: "high",
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
ollama: {
|
|
76
|
+
model: "llama3.2",
|
|
77
|
+
baseUrl: "http://127.0.0.1:11434",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
// Embeddings are their own block with their own driver — see below.
|
|
82
|
+
embeddings: {
|
|
83
|
+
default: "openai",
|
|
84
|
+
drivers: {
|
|
85
|
+
openai: { apiKey: Bun.env["OPENAI_API_KEY"] ?? "" },
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
limits: { perRequestUsd: 0.5, perDayUsd: 25 },
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Only the drivers you declare exist. An app that talks to Ollama alone declares no
|
|
94
|
+
`anthropic` block, installs no SDK, and needs no API key.
|
|
95
|
+
|
|
96
|
+
### Why embeddings are configured separately
|
|
97
|
+
|
|
98
|
+
Anthropic has no embeddings endpoint. If `embed()` hung off the generation driver,
|
|
99
|
+
the normal pairing — Claude for generation, something cheaper for vectors — could not
|
|
100
|
+
be expressed at all. So `embeddings` is its own block with its own `default`.
|
|
101
|
+
|
|
102
|
+
### Spend ceilings
|
|
103
|
+
|
|
104
|
+
`limits.perRequestUsd` is checked **before** the request is sent, from a real token
|
|
105
|
+
count, and bounds the blast radius of one runaway prompt.
|
|
106
|
+
`limits.perDayUsd` is checked from reported usage as it accumulates.
|
|
107
|
+
|
|
108
|
+
Both are process-scoped and estimated from public list prices. That is a real
|
|
109
|
+
limitation stated plainly: N workers hold N daily ceilings, and an account with
|
|
110
|
+
negotiated rates pays less than the estimate. They are a guard against a runaway
|
|
111
|
+
loop, not a billing system — the provider's dashboard remains the authority.
|
|
112
|
+
|
|
113
|
+
A model this package has no price for is never blocked, because a ceiling with
|
|
114
|
+
nothing to compare against should stand aside rather than refuse everything. Teach it
|
|
115
|
+
a price with `registerModelPrice()`.
|
|
116
|
+
|
|
117
|
+
## Generating text
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { Ai } from "@zerotal/ai";
|
|
121
|
+
|
|
122
|
+
// Just the text.
|
|
123
|
+
const summary = await Ai.text(`Summarize in one sentence:\n\n${article}`);
|
|
124
|
+
|
|
125
|
+
// The text plus the accounting.
|
|
126
|
+
const response = await Ai.generate({
|
|
127
|
+
prompt: "Explain event sourcing to a backend developer.",
|
|
128
|
+
system: "You are terse. No preamble.",
|
|
129
|
+
effort: "low",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
response.text;
|
|
133
|
+
response.usage.outputTokens;
|
|
134
|
+
response.stopReason; // "end_turn" | "max_tokens" | "tool_use" | …
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Effort, not temperature
|
|
138
|
+
|
|
139
|
+
Current Claude models **reject** `temperature`, `top_p`, and `top_k` with a 400 — a
|
|
140
|
+
generic sampling parameter forwarded blindly fails every request. The Anthropic
|
|
141
|
+
driver therefore drops `temperature` and warns once.
|
|
142
|
+
|
|
143
|
+
Reach for `effort` instead. It trades thoroughness against cost and latency:
|
|
144
|
+
|
|
145
|
+
| Effort | Use it for |
|
|
146
|
+
| -------- | -------------------------------------------------------- |
|
|
147
|
+
| `low` | Classification, extraction, short latency-sensitive work |
|
|
148
|
+
| `medium` | A cost-conscious default |
|
|
149
|
+
| `high` | The default — most intelligence-sensitive work |
|
|
150
|
+
| `xhigh` | Hard coding and agentic tasks |
|
|
151
|
+
| `max` | When correctness matters more than the bill |
|
|
152
|
+
|
|
153
|
+
### Streaming
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
for await (const chunk of Ai.stream({ prompt, signal })) {
|
|
157
|
+
if (chunk.type === "text") process.stdout.write(chunk.text);
|
|
158
|
+
if (chunk.type === "done") console.log(chunk.response.usage);
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The last chunk is always `{ type: "done" }` carrying the assembled response, so a
|
|
163
|
+
caller that only wants tokens can ignore it and one that needs usage does not have to
|
|
164
|
+
add up the pieces. Pass an `AbortSignal` and a cancelled caller actually stops the
|
|
165
|
+
generation. See [Flow](/docs/flow) for streaming straight into a component.
|
|
166
|
+
|
|
167
|
+
### Structured output
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
const review = await Ai.object({ prompt: `Classify this review:\n\n${text}` }, (rule) => ({
|
|
171
|
+
sentiment: rule.string().in(["positive", "neutral", "negative"]),
|
|
172
|
+
summary: rule.string().max(140),
|
|
173
|
+
score: rule.number().min(1).max(5),
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
review.sentiment; // typed, validated
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The schema is the same [validator](/docs/validator) schema you use for forms.
|
|
180
|
+
|
|
181
|
+
The JSON Schema subset these APIs accept is narrow: `additionalProperties: false` is
|
|
182
|
+
required on every object, and `minLength` / `maxLength` / `minimum` / `maximum` /
|
|
183
|
+
recursive schemas are **not supported**. So constraints the provider cannot express
|
|
184
|
+
are stripped from the schema it receives and **re-checked here** against the original
|
|
185
|
+
— `max(140)` is enforced by the validator on the way back in, and a violation raises
|
|
186
|
+
`AiSchemaError`. A recursive schema has no client-side rescue and is refused when the
|
|
187
|
+
schema is defined, not when the request is sent.
|
|
188
|
+
|
|
189
|
+
`strippedConstraints()` names exactly what the model will not see, if you want to
|
|
190
|
+
check that a load-bearing constraint is visible to it:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
strippedConstraints({ title: rule.string().min(3) }); // → ["title: min"]
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Tools and the agent loop
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
import { Ai, tool } from "@zerotal/ai";
|
|
200
|
+
|
|
201
|
+
const lookupOrder = tool({
|
|
202
|
+
name: "lookup_order",
|
|
203
|
+
description:
|
|
204
|
+
"Fetch one order by id. Call this whenever the user mentions an order number — " +
|
|
205
|
+
"do not answer from the conversation alone.",
|
|
206
|
+
input: (rule) => ({ id: rule.string() }),
|
|
207
|
+
handle: async ({ id }) => await Order.find(id),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const result = await Ai.agent({
|
|
211
|
+
prompt: "Where is order 4821?",
|
|
212
|
+
tools: [lookupOrder],
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
result.text;
|
|
216
|
+
result.steps; // every call, its result, and how long it took
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Say **when** to call a tool, not just what it does — the trigger condition is the half
|
|
220
|
+
that moves the call rate.
|
|
221
|
+
|
|
222
|
+
A handler that throws does not end the run: the error becomes an error-flagged result
|
|
223
|
+
the model can react to. A call to a tool that does not exist gets the same treatment,
|
|
224
|
+
naming the tools that do.
|
|
225
|
+
|
|
226
|
+
### Ceilings
|
|
227
|
+
|
|
228
|
+
Two, because a model deciding when to stop is not a termination proof:
|
|
229
|
+
|
|
230
|
+
- `agent.maxSteps` (default 25) caps tool-calling round trips.
|
|
231
|
+
- `agent.maxResumes` (default 5) caps `pause_turn` restarts.
|
|
232
|
+
|
|
233
|
+
`pause_turn` is worth knowing about. A provider running a long server-side tool can
|
|
234
|
+
end a turn with `stop_reason: "pause_turn"`, meaning "ask me again" — not an error and
|
|
235
|
+
not a completion. Left unhandled it reads as a finished answer, so the user sees a
|
|
236
|
+
silently truncated response with no warning anywhere. The loop pushes the paused turn
|
|
237
|
+
back and re-requests.
|
|
238
|
+
|
|
239
|
+
### Locking a run
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
await Ai.agent({
|
|
243
|
+
prompt: "Refund order 4821 if it shipped over 30 days ago.",
|
|
244
|
+
tools: [lookupOrder, issueRefund],
|
|
245
|
+
lock: "refund:4821",
|
|
246
|
+
});
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Naming a run makes it exclusive **for that name** — two workers cannot refund the same
|
|
250
|
+
order, while unrelated runs proceed in parallel. A shared key would serialize every
|
|
251
|
+
agent run in the app, which is why the lock is opt-in and named rather than automatic.
|
|
252
|
+
|
|
253
|
+
The lock refreshes for as long as the loop runs, so `agent.lockTtl` (default 120s)
|
|
254
|
+
stops meaning "how long the job might take" — unanswerable — and becomes "how long
|
|
255
|
+
after a crash before another worker may take over". If the lock is ever lost the
|
|
256
|
+
loop's signal aborts, because at that point somebody else may be doing the same work.
|
|
257
|
+
See [Locking](/docs/lock) for the mechanism.
|
|
258
|
+
|
|
259
|
+
## Refusals
|
|
260
|
+
|
|
261
|
+
A provider's safety classifiers can decline a request. That arrives as a **successful
|
|
262
|
+
HTTP 200** with empty or partial content — so code that reads `content[0]` without
|
|
263
|
+
checking crashes on a response the API considers fine.
|
|
264
|
+
|
|
265
|
+
This package checks the stop reason first and raises a typed error:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
import { AiRefusedError } from "@zerotal/ai";
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
await Ai.text(prompt);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (error instanceof AiRefusedError) {
|
|
274
|
+
error.category; // "cyber" | "bio" | … | null
|
|
275
|
+
error.partialText; // whatever arrived before a mid-stream decline
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
The Anthropic driver ships `fallbacks: "default"` on by default, which re-runs a
|
|
281
|
+
declined request on the provider's recommended fallback model server-side. Turn it off
|
|
282
|
+
with `drivers.anthropic.fallbacks: false`.
|
|
283
|
+
|
|
284
|
+
## Embeddings
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
const { embeddings } = await Ai.embed(["first chunk", "second chunk"]);
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
One vector per input, in input order.
|
|
291
|
+
|
|
292
|
+
## Background generation
|
|
293
|
+
|
|
294
|
+
A queued generation is serialized, so its completion handler is registered by **name**
|
|
295
|
+
— a closure cannot survive the trip to a worker process:
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
// in a service provider's onBooted(), so the worker registers it too
|
|
299
|
+
Ai.onGenerated("summarize-ticket", async (response, meta) => {
|
|
300
|
+
await Ticket.query().where("id", meta.ticketId).update({ summary: response.text });
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// anywhere
|
|
304
|
+
await Ai.queue({ prompt }, { handler: "summarize-ticket", meta: { ticketId } });
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
`tools` and `signal` are stripped at dispatch rather than silently arriving as
|
|
308
|
+
`undefined` — a queued generation is a one-shot completion, and `Ai.agent()` stays
|
|
309
|
+
in-process where its tools are.
|
|
310
|
+
|
|
311
|
+
## Testing
|
|
312
|
+
|
|
313
|
+
`AiFake` replaces the container binding and answers from a script. No API key, no
|
|
314
|
+
network, no flakiness:
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
import { AiFake } from "@zerotal/ai";
|
|
318
|
+
|
|
319
|
+
const ai = AiFake.install();
|
|
320
|
+
ai.respondWith("A one-sentence summary.");
|
|
321
|
+
|
|
322
|
+
await service.summarize(article);
|
|
323
|
+
|
|
324
|
+
ai.assertPrompted(/Summarize/);
|
|
325
|
+
ai.assertPromptCount(1);
|
|
326
|
+
|
|
327
|
+
ai.restore(); // in afterEach
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
The assertions are about **what your application asked for** — the part you wrote and
|
|
331
|
+
the part that can be wrong. Whether the model's prose is good is not a unit test.
|
|
332
|
+
|
|
333
|
+
`ai.refuse()` makes the next call decline, which is worth exercising deliberately: a
|
|
334
|
+
refusal is an HTTP 200, so that handling path is the one most likely never to have run.
|
|
335
|
+
|
|
336
|
+
## Observability
|
|
337
|
+
|
|
338
|
+
Every generation emits `AiGenerated` on the framework event bus, and a decline also
|
|
339
|
+
emits `AiRefused`. With `@zerotal/monitor` installed, the **AI** section shows spend
|
|
340
|
+
against the daily ceiling, tokens in and out, cache reads, latency percentiles per
|
|
341
|
+
model, and the refusal rate.
|
|
342
|
+
|
|
343
|
+
**Prompts are redacted by default.** A prompt is user data, and the observability path
|
|
344
|
+
is the one place it would otherwise be durably kept — so what is recorded is a shape,
|
|
345
|
+
`[redacted 38 chars]`, not the text. Set `redact: false` to record a truncated preview
|
|
346
|
+
instead.
|
|
347
|
+
|
|
348
|
+
## Commands
|
|
349
|
+
|
|
350
|
+
| Command | What it does |
|
|
351
|
+
| --------------------- | -------------------------------------------------------- |
|
|
352
|
+
| `zt ai:test [driver]` | Reach the provider once and print the **resolved model** |
|
|
353
|
+
| `zt ai:spend` | This process's token spend today, by model |
|
|
354
|
+
|
|
355
|
+
`ai:test` exists because AI configuration fails in ways unit tests cannot reach: a key
|
|
356
|
+
with no access to the model, a model id that 404s because someone appended a date
|
|
357
|
+
suffix, a gateway that rewrites the base URL.
|
|
358
|
+
|
|
359
|
+
## Adding a provider
|
|
360
|
+
|
|
361
|
+
Implement `AiDriver` — `text`, `stream`, `object`, `countTokens`, `verify` — and
|
|
362
|
+
register it:
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
// in a service provider's onBooted()
|
|
366
|
+
const ai = app.container.makeSync("ai");
|
|
367
|
+
ai.extend("bedrock", () => new BedrockDriver(config));
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Nothing else is needed. Spend ceilings, redaction, telemetry, the lock, and the agent
|
|
371
|
+
loop all live above the driver, so a new provider is a translation layer and nothing
|
|
372
|
+
more. `agent()` is optional on the interface and none of the built-in three implement
|
|
373
|
+
it — they all run the same shared loop.
|