@zerotal/ai 1.10.0 → 1.11.1

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 CHANGED
@@ -8,6 +8,50 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.11.0] — 2026-08-31
12
+
13
+ ### Fixed
14
+
15
+ - **An empty string is an answer.** `required` treats `""` as absent, which is right
16
+ for an HTML form — an empty text input submits `""` — and wrong for structured
17
+ model output, where `""` is the conventional way to say _"this field does not
18
+ apply"_ and is what a prompt naturally asks for. So `rule.string()` rejected the
19
+ answer a prompt had requested, and the whole feature returned nothing: an app's
20
+ questions mostly named no month, the model replied `""` in 3.3 seconds every time,
21
+ and the page said "either no model is configured, or it was not about your money"
22
+ while a model was configured and had answered.
23
+
24
+ `""` now counts as present on a **string** field in the AI path only. Absence is
25
+ still a failure, every other constraint still applies (a `min(3)` still rejects
26
+ `""`), and a non-string field is untouched — `""` for a number is a malformed
27
+ answer, not a convention.
28
+
29
+ - **`AiFake` validates what it is scripted with.** `respondWithObject()` handed the
30
+ canned value back unexamined, so a fake answer the real driver would reject passed
31
+ every test. An app scripted `{ month: "" }`, eleven tests passed on it, and the live
32
+ path rejected the identical value every time — the feature shipped green and
33
+ answered nothing. The permissive fake is what made the bug above invisible; they are
34
+ the same defect from both ends.
35
+
36
+ `AiFake.object()` now takes the schema `AiManager.object()` takes, and checks the
37
+ scripted object through the same `recheckAgainstSchema` a driver uses. Omit the
38
+ schema and nothing is checked, because there is nothing to check against.
39
+
40
+ ### Added
41
+
42
+ - **`AiError.transient`** — `true` for _this call failed_, `false` for _this machine
43
+ cannot do this_. A service calling a model per row has to latch itself off after a
44
+ permanent failure, or a machine with no API key pays the driver's timeout per row,
45
+ per merchant, per page load — 8s × 12 merchants is ninety seconds of blank page.
46
+
47
+ Writing that latch meant classifying eleven error classes by hand, and the mistake
48
+ is unrecoverable in one direction: call something permanent that is not, and the
49
+ feature disables itself for the life of the process, silently, because every call
50
+ site already treats "no answer" as normal. An app classified `AiSchemaError` as
51
+ permanent and would have turned two features off on their first badly-shaped reply.
52
+ **It is transient** — sampling is not deterministic. Only this package knows what a
53
+ new error class means, so the judgement now lives here.
54
+
11
55
  ## [1.5.0] — 2026-08-15
12
56
 
13
57
  ### Added
package/api-surface.md CHANGED
@@ -10,6 +10,7 @@ class AiAgentLimitError = {
10
10
  readonly code: string
11
11
  readonly context?: Record<string, unknown> | undefined
12
12
  readonly status: number
13
+ readonly transient: boolean
13
14
  }
14
15
 
