@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 +44 -0
- package/api-surface.md +13 -2
- package/package.json +4 -4
- package/src/AiFake.ts +45 -3
- package/src/AiManager.ts +2 -10
- package/src/errors.ts +81 -12
- package/src/schema.ts +68 -1
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
|
|
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.
|
|
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.
|
|
35
|
-
"@zerotal/validator": "1.
|
|
36
|
-
"@zerotal/queue": "1.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|