15
16
  class AiCancelledError = {
@@ -17,6 +18,7 @@ class AiCancelledError = {
17
18
  readonly code: string
18
19
  readonly context?: Record<string, unknown> | undefined
19
20
  readonly status: number
21
+ readonly transient: boolean
20
22
  }
21
23
 
22
24
  class AiConfigError = {
@@ -24,6 +26,7 @@ class AiConfigError = {
24
26
  readonly code: string
25
27
  readonly context?: Record<string, unknown> | undefined
26
28
  readonly status: number
29
+ readonly transient: boolean
27
30
  }
28
31
 
29
32
  class AiDriverUnavailableError = {
@@ -31,13 +34,15 @@ class AiDriverUnavailableError = {
31
34
  readonly code: string
32
35
  readonly context?: Record<string, unknown> | undefined
33
36
  readonly status: number
37
+ readonly transient: boolean
34
38
  }
35
39
 
36
40
  class AiError = {
37
- new (message: string, code?: string, status?: number, context?: Record<string, unknown>): AiError
41
+ new (message: string, code?: string, status?: number, context?: Record<string, unknown>, transient?: boolean): AiError
38
42
  readonly code: string
39
43
  readonly context?: Record<string, unknown> | undefined
40
44
  readonly status: number
45
+ readonly transient: boolean
41
46
  }
42
47
 
43
48
  class AiFake = {
@@ -55,7 +60,7 @@ class AiFake = {
55
60
  embed: (input: string | string[], _options?: Omit<AiEmbedRequest, 'input'>) => Promise<AiEmbedResponse>
56
61
  generate: (request: AiRequest | string) => Promise<AiResponse>
57
62
  handlerFor: (name: string) => AiQueueHandler | undefined
58
- object: <T = Record<string, unknown>>(request: AiRequest | string) => Promise<T>
63
+ object: <T = Record<string, unknown>>(request: AiRequest | string, schema?: SchemaInput | ((rule: RuleBuilder) => SchemaInput)) => Promise<T>
59
64
  onGenerated: (name: string, handler: AiQueueHandler) => AiFake
60
65
  prompts: string[]
61
66
  queue: (request: AiRequest, options: AiQueueOptions) => Promise<void>
@@ -131,6 +136,7 @@ class AiRateLimitError = {
131
136
  readonly context?: Record<string, unknown> | undefined
132
137
  readonly retryAfterSeconds?: number | undefined
133
138
  readonly status: number
139
+ readonly transient: boolean
134
140
  }
135
141
 
136
142
  class AiRefused = {
@@ -149,6 +155,7 @@ class AiRefusedError = {
149
155
  readonly explanation: string | null
150
156
  readonly partialText: string
151
157
  readonly status: number
158
+ readonly transient: boolean
152
159
  }
153
160
 
154
161
  class AiRequestError = {
@@ -157,6 +164,7 @@ class AiRequestError = {
157
164
  readonly context?: Record<string, unknown> | undefined
158
165
  readonly providerStatus: number
159
166
  readonly status: number
167
+ readonly transient: boolean
160
168
  }
161
169
 
162
170
  class AiSchemaError = {
@@ -164,6 +172,7 @@ class AiSchemaError = {
164
172
  readonly code: string
165
173
  readonly context?: Record<string, unknown> | undefined
166
174
  readonly status: number
175
+ readonly transient: boolean
167
176
  }
168
177
 
169
178
  class AiSpendLimitError = {
@@ -171,6 +180,7 @@ class AiSpendLimitError = {
171
180
  readonly code: string
172
181
  readonly context?: Record<string, unknown> | undefined
173
182
  readonly status: number
183
+ readonly transient: boolean
174
184
  }
175
185
 
176
186
  class AiToolCalled = {
@@ -235,6 +245,7 @@ class UnknownAiDriverError = {
235
245
  readonly code: string
236
246
  readonly context?: Record<string, unknown> | undefined
237
247
  readonly status: number
248
+ readonly transient: boolean
238
249
  }
239
250
 
240
251
  const Ai = AiManager
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/ai",
3
- "version": "1.10.0",
3
+ "version": "1.11.1",
4
4
  "license": "MIT",
5
5
  "maturity": "experimental",
6
6
  "maturityReview": "1.11.0",
@@ -31,9 +31,9 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@zerotal/core": "1.10.0",
35
- "@zerotal/validator": "1.10.0",
36
- "@zerotal/queue": "1.10.0"
34
+ "@zerotal/core": "1.11.1",
35
+ "@zerotal/validator": "1.11.1",
36
+ "@zerotal/queue": "1.11.1"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@anthropic-ai/sdk": ">=0.70.0"
package/src/AiFake.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { Application, currentApp } from "@zerotal/core";
2
- import { AiRefusedError } from "./errors.ts";
2
+ import { AiRefusedError, AiSchemaError } from "./errors.ts";
3
+ import { recheckAgainstSchema, _resolveSchema, type SchemaInput } from "./schema.ts";
4
+ import type { RuleBuilder } from "@zerotal/validator";
3
5
  import { normalizeMessages, promptText, type DriverStatus } from "./drivers/AiDriver.ts";
4
6
  import type { AiAgentRequest, AiQueueHandler, AiQueueOptions } from "./AiManager.ts";
5
7
  import type {
@@ -158,7 +160,28 @@ export class AiFake {
158
160
  };
159
161
  }
160
162
 
161
- async object<T = Record<string, unknown>>(request: AiRequest | string): Promise<T> {
163
+ /**
164
+ * Return the next scripted object — after checking it against the same schema the
165
+ * real driver would.
166
+ *
167
+ * This used to hand the canned value back unexamined, and that is a fake that
168
+ * makes tests *less* informative than no test. An app scripted `{ month: "" }`,
169
+ * eleven tests passed on it, and the live path rejected the identical answer every
170
+ * time — so the suite was green about a feature that returned nothing in
171
+ * production. The permissive fake is what made the schema bug invisible; they are
172
+ * the same defect seen from both ends.
173
+ *
174
+ * The check runs only when the caller passed a schema, because that is the only
175
+ * case where there is anything to check. A mismatch throws here, in the test, with
176
+ * the field named — which is the whole point.
177
+ *
178
+ * @param request - The request, or just a prompt.
179
+ * @param schema - The schema the production call declares.
180
+ */
181
+ async object<T = Record<string, unknown>>(
182
+ request: AiRequest | string,
183
+ schema?: SchemaInput | ((rule: RuleBuilder) => SchemaInput),
184
+ ): Promise<T> {
162
185
  const normalized = capture(request);
163
186
  this._record("object", normalized);
164
187
  this._maybeRefuse();
@@ -169,7 +192,26 @@ export class AiFake {
169
192
  "ai.respondWithObject({ … }) before the code under test runs.",
170
193
  );
171
194
  }
172
- return (this._objects.length > 1 ? this._objects.shift() : this._objects[0]) as T;
195
+
196
+ const scripted = (
197
+ this._objects.length > 1 ? this._objects.shift() : this._objects[0]
198
+ ) as unknown;
199
+
200
+ if (schema === undefined) return scripted as T;
201
+
202
+ const resolved = await _resolveSchema(schema);
203
+ try {
204
+ return recheckAgainstSchema<T>(resolved, scripted);
205
+ } catch (error) {
206
+ throw new AiSchemaError(
207
+ `[Zerotal/ai] The object scripted with respondWithObject() does not satisfy the ` +
208
+ `schema this call declares, so the real driver would reject it too — and a test ` +
209
+ `passing on it would be green about a call that fails in production.
210
+ ` +
211
+ `${(error as Error).message}`,
212
+ { scripted },
213
+ );
214
+ }
173
215
  }
174
216
 
175
217
  async agent(request: AiAgentRequest): Promise<AiAgentResult> {
package/src/AiManager.ts CHANGED
@@ -12,7 +12,7 @@ import { AiCancelledError, AiConfigError, AiRefusedError, UnknownAiDriverError }
12
12
  import { AiGenerated, AiRefused } from "./events.ts";
13
13
  import { estimateCost } from "./pricing.ts";
14
14
  import { redactPrompt } from "./redact.ts";
15
- import type { SchemaInput } from "./schema.ts";
15
+ import { _resolveSchema, type SchemaInput } from "./schema.ts";
16
16
  import { assertWithinLimits, recordSpend } from "./spend.ts";
17
17
  import type {
18
18
  AiAgentResult,
@@ -266,7 +266,7 @@ export class AiManager {
266
266
  ): Promise<T> {
267
267
  const normalized = normalize(request);
268
268
  const driver = this.driver(normalized.driver);
269
- const resolved = await resolveSchema(schema);
269
+ const resolved = await _resolveSchema(schema);
270
270
  const startedAt = performance.now();
271
271
 
272
272
  try {
@@ -553,14 +553,6 @@ function normalize(request: AiRequest | string): AiRequest {
553
553
  }
554
554
 
555
555
  /** Accept either a schema map or a `(rule) => schema` factory. */
556
- async function resolveSchema(
557
- schema: SchemaInput | ((rule: import("@zerotal/validator").RuleBuilder) => SchemaInput),
558
- ): Promise<SchemaInput> {
559
- if (typeof schema !== "function") return schema;
560
- const { RuleBuilder } = await import("@zerotal/validator");
561
- return schema(new RuleBuilder());
562
- }
563
-
564
556
  /** The `lock` binding, or `undefined` when the app has no LockProvider. */
565
557
  async function lockManager(): Promise<import("@zerotal/core/lock").LockManager | undefined> {
566
558
  try {
package/src/errors.ts CHANGED
@@ -1,9 +1,52 @@
1
1
  import { ZerotalError } from "@zerotal/core";
2
2
 
3
- /** Base class for all `@zerotal/ai` errors. */
3
+ /**
4
+ * Base class for all `@zerotal/ai` errors.
5
+ *
6
+ * Every one of them carries {@link transient}, because the caller cannot work it out
7
+ * and the package can.
8
+ */
4
9
  export class AiError extends ZerotalError {
5
- constructor(message: string, code = "E_AI", status = 500, context?: Record<string, unknown>) {
10
+ /**
11
+ * Whether retrying could plausibly succeed — `true` for *this call failed*, `false`
12
+ * for *this machine cannot do this*.
13
+ *
14
+ * The distinction exists because a service that calls a model per row has to latch
15
+ * itself off after a permanent failure. Without that, a laptop with no API key pays
16
+ * the driver's timeout per row, per merchant, per page load — measured at 8s × 12
17
+ * merchants, which is ninety seconds of blank page.
18
+ *
19
+ * Writing that latch meant classifying eleven error classes by hand, and the
20
+ * permissive mistake is unrecoverable: get it wrong toward "permanent" and a
21
+ * feature disables itself for the lifetime of the process, silently, because every
22
+ * call site already treats "no answer" as normal. An app classified
23
+ * {@link AiSchemaError} as permanent and would have turned two features off on
24
+ * their first badly-shaped answer.
25
+ *
26
+ * So the judgement lives here, where the knowledge is. Only this package knows
27
+ * whether a new error class means "this call" or "this machine".
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * try {
32
+ * return await Ai.object(prompt, schema);
33
+ * } catch (error) {
34
+ * if (error instanceof AiError && !error.transient) this.disabled = true;
35
+ * return null;
36
+ * }
37
+ * ```
38
+ */
39
+ readonly transient: boolean;
40
+
41
+ constructor(
42
+ message: string,
43
+ code = "E_AI",
44
+ status = 500,
45
+ context?: Record<string, unknown>,
46
+ transient = false,
47
+ ) {
6
48
  super(message, code, status, context);
49
+ this.transient = transient;
7
50
  }
8
51
  }
9
52
 
@@ -18,6 +61,8 @@ export class UnknownAiDriverError extends AiError {
18
61
  "E_AI_UNKNOWN_DRIVER",
19
62
  500,
20
63
  { driver, known },
64
+ // A name that does not exist will not start existing. Permanent.
65
+ false,
21
66
  );
22
67
  }
23
68
  }
@@ -25,7 +70,8 @@ export class UnknownAiDriverError extends AiError {
25
70
  /** Thrown at boot, or on first use, for a config combination that cannot work. */
26
71
  export class AiConfigError extends AiError {
27
72
  constructor(message: string, context?: Record<string, unknown>) {
28
- super(`[Zerotal/ai] ${message}`, "E_AI_CONFIG", 500, context);
73
+ // Configuration does not fix itself between two calls. Permanent.
74
+ super(`[Zerotal/ai] ${message}`, "E_AI_CONFIG", 500, context, false);
29
75
  }
30
76
  }
31
77
 
@@ -43,6 +89,9 @@ export class AiDriverUnavailableError extends AiError {
43
89
  "E_AI_DRIVER_UNAVAILABLE",
44
90
  500,
45
91
  { driver, packageName },
92
+ // A missing package is missing for the life of the process. Permanent, and the
93
+ // one this distinction exists for — it is what a laptop with no key hits.
94
+ false,
46
95
  );
47
96
  }
48
97
  }
@@ -72,6 +121,10 @@ export class AiRefusedError extends AiError {
72
121
  // policy decision about the content, which is what 422 says.
73
122
  422,
74
123
  { category, explanation },
124
+ // Transient: a refusal is about *this content*, not about the machine. The
125
+ // next prompt may be fine, and latching a feature off because one request was
126
+ // declined would disable it for everybody over one user's question.
127
+ true,
75
128
  );
76
129
  }
77
130
  }
@@ -82,7 +135,8 @@ export class AiRateLimitError extends AiError {
82
135
  message: string,
83
136
  readonly retryAfterSeconds?: number,
84
137
  ) {
85
- super(`[Zerotal/ai] ${message}`, "E_AI_RATE_LIMIT", 429, { retryAfterSeconds });
138
+ // The provider is telling you when to come back. Transient by definition.
139
+ super(`[Zerotal/ai] ${message}`, "E_AI_RATE_LIMIT", 429, { retryAfterSeconds }, true);
86
140
  }
87
141
  }
88
142
 
@@ -93,37 +147,52 @@ export class AiRequestError extends AiError {
93
147
  readonly providerStatus: number,
94
148
  context?: Record<string, unknown>,
95
149
  ) {
96
- super(`[Zerotal/ai] ${message}`, "E_AI_REQUEST", providerStatus >= 500 ? 502 : 400, {
97
- providerStatus,
98
- ...context,
99
- });
150
+ super(
151
+ `[Zerotal/ai] ${message}`,
152
+ "E_AI_REQUEST",
153
+ providerStatus >= 500 ? 502 : 400,
154
+ { providerStatus, ...context },
155
+ // The only classification here that reads a value, and the split is the
156
+ // provider's own: 5xx is the provider having a bad moment and 408/429 say so
157
+ // outright, while a 4xx is this request being wrong in a way that repeating it
158
+ // will not fix — a bad key, a model name that does not exist, a payload the
159
+ // API rejects.
160
+ providerStatus >= 500 || providerStatus === 408 || providerStatus === 429,
161
+ );
100
162
  }
101
163
  }
102
164
 
103
165
  /** Thrown when a request would breach a configured spend ceiling. */
104
166
  export class AiSpendLimitError extends AiError {
105
167
  constructor(message: string, context?: Record<string, unknown>) {
106
- super(`[Zerotal/ai] ${message}`, "E_AI_SPEND_LIMIT", 429, context);
168
+ // A budget resets on its window. Transient, though a caller may reasonably
169
+ // back off much harder than it would for a rate limit.
170
+ super(`[Zerotal/ai] ${message}`, "E_AI_SPEND_LIMIT", 429, context, true);
107
171
  }
108
172
  }
109
173
 
110
174
  /** Thrown when a validator schema uses a constraint structured output cannot express. */
111
175
  export class AiSchemaError extends AiError {
112
176
  constructor(message: string, context?: Record<string, unknown>) {
113
- super(`[Zerotal/ai] ${message}`, "E_AI_SCHEMA", 500, context);
177
+ // Transient, and deliberately so. A model that shaped one answer badly may
178
+ // shape the next one correctly — sampling is not deterministic. An app called
179
+ // this permanent and would have disabled two features on a single bad reply.
180
+ super(`[Zerotal/ai] ${message}`, "E_AI_SCHEMA", 500, context, true);
114
181
  }
115
182
  }
116
183
 
117
184
  /** Thrown when the agent loop hits its step or resume ceiling. */
118
185
  export class AiAgentLimitError extends AiError {
119
186
  constructor(message: string, context?: Record<string, unknown>) {
120
- super(`[Zerotal/ai] ${message}`, "E_AI_AGENT_LIMIT", 500, context);
187
+ // The loop hit its own ceiling on this run. Another run may not. Transient.
188
+ super(`[Zerotal/ai] ${message}`, "E_AI_AGENT_LIMIT", 500, context, true);
121
189
  }
122
190
  }
123
191
 
124
192
  /** Thrown when the caller's `AbortSignal` fired before the call finished. */
125
193
  export class AiCancelledError extends AiError {
126
194
  constructor(message = "The generation was cancelled.") {
127
- super(`[Zerotal/ai] ${message}`, "E_AI_CANCELLED", 499);
195
+ // Somebody asked for this to stop. Nothing is wrong with the machine.
196
+ super(`[Zerotal/ai] ${message}`, "E_AI_CANCELLED", 499, undefined, true);
128
197
  }
129
198
  }
package/src/schema.ts CHANGED
@@ -258,7 +258,7 @@ export function recheckAgainstSchema<T>(input: SchemaInput, value: unknown): T {
258
258
  }
259
259
 
260
260
  const cleaned = dropAbsentNulls(schema, value as Record<string, unknown>);
261
- const result = runValidation(schema, cleaned);
261
+ const result = runValidation(emptyStringIsPresent(schema, cleaned), cleaned);
262
262
 
263
263
  if (!result.success) {
264
264
  const detail = Object.entries(result.errors)
@@ -290,3 +290,70 @@ function dropAbsentNulls(schema: Schema, value: Record<string, unknown>): Record
290
290
 
291
291
  return out;
292
292
  }
293
+
294
+ /**
295
+ * Let `""` count as an answer on a required string field.
296
+ *
297
+ * `required` treats the empty string as absent, and for an HTML form that is exactly
298
+ * right: an empty text input submits `""`, and a user who typed nothing has supplied
299
+ * nothing. Structured model output is not a form. There, `""` is the conventional way
300
+ * to say *"this field does not apply"* — it is what a prompt naturally asks for, and
301
+ * a model that returns it has answered rather than declined:
302
+ *
303
+ * > A month must be YYYY-MM. Use an empty string when the question names no month.
304
+ *
305
+ * With the form rule applied, that correct answer was rejected as malformed and the
306
+ * whole feature returned nothing. An app shipped exactly that: most questions named
307
+ * no month, the model returned `""` in 3.3 seconds every time, and the page said
308
+ * "either no model is configured, or it was not about your money" — while a model
309
+ * was configured and had answered.
310
+ *
311
+ * Only `required` is relaxed, and only for a field the schema declares as a string
312
+ * whose value is exactly `""`. Every other constraint still applies: a
313
+ * `rule.string().min(3)` still rejects `""`, because that length is the app's own
314
+ * requirement rather than a form convention leaking in.
315
+ *
316
+ * @param schema - The declared schema.
317
+ * @param value - The model's parsed answer.
318
+ * @returns A schema to validate with — the same one when nothing needed relaxing.
319
+ */
320
+ function emptyStringIsPresent(schema: Schema, value: Record<string, unknown>): Schema {
321
+ let relaxed: Schema | undefined;
322
+
323
+ for (const [name, def] of Object.entries(schema)) {
324
+ // Only a field that is *present* and holds `""`. An absent field leaves
325
+ // `value[name]` as `undefined`, so `required` still fires on it — which is the
326
+ // difference between "the model said this does not apply" and "the model did
327
+ // not answer", and only the first is an answer.
328
+ if (value[name] !== "") continue;
329
+
330
+ const shape = def as { type?: string; required?: boolean };
331
+ if (shape.type !== "string" || shape.required !== true) continue;
332
+
333
+ relaxed ??= { ...schema };
334
+ // A shallow clone: the caller's schema object is theirs, and mutating it would
335
+ // change every later validation that reuses the same instance.
336
+ relaxed[name] = { ...(def as object), required: false } as Schema[string];
337
+ }
338
+
339
+ return relaxed ?? schema;
340
+ }
341
+
342
+ /**
343
+ * Resolve a schema that may be declared as a builder callback.
344
+ *
345
+ * `Ai.object(request, (rule) => ({ … }))` is the ergonomic form — it saves the caller
346
+ * an import — and every consumer of a schema has to accept it. Shared so the fake
347
+ * resolves it exactly as the manager does, rather than declining to check a schema it
348
+ * did not recognise.
349
+ *
350
+ * @param schema - A schema, or a callback given a {@link RuleBuilder}.
351
+ * @internal
352
+ */
353
+ export async function _resolveSchema(
354
+ schema: SchemaInput | ((rule: import("@zerotal/validator").RuleBuilder) => SchemaInput),
355
+ ): Promise<SchemaInput> {
356
+ if (typeof schema !== "function") return schema;
357
+ const { RuleBuilder } = await import("@zerotal/validator");
358
+ return schema(new RuleBuilder());
359
+ }