@tanstack-ai-browser/afm 0.2.1 → 0.2.3
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/README.md +14 -1
- package/dist/adapter.d.ts +13 -2
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +39 -5
- package/dist/electron.d.ts +7 -0
- package/dist/electron.d.ts.map +1 -1
- package/dist/electron.js +5 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/json-value.d.ts +9 -0
- package/dist/json-value.d.ts.map +1 -0
- package/dist/json-value.js +19 -0
- package/dist/native.d.ts +14 -2
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +1 -1
- package/dist/validate.d.ts +2 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +124 -0
- package/electron/AfmNode.swift +1 -1
- package/electron/afm.node +0 -0
- package/electron/afm.node.sha256 +1 -1
- package/ios/Sources/AfmCore/Bridge.swift +47 -8
- package/ios/Sources/AfmPlugin/AfmPlugin.swift +1 -1
- package/package.json +5 -5
- package/src/adapter.test.ts +61 -1
- package/src/adapter.ts +51 -7
- package/src/electron.ts +13 -0
- package/src/index.ts +1 -0
- package/src/json-value.test.ts +26 -0
- package/src/json-value.ts +17 -0
- package/src/native.ts +12 -3
- package/src/validate.test.ts +38 -0
- package/src/validate.ts +112 -0
package/README.md
CHANGED
|
@@ -24,7 +24,8 @@ import { afmText } from "@tanstack-ai-browser/afm"
|
|
|
24
24
|
|
|
25
25
|
const adapter = afmText()
|
|
26
26
|
if ((await adapter.availability()) === "available") {
|
|
27
|
-
|
|
27
|
+
// One threadId per conversation: its follow-ups reuse the same native session.
|
|
28
|
+
const stream = chat({ adapter, threadId, messages, tools })
|
|
28
29
|
}
|
|
29
30
|
```
|
|
30
31
|
|
|
@@ -32,6 +33,18 @@ if ((await adapter.availability()) === "available") {
|
|
|
32
33
|
|
|
33
34
|
Each thread (`threadId`) keeps a native session, so a follow-up message only sends that message. Structured output (`chat({ outputSchema })`) is held to the schema by the framework.
|
|
34
35
|
|
|
36
|
+
### Guardrails
|
|
37
|
+
|
|
38
|
+
Apple's default guardrails can block ordinary text that sounds unsafe out of context, such as a card game's card names ("add 2 Searing Blood", "swap the Shocks for Play with Fire"), with a `guardrailViolation` error. `guardrails: "permissiveContentTransformations"` relaxes them:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
const adapter = afmText({ guardrails: "permissiveContentTransformations" })
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
A request's `modelOptions.guardrails` overrides the adapter's, and in Electron `serveAfm({ guardrails })` sets them for every page that names none. `"default"` is the default.
|
|
45
|
+
|
|
46
|
+
Apple's permissive guardrails cover text responses only: anything else, such as structured output (`chat({ outputSchema })`), gets the default guardrails. So when a permissive session's structured answer is blocked, the adapter asks for it again as text: a JSON value to the schema. The framework doesn't hold that answer to the schema while it's written, so the adapter parses the whole answer (or everything inside a code fence) and checks it against the schema before returning it; one that doesn't fit fails with the code `schemaMismatch`, and one that isn't JSON with `invalidJson`. (`chat()` itself validates only schemas that come with a Standard Schema validator, not plain JSON Schema.) Tool calls' arguments keep the default guardrails. A guardrail violation that still happens has the code `guardrailViolation`: on the error `chat()` throws, and on a stream's `RUN_ERROR`. Apple words it "May contain unsafe content" or "Response may contain sensitive or unsafe content"; it's one check either way. With structured output (`chat({ outputSchema })`), TanStack AI 1.4 doesn't carry an adapter's error code through, so there only the message tells.
|
|
47
|
+
|
|
35
48
|
### Electron (macOS)
|
|
36
49
|
|
|
37
50
|
The page is the same as on iOS: `afmText()`. The main process runs the model, with one call before opening windows:
|
package/dist/adapter.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { AdapterYieldChunk, TextOptions } from "@tanstack/ai";
|
|
|
2
2
|
import { BaseTextAdapter } from "@tanstack/ai/adapters";
|
|
3
3
|
import type { StructuredOutputOptions, StructuredOutputResult } from "@tanstack/ai/adapters";
|
|
4
4
|
import type { AfmChatModel } from "./model-meta.js";
|
|
5
|
-
import type { AfmNative } from "./native.js";
|
|
5
|
+
import type { AfmGuardrails, AfmNative } from "./native.js";
|
|
6
6
|
export type AfmConfig = {
|
|
7
7
|
/**
|
|
8
8
|
* The native side. By default `window.afm` where a host put it there
|
|
@@ -10,12 +10,20 @@ export type AfmConfig = {
|
|
|
10
10
|
* plugin (`Afm`).
|
|
11
11
|
*/
|
|
12
12
|
native?: AfmNative;
|
|
13
|
+
/**
|
|
14
|
+
* Apple's guardrails: "default" (the default), or "permissiveContentTransformations",
|
|
15
|
+
* for text the default ones block out of context, such as a card game's card
|
|
16
|
+
* names. A request's `modelOptions.guardrails` overrides it.
|
|
17
|
+
*/
|
|
18
|
+
guardrails?: AfmGuardrails;
|
|
13
19
|
};
|
|
14
|
-
/** Per-call `modelOptions`: the response's sampling and length. */
|
|
20
|
+
/** Per-call `modelOptions`: the response's sampling and length, and the guardrails. */
|
|
15
21
|
export type AfmTextModelOptions = {
|
|
16
22
|
temperature?: number;
|
|
17
23
|
/** Most tokens to generate. */
|
|
18
24
|
maximumResponseTokens?: number;
|
|
25
|
+
/** Apple's guardrails for this request's session, over the adapter's `guardrails`. */
|
|
26
|
+
guardrails?: AfmGuardrails;
|
|
19
27
|
};
|
|
20
28
|
export type Availability = "unavailable" | "downloadable" | "downloading" | "available";
|
|
21
29
|
type MetadataByModality = {
|
|
@@ -41,6 +49,9 @@ export declare class AfmTextAdapter extends BaseTextAdapter<AfmChatModel, AfmTex
|
|
|
41
49
|
private readonly paused;
|
|
42
50
|
private listening;
|
|
43
51
|
constructor(config: AfmConfig, model: AfmChatModel);
|
|
52
|
+
private readonly guardrails;
|
|
53
|
+
/** A request's guardrails: its own, else the adapter's (left out: the host's, else Apple's default). */
|
|
54
|
+
private guardrailsOf;
|
|
44
55
|
/** Whether Apple's model can run here: "downloading" while Apple Intelligence gets it. */
|
|
45
56
|
availability(): Promise<Availability>;
|
|
46
57
|
/** Tokens a session holds, where the OS says (26.4 and later). */
|
package/dist/adapter.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAE,iBAAiB,EAAgB,WAAW,EAAoB,MAAM,cAAc,CAAA;AAClG,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAA;AACvD,OAAO,KAAK,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAA;AAG5F,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAEnD,OAAO,KAAK,EAAE,SAAS,EAA8D,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"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"}
|
package/dist/adapter.js
CHANGED
|
@@ -21,6 +21,8 @@ import { BaseTextAdapter } from "@tanstack/ai/adapters";
|
|
|
21
21
|
import { contentText, toNativeMessages } from "./messages.js";
|
|
22
22
|
import { Afm } from "./native.js";
|
|
23
23
|
import { toNativeSchema } from "./schema.js";
|
|
24
|
+
import { parseJsonValue } from "./json-value.js";
|
|
25
|
+
import { schemaErrors } from "./validate.js";
|
|
24
26
|
/** Events of one native response, as they come. */
|
|
25
27
|
class EventQueue {
|
|
26
28
|
events = [];
|
|
@@ -102,6 +104,26 @@ const usageOf = (usage) => usage && {
|
|
|
102
104
|
let ids = 0;
|
|
103
105
|
const nextId = (prefix) => `${prefix}_${Date.now().toString(36)}_${(ids++).toString(36)}`;
|
|
104
106
|
const signalOf = (options) => options.abortController?.signal ?? options.request?.signal ?? undefined;
|
|
107
|
+
/**
|
|
108
|
+
* A structured answer the model wrote as text (permissive guardrails' fallback,
|
|
109
|
+
* which the framework didn't hold to the schema): its JSON value, if it fits the
|
|
110
|
+
* schema. chat() validates only schemas with a Standard Schema validator, not
|
|
111
|
+
* plain JSON Schema, so the adapter checks it here.
|
|
112
|
+
*/
|
|
113
|
+
function checkedAnswer(text, schema) {
|
|
114
|
+
let data;
|
|
115
|
+
try {
|
|
116
|
+
data = parseJsonValue(text);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
throw Object.assign(new Error(`Apple's model wrote no JSON for the schema (its answer, written as text past its guardrails, was ${JSON.stringify(text.slice(0, 120))})`, { cause: error }), { code: "invalidJson" });
|
|
120
|
+
}
|
|
121
|
+
const problems = schemaErrors(schema, data);
|
|
122
|
+
if (problems.length > 0) {
|
|
123
|
+
throw Object.assign(new Error(`Apple's model's answer, written as text past its guardrails, doesn't fit the schema: ${problems.slice(0, 5).join("; ")}`), { code: "schemaMismatch" });
|
|
124
|
+
}
|
|
125
|
+
return data;
|
|
126
|
+
}
|
|
105
127
|
const parse = (json) => {
|
|
106
128
|
try {
|
|
107
129
|
return JSON.parse(json);
|
|
@@ -128,11 +150,17 @@ export class AfmTextAdapter extends BaseTextAdapter {
|
|
|
128
150
|
constructor(config, model) {
|
|
129
151
|
super({}, model);
|
|
130
152
|
this.native = config.native ?? globalThis.afm ?? Afm;
|
|
153
|
+
this.guardrails = config.guardrails;
|
|
154
|
+
}
|
|
155
|
+
guardrails;
|
|
156
|
+
/** A request's guardrails: its own, else the adapter's (left out: the host's, else Apple's default). */
|
|
157
|
+
guardrailsOf(options) {
|
|
158
|
+
return options.modelOptions?.guardrails ?? this.guardrails;
|
|
131
159
|
}
|
|
132
160
|
/** Whether Apple's model can run here: "downloading" while Apple Intelligence gets it. */
|
|
133
161
|
async availability() {
|
|
134
162
|
try {
|
|
135
|
-
const answer = await this.native.availability();
|
|
163
|
+
const answer = await this.native.availability(this.guardrails ? { guardrails: this.guardrails } : undefined);
|
|
136
164
|
return answer.available ? "available" : AVAILABILITY[answer.reason ?? "unavailable"];
|
|
137
165
|
}
|
|
138
166
|
catch {
|
|
@@ -142,7 +170,7 @@ export class AfmTextAdapter extends BaseTextAdapter {
|
|
|
142
170
|
/** Tokens a session holds, where the OS says (26.4 and later). */
|
|
143
171
|
async contextSize() {
|
|
144
172
|
try {
|
|
145
|
-
return (await this.native.availability()).contextSize;
|
|
173
|
+
return (await this.native.availability(this.guardrails ? { guardrails: this.guardrails } : undefined)).contextSize;
|
|
146
174
|
}
|
|
147
175
|
catch {
|
|
148
176
|
return undefined;
|
|
@@ -219,7 +247,8 @@ export class AfmTextAdapter extends BaseTextAdapter {
|
|
|
219
247
|
description: tool.description ?? "",
|
|
220
248
|
parameters: toNativeSchema(tool.inputSchema ?? { type: "object", properties: {} }, tool.name)
|
|
221
249
|
})),
|
|
222
|
-
messages
|
|
250
|
+
messages,
|
|
251
|
+
...(this.guardrailsOf(options) && { guardrails: this.guardrailsOf(options) })
|
|
223
252
|
});
|
|
224
253
|
return thread;
|
|
225
254
|
}
|
|
@@ -241,7 +270,10 @@ export class AfmTextAdapter extends BaseTextAdapter {
|
|
|
241
270
|
maximumResponseTokens,
|
|
242
271
|
// A response keeps the mode it started with: a step with another mode
|
|
243
272
|
// (required, then auto) can't resume it, and starts a new session.
|
|
244
|
-
toolCallingModeOf(options)
|
|
273
|
+
toolCallingModeOf(options),
|
|
274
|
+
// A session's guardrails are its model's: other guardrails, another session.
|
|
275
|
+
// None named isn't "default": the host's (serveAfm's) may be permissive.
|
|
276
|
+
this.guardrailsOf(options) ?? "inherited"
|
|
245
277
|
]);
|
|
246
278
|
const keys = options.messages.map(messageKey);
|
|
247
279
|
// A kept session, or one whose response waits for tool results.
|
|
@@ -427,6 +459,8 @@ export class AfmTextAdapter extends BaseTextAdapter {
|
|
|
427
459
|
model,
|
|
428
460
|
timestamp: timestamp(),
|
|
429
461
|
message,
|
|
462
|
+
// At the top too: chat() puts only this one on the error it throws.
|
|
463
|
+
...(typeof code === "string" && { code }),
|
|
430
464
|
error: { message, ...(typeof code === "string" && { code }) }
|
|
431
465
|
};
|
|
432
466
|
}
|
|
@@ -472,7 +506,7 @@ export class AfmTextAdapter extends BaseTextAdapter {
|
|
|
472
506
|
const event = await thread.queue.next(signal);
|
|
473
507
|
if (event.type === "done") {
|
|
474
508
|
const usage = usageOf(event.usage);
|
|
475
|
-
return { data: JSON.parse(event.text), rawText: event.text, model: this.model, ...(usage && { usage }) };
|
|
509
|
+
return { data: event.asText ? checkedAnswer(event.text, outputSchema) : JSON.parse(event.text), rawText: event.text, model: this.model, ...(usage && { usage }) };
|
|
476
510
|
}
|
|
477
511
|
if (event.type === "error")
|
|
478
512
|
throw Object.assign(new Error(event.message), { code: event.code });
|
package/dist/electron.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AfmGuardrails } from "./native.js";
|
|
1
2
|
export type ServeAfmOptions = {
|
|
2
3
|
/**
|
|
3
4
|
* The folder with the package's Electron files (afm.node and preload.cjs):
|
|
@@ -12,6 +13,12 @@ export type ServeAfmOptions = {
|
|
|
12
13
|
* but an http(s) one on another host than localhost.
|
|
13
14
|
*/
|
|
14
15
|
allow?: (url: string) => boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Apple's guardrails for every page's sessions that don't name their own
|
|
18
|
+
* (`afmText({ guardrails })` or a request's `modelOptions.guardrails`):
|
|
19
|
+
* "default" (the default), or "permissiveContentTransformations".
|
|
20
|
+
*/
|
|
21
|
+
guardrails?: AfmGuardrails;
|
|
15
22
|
};
|
|
16
23
|
/**
|
|
17
24
|
* Runs Apple's on-device model for the app's pages, where `afmText()` finds it.
|
package/dist/electron.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"electron.d.ts","sourceRoot":"","sources":["../src/electron.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"electron.d.ts","sourceRoot":"","sources":["../src/electron.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AA8BhD,MAAM,MAAM,eAAe,GAAG;IAC5B;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;OAGG;IACH,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAA;IAChC;;;;OAIG;IACH,UAAU,CAAC,EAAE,aAAa,CAAA;CAC3B,CAAA;AAgBD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,OAAO,GAAE,eAAoB,GAAG,IAAI,CAwG5D"}
|
package/dist/electron.js
CHANGED
|
@@ -20,6 +20,7 @@ let served = false;
|
|
|
20
20
|
* macOS 26, or elsewhere, the pages hear the model is unavailable.
|
|
21
21
|
*/
|
|
22
22
|
export function serveAfm(options = {}) {
|
|
23
|
+
const appGuardrails = options.guardrails;
|
|
23
24
|
if (served)
|
|
24
25
|
return;
|
|
25
26
|
served = true;
|
|
@@ -85,6 +86,10 @@ export function serveAfm(options = {}) {
|
|
|
85
86
|
throw new Error("Apple's on-device model needs macOS 26 on a Mac with Apple silicon");
|
|
86
87
|
}
|
|
87
88
|
const options = { ...(typeof args === "object" && args !== null ? args : {}) };
|
|
89
|
+
// The app's guardrails, where the page names none.
|
|
90
|
+
if ((method === "createSession" || method === "availability") && options.guardrails === undefined && appGuardrails) {
|
|
91
|
+
options.guardrails = appGuardrails;
|
|
92
|
+
}
|
|
88
93
|
// The frame's own session: another frame's can't be reached.
|
|
89
94
|
if (typeof options.sessionId === "string") {
|
|
90
95
|
const frameId = frameIdOf(frame);
|
package/dist/index.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export type { Availability, AfmConfig, AfmTextModelOptions } from "./adapter.js"
|
|
|
10
10
|
export { Afm } from "./native.js";
|
|
11
11
|
export { serveAfm } from "./electron.js";
|
|
12
12
|
export type { ServeAfmOptions } from "./electron.js";
|
|
13
|
-
export type { AfmNative, NativeAvailability, NativeEvent, NativeGenerationSchema, NativeMessage, NativeProperty, NativeSchema, NativeTool, NativeUsage, UnavailableReason } from "./native.js";
|
|
13
|
+
export type { AfmGuardrails, AfmNative, NativeAvailability, NativeEvent, NativeGenerationSchema, NativeMessage, NativeProperty, NativeSchema, NativeTool, NativeUsage, UnavailableReason } from "./native.js";
|
|
14
14
|
export { toNativeSchema } from "./schema.js";
|
|
15
15
|
export { AFM_CHAT_MODELS, AFM_MODEL_META } from "./model-meta.js";
|
|
16
16
|
export type { AfmChatModel, AfmModelMeta } from "./model-meta.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACtD,YAAY,EAAE,YAAY,EAAE,SAAS,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAA;AAChF,OAAO,EAAE,GAAG,EAAE,MAAM,aAAa,CAAA;AACjC,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AACxC,YAAY,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AACpD,YAAY,EACV,SAAS,EACT,kBAAkB,EAClB,WAAW,EACX,sBAAsB,EACtB,aAAa,EACb,cAAc,EACd,YAAY,EACZ,UAAU,EACV,WAAW,EACX,iBAAiB,EAClB,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAC5C,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACjE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACtD,YAAY,EAAE,YAAY,EAAE,SAAS,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAA;AAChF,OAAO,EAAE,GAAG,EAAE,MAAM,aAAa,CAAA;AACjC,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AACxC,YAAY,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AACpD,YAAY,EACV,aAAa,EACb,SAAS,EACT,kBAAkB,EAClB,WAAW,EACX,sBAAsB,EACtB,aAAa,EACb,cAAc,EACd,YAAY,EACZ,UAAU,EACV,WAAW,EACX,iBAAiB,EAClB,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAC5C,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACjE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The JSON value in a model's text answer: the whole trimmed text, or, when the
|
|
3
|
+
* model wrapped it in a Markdown code fence, everything inside the fence. Any
|
|
4
|
+
* value: an object, an array, a string, a number, a boolean or null. Never by
|
|
5
|
+
* brace positions, which would cut an array down to one of its objects, or a
|
|
6
|
+
* string that contains braces out of its quotes.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseJsonValue(text: string): unknown;
|
|
9
|
+
//# sourceMappingURL=json-value.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"json-value.d.ts","sourceRoot":"","sources":["../src/json-value.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CASpD"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The JSON value in a model's text answer: the whole trimmed text, or, when the
|
|
3
|
+
* model wrapped it in a Markdown code fence, everything inside the fence. Any
|
|
4
|
+
* value: an object, an array, a string, a number, a boolean or null. Never by
|
|
5
|
+
* brace positions, which would cut an array down to one of its objects, or a
|
|
6
|
+
* string that contains braces out of its quotes.
|
|
7
|
+
*/
|
|
8
|
+
export function parseJsonValue(text) {
|
|
9
|
+
const trimmed = text.trim();
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(trimmed);
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
const fenced = /^```[\w-]*[^\S\n]*\n([\s\S]*?)\n?```$/.exec(trimmed);
|
|
15
|
+
if (fenced)
|
|
16
|
+
return JSON.parse(fenced[1].trim());
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
package/dist/native.d.ts
CHANGED
|
@@ -107,22 +107,34 @@ export type NativeEvent = {
|
|
|
107
107
|
name: string;
|
|
108
108
|
arguments: string;
|
|
109
109
|
}>;
|
|
110
|
-
}
|
|
110
|
+
}
|
|
111
|
+
/** `asText`: a schema's answer written as text (permissive guardrails' fallback), not held to the schema. */
|
|
112
|
+
| {
|
|
111
113
|
type: "done";
|
|
112
114
|
text: string;
|
|
113
115
|
usage?: NativeUsage;
|
|
116
|
+
asText?: boolean;
|
|
114
117
|
} | {
|
|
115
118
|
type: "error";
|
|
116
119
|
code: string;
|
|
117
120
|
message: string;
|
|
118
121
|
});
|
|
122
|
+
/**
|
|
123
|
+
* Apple's guardrails for the model: "default", or "permissiveContentTransformations",
|
|
124
|
+
* which lets through text the default ones can block out of context (a card game's
|
|
125
|
+
* "Searing Blood" or "Play with Fire").
|
|
126
|
+
*/
|
|
127
|
+
export type AfmGuardrails = "default" | "permissiveContentTransformations";
|
|
119
128
|
export interface AfmNative {
|
|
120
|
-
availability(
|
|
129
|
+
availability(options?: {
|
|
130
|
+
guardrails?: AfmGuardrails;
|
|
131
|
+
}): Promise<NativeAvailability>;
|
|
121
132
|
createSession(options: {
|
|
122
133
|
sessionId: string;
|
|
123
134
|
instructions?: string;
|
|
124
135
|
tools: Array<NativeTool>;
|
|
125
136
|
messages: Array<NativeMessage>;
|
|
137
|
+
guardrails?: AfmGuardrails;
|
|
126
138
|
}): Promise<void>;
|
|
127
139
|
prewarm(options: {
|
|
128
140
|
sessionId: string;
|
package/dist/native.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"native.d.ts","sourceRoot":"","sources":["../src/native.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"native.d.ts","sourceRoot":"","sources":["../src/native.ts"],"names":[],"mappings":"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"}
|
package/dist/native.js
CHANGED
|
@@ -10,7 +10,7 @@ const capacitor = () => (loading ??= import("@capacitor/core").then(({ registerP
|
|
|
10
10
|
* its calls reject: Capacitor has no web version of it.
|
|
11
11
|
*/
|
|
12
12
|
export const Afm = {
|
|
13
|
-
availability: async () => (await capacitor()).plugin.availability(),
|
|
13
|
+
availability: async (options) => (await capacitor()).plugin.availability(options),
|
|
14
14
|
createSession: async (options) => (await capacitor()).plugin.createSession(options),
|
|
15
15
|
prewarm: async (options) => (await capacitor()).plugin.prewarm(options),
|
|
16
16
|
respond: async (options) => (await capacitor()).plugin.respond(options),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAiCA,wBAAgB,YAAY,CAAC,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,GAAG,KAAK,CAAC,MAAM,CAAC,CA8E3E"}
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2
|
+
const typeOf = (value) => (value === null ? "null" : Array.isArray(value) ? "array" : typeof value);
|
|
3
|
+
function equal(a, b) {
|
|
4
|
+
if (a === b)
|
|
5
|
+
return true;
|
|
6
|
+
if (Array.isArray(a) && Array.isArray(b))
|
|
7
|
+
return a.length === b.length && a.every((v, i) => equal(v, b[i]));
|
|
8
|
+
if (isObject(a) && isObject(b)) {
|
|
9
|
+
const keys = Object.keys(a);
|
|
10
|
+
return keys.length === Object.keys(b).length && keys.every((k) => Object.hasOwn(b, k) && equal(a[k], b[k]));
|
|
11
|
+
}
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
function fitsType(type, value) {
|
|
15
|
+
if (type === "integer")
|
|
16
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
17
|
+
if (type === "number")
|
|
18
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
19
|
+
return typeOf(value) === type;
|
|
20
|
+
}
|
|
21
|
+
const show = (value) => JSON.stringify(value);
|
|
22
|
+
export function schemaErrors(schema, value) {
|
|
23
|
+
const root = isObject(schema) ? schema : {};
|
|
24
|
+
const errors = [];
|
|
25
|
+
const resolve = (ref) => {
|
|
26
|
+
const match = /^#\/(\$defs|definitions)\/(.+)$/.exec(ref);
|
|
27
|
+
const defs = match ? root[match[1]] : undefined;
|
|
28
|
+
return match && isObject(defs) ? defs[decodeURIComponent(match[2])] : undefined;
|
|
29
|
+
};
|
|
30
|
+
const check = (s, v, path, out) => {
|
|
31
|
+
if (s === true || s === undefined)
|
|
32
|
+
return;
|
|
33
|
+
if (s === false)
|
|
34
|
+
return void out.push(`${path || "/"}: not allowed`);
|
|
35
|
+
if (!isObject(s))
|
|
36
|
+
return;
|
|
37
|
+
if (typeof s.$ref === "string") {
|
|
38
|
+
const target = resolve(s.$ref);
|
|
39
|
+
if (target === undefined)
|
|
40
|
+
return void out.push(`${path || "/"}: unknown $ref ${s.$ref}`);
|
|
41
|
+
check(target, v, path, out);
|
|
42
|
+
}
|
|
43
|
+
const at = path || "/";
|
|
44
|
+
if ("const" in s && !equal(s.const, v))
|
|
45
|
+
out.push(`${at}: should be ${show(s.const)}, not ${show(v)}`);
|
|
46
|
+
if (Array.isArray(s.enum) && !s.enum.some((e) => equal(e, v)))
|
|
47
|
+
out.push(`${at}: ${show(v)} is not one of ${s.enum.map(show).join(", ")}`);
|
|
48
|
+
if (s.type !== undefined) {
|
|
49
|
+
const types = (Array.isArray(s.type) ? s.type : [s.type]).filter((t) => typeof t === "string");
|
|
50
|
+
if (types.length > 0 && !types.some((t) => fitsType(t, v))) {
|
|
51
|
+
out.push(`${at}: should be ${types.join(" or ")}, not ${typeOf(v)}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (Array.isArray(s.allOf))
|
|
56
|
+
for (const part of s.allOf)
|
|
57
|
+
check(part, v, path, out);
|
|
58
|
+
for (const key of ["anyOf", "oneOf"]) {
|
|
59
|
+
const choices = s[key];
|
|
60
|
+
if (!Array.isArray(choices))
|
|
61
|
+
continue;
|
|
62
|
+
const fitting = choices.filter((choice) => {
|
|
63
|
+
const sub = [];
|
|
64
|
+
check(choice, v, path, sub);
|
|
65
|
+
return sub.length === 0;
|
|
66
|
+
}).length;
|
|
67
|
+
if (key === "anyOf" ? fitting === 0 : fitting !== 1)
|
|
68
|
+
out.push(`${at}: fits ${fitting} of its ${key} choices`);
|
|
69
|
+
}
|
|
70
|
+
if (isObject(v)) {
|
|
71
|
+
const properties = isObject(s.properties) ? s.properties : {};
|
|
72
|
+
if (Array.isArray(s.required)) {
|
|
73
|
+
for (const key of s.required)
|
|
74
|
+
if (typeof key === "string" && !Object.hasOwn(v, key))
|
|
75
|
+
out.push(`${path}/${key}: missing`);
|
|
76
|
+
}
|
|
77
|
+
for (const [key, item] of Object.entries(v)) {
|
|
78
|
+
if (Object.hasOwn(properties, key))
|
|
79
|
+
check(properties[key], item, `${path}/${key}`, out);
|
|
80
|
+
else if (s.additionalProperties === false)
|
|
81
|
+
out.push(`${path}/${key}: not a property of the schema`);
|
|
82
|
+
else if (isObject(s.additionalProperties))
|
|
83
|
+
check(s.additionalProperties, item, `${path}/${key}`, out);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (Array.isArray(v)) {
|
|
87
|
+
if (typeof s.minItems === "number" && v.length < s.minItems)
|
|
88
|
+
out.push(`${at}: fewer than ${s.minItems} items`);
|
|
89
|
+
if (typeof s.maxItems === "number" && v.length > s.maxItems)
|
|
90
|
+
out.push(`${at}: more than ${s.maxItems} items`);
|
|
91
|
+
if (Array.isArray(s.items))
|
|
92
|
+
s.items.forEach((item, i) => i < v.length && check(item, v[i], `${path}/${i}`, out));
|
|
93
|
+
else if (s.items !== undefined)
|
|
94
|
+
v.forEach((item, i) => check(s.items, item, `${path}/${i}`, out));
|
|
95
|
+
}
|
|
96
|
+
if (typeof v === "string") {
|
|
97
|
+
if (typeof s.minLength === "number" && [...v].length < s.minLength)
|
|
98
|
+
out.push(`${at}: shorter than ${s.minLength}`);
|
|
99
|
+
if (typeof s.maxLength === "number" && [...v].length > s.maxLength)
|
|
100
|
+
out.push(`${at}: longer than ${s.maxLength}`);
|
|
101
|
+
if (typeof s.pattern === "string") {
|
|
102
|
+
try {
|
|
103
|
+
if (!new RegExp(s.pattern, "u").test(v))
|
|
104
|
+
out.push(`${at}: doesn't match ${s.pattern}`);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// A pattern JavaScript can't read isn't checked.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (typeof v === "number") {
|
|
112
|
+
if (typeof s.minimum === "number" && v < s.minimum)
|
|
113
|
+
out.push(`${at}: less than ${s.minimum}`);
|
|
114
|
+
if (typeof s.maximum === "number" && v > s.maximum)
|
|
115
|
+
out.push(`${at}: more than ${s.maximum}`);
|
|
116
|
+
if (typeof s.exclusiveMinimum === "number" && v <= s.exclusiveMinimum)
|
|
117
|
+
out.push(`${at}: not more than ${s.exclusiveMinimum}`);
|
|
118
|
+
if (typeof s.exclusiveMaximum === "number" && v >= s.exclusiveMaximum)
|
|
119
|
+
out.push(`${at}: not less than ${s.exclusiveMaximum}`);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
check(root, value, "", errors);
|
|
123
|
+
return errors;
|
|
124
|
+
}
|
package/electron/AfmNode.swift
CHANGED
|
@@ -52,7 +52,7 @@ public func afm_call(_ method: UnsafePointer<CChar>, _ options: UnsafePointer<CC
|
|
|
52
52
|
if #available(macOS 26.0, *) {
|
|
53
53
|
do {
|
|
54
54
|
switch name {
|
|
55
|
-
case "availability": reply = ["result": AfmBridge.availability()]
|
|
55
|
+
case "availability": reply = ["result": AfmBridge.availability(object)]
|
|
56
56
|
case "createSession": try bridge().createSession(object); reply = ["result": NSNull()]
|
|
57
57
|
case "prewarm": try bridge().prewarm(object); reply = ["result": NSNull()]
|
|
58
58
|
case "respond": try bridge().respond(object); reply = ["result": NSNull()]
|
package/electron/afm.node
CHANGED
|
Binary file
|
package/electron/afm.node.sha256
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
7242528a429eafaaa0928604d5ca0130429ded1465d72e765876097777d1f9e1
|
|
@@ -15,9 +15,11 @@ import FoundationModels
|
|
|
15
15
|
/// Events, each with the `sessionId` and the `turn` of the `respond` call:
|
|
16
16
|
/// - `{ type: "delta", text }`: more of the response.
|
|
17
17
|
/// - `{ type: "toolCalls", calls: [{ id, name, arguments }] }`: `arguments` in JSON.
|
|
18
|
-
/// - `{ type: "done", text, usage }`: the response, and its tokens (iOS/macOS 27):
|
|
18
|
+
/// - `{ type: "done", text, usage, asText }`: the response, and its tokens (iOS/macOS 27):
|
|
19
19
|
/// `inputTokens` and `outputTokens` over every pass of the model (a tool call's
|
|
20
20
|
/// too), and `cachedTokens` only for a session without tools (see `usage()`).
|
|
21
|
+
/// `asText` when a schema's answer was written as text (a permissive session's
|
|
22
|
+
/// fallback, see `run`): not held to the schema, so the caller checks it.
|
|
21
23
|
/// - `{ type: "error", code, message }`.
|
|
22
24
|
@available(iOS 26.0, macOS 26.0, *)
|
|
23
25
|
public final class AfmBridge: @unchecked Sendable {
|
|
@@ -31,9 +33,20 @@ public final class AfmBridge: @unchecked Sendable {
|
|
|
31
33
|
self.emit = emit
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
///
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
/// The model with the guardrails `options` names: "permissiveContentTransformations",
|
|
37
|
+
/// or the default ones. Apple's default guardrails can block ordinary text that
|
|
38
|
+
/// sounds unsafe out of context (a card game's "Searing Blood", "Play with Fire").
|
|
39
|
+
static func model(_ options: [String: Any]) -> SystemLanguageModel {
|
|
40
|
+
switch options["guardrails"] as? String {
|
|
41
|
+
case "permissiveContentTransformations": return SystemLanguageModel(guardrails: .permissiveContentTransformations)
|
|
42
|
+
default: return .default
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Whether the model can run here, why not, and the tokens a session holds:
|
|
47
|
+
/// the model with the guardrails `options` names.
|
|
48
|
+
public static func availability(_ options: [String: Any] = [:]) -> [String: Any] {
|
|
49
|
+
let model = model(options)
|
|
37
50
|
var result: [String: Any]
|
|
38
51
|
switch model.availability {
|
|
39
52
|
case .available:
|
|
@@ -55,8 +68,9 @@ public final class AfmBridge: @unchecked Sendable {
|
|
|
55
68
|
}
|
|
56
69
|
|
|
57
70
|
/// Starts a session: `sessionId`, `instructions`, `tools` ([{ name, description,
|
|
58
|
-
/// parameters }])
|
|
59
|
-
/// the same id
|
|
71
|
+
/// parameters }]), `messages`, the conversation it starts from, and `guardrails`
|
|
72
|
+
/// ("default" or "permissiveContentTransformations"). A session with the same id
|
|
73
|
+
/// is ended first.
|
|
60
74
|
public func createSession(_ options: [String: Any]) throws {
|
|
61
75
|
let id = try Self.string(options, "sessionId")
|
|
62
76
|
deleteSession(id)
|
|
@@ -75,8 +89,9 @@ public final class AfmBridge: @unchecked Sendable {
|
|
|
75
89
|
tools: tools.map { Transcript.ToolDefinition(tool: $0) },
|
|
76
90
|
messages: options["messages"] as? [Any] ?? []
|
|
77
91
|
)
|
|
78
|
-
state.session = LanguageModelSession(model: .
|
|
92
|
+
state.session = LanguageModelSession(model: Self.model(options), tools: tools, transcript: transcript)
|
|
79
93
|
state.hasTools = !tools.isEmpty
|
|
94
|
+
state.permissive = (options["guardrails"] as? String) == "permissiveContentTransformations"
|
|
80
95
|
lock.withLock { sessions[id] = state }
|
|
81
96
|
}
|
|
82
97
|
|
|
@@ -143,6 +158,8 @@ final class BridgeSession: @unchecked Sendable {
|
|
|
143
158
|
let id: String
|
|
144
159
|
var session: LanguageModelSession!
|
|
145
160
|
var hasTools = false
|
|
161
|
+
/// Permissive guardrails: structured answers come as text (see `AfmBridge.respond`).
|
|
162
|
+
var permissive = false
|
|
146
163
|
|
|
147
164
|
private let emit: AfmBridge.Emit
|
|
148
165
|
private let lock = NSLock()
|
|
@@ -173,12 +190,30 @@ final class BridgeSession: @unchecked Sendable {
|
|
|
173
190
|
}
|
|
174
191
|
}
|
|
175
192
|
|
|
193
|
+
/// What a prompt adds to ask for a schema's answer as text: a JSON value (an object,
|
|
194
|
+
/// an array or a primitive, as the schema says) that matches it.
|
|
195
|
+
static func asJSON(_ schema: GenerationSchema) -> String {
|
|
196
|
+
let json = (try? JSONEncoder().encode(schema)).flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
|
197
|
+
return "\n\nAnswer with only a JSON value, and no other text, that matches this JSON Schema:\n\(json)"
|
|
198
|
+
}
|
|
199
|
+
|
|
176
200
|
private func run(_ prompt: String, _ options: GenerationOptions, _ schema: GenerationSchema?) async {
|
|
177
201
|
let before = usage()
|
|
178
202
|
var text = ""
|
|
203
|
+
var asText = false
|
|
179
204
|
do {
|
|
180
205
|
if let schema {
|
|
181
|
-
|
|
206
|
+
do {
|
|
207
|
+
text = try await session.respond(to: prompt, schema: schema, options: options).content.jsonString
|
|
208
|
+
} catch where permissive && Self.code(error) == "guardrailViolation" {
|
|
209
|
+
// Permissive guardrails cover String responses only: Apple documents that
|
|
210
|
+
// any other response (a schema's) gets the default guardrails, which block
|
|
211
|
+
// text such as "add 2 Searing Blood". So a blocked answer is asked for again
|
|
212
|
+
// as text, a JSON value to the schema in JSON Schema. `done` says so
|
|
213
|
+
// (`asText`): the framework didn't hold it to the schema, the caller checks it.
|
|
214
|
+
text = try await session.respond(to: prompt + Self.asJSON(schema), options: options).content
|
|
215
|
+
asText = true
|
|
216
|
+
}
|
|
182
217
|
} else {
|
|
183
218
|
for try await snapshot in session.streamResponse(to: prompt, options: options) {
|
|
184
219
|
let content = snapshot.content
|
|
@@ -190,6 +225,7 @@ final class BridgeSession: @unchecked Sendable {
|
|
|
190
225
|
// A stopped response's stream may just end.
|
|
191
226
|
try Task.checkCancellation()
|
|
192
227
|
var done: [String: Any] = ["type": "done", "text": text]
|
|
228
|
+
if asText { done["asText"] = true }
|
|
193
229
|
if let before, let after = usage() {
|
|
194
230
|
var usage: [String: Any] = [
|
|
195
231
|
"inputTokens": after.input - before.input,
|
|
@@ -293,6 +329,9 @@ final class BridgeSession: @unchecked Sendable {
|
|
|
293
329
|
|
|
294
330
|
/// An error's case name (`exceededContextWindowSize`, `guardrailViolation`, …).
|
|
295
331
|
static func code(_ error: Error) -> String {
|
|
332
|
+
// An enum error's case: LanguageModelError (iOS/macOS 27) describes itself with its
|
|
333
|
+
// message ("Response may contain sensitive or unsafe content"), not its case.
|
|
334
|
+
if let label = Mirror(reflecting: error).children.first?.label { return label }
|
|
296
335
|
let text = String(describing: error)
|
|
297
336
|
return String(text.prefix { $0 != "(" && $0 != ":" && $0 != " " })
|
|
298
337
|
}
|
|
@@ -36,7 +36,7 @@ public class AfmPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
36
36
|
call.resolve(["available": false, "reason": "unsupportedOS"])
|
|
37
37
|
return
|
|
38
38
|
}
|
|
39
|
-
call.resolve(AfmBridge.availability())
|
|
39
|
+
call.resolve(AfmBridge.availability(call.options as? [String: Any] ?? [:]))
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
@objc func createSession(_ call: CAPPluginCall) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack-ai-browser/afm",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "TanStack AI adapter for Apple's on-device Foundation Model (AFM), through Apple's framework itself, with native tool calling: a Capacitor plugin for iOS, and serveAfm() for Electron on macOS",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "daveycodez",
|
|
@@ -84,9 +84,6 @@
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
},
|
|
87
|
-
"scripts": {
|
|
88
|
-
"test": "node --import @swc-node/register/esm-register --test \"src/**/*.test.ts\""
|
|
89
|
-
},
|
|
90
87
|
"peerDependencies": {
|
|
91
88
|
"@capacitor/core": "^8.0.0",
|
|
92
89
|
"@tanstack/ai": "^1.2.0"
|
|
@@ -96,5 +93,8 @@
|
|
|
96
93
|
"@tanstack/ai": "1.4.0",
|
|
97
94
|
"electron": "^45.3.1",
|
|
98
95
|
"node-api-headers": "1.9.0"
|
|
96
|
+
},
|
|
97
|
+
"scripts": {
|
|
98
|
+
"test": "node --import @swc-node/register/esm-register --test \"src/**/*.test.ts\""
|
|
99
99
|
}
|
|
100
|
-
}
|
|
100
|
+
}
|
package/src/adapter.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict"
|
|
2
2
|
import { beforeEach, test } from "node:test"
|
|
3
3
|
|
|
4
|
+
import { chat } from "@tanstack/ai"
|
|
4
5
|
import type { TextOptions } from "@tanstack/ai"
|
|
5
6
|
|
|
6
7
|
import { afmText } from "./adapter.js"
|
|
@@ -24,7 +25,8 @@ class FakeNative implements AfmNative {
|
|
|
24
25
|
private turns = new Map<string, number>()
|
|
25
26
|
private waiting = new Map<string, Set<string>>()
|
|
26
27
|
|
|
27
|
-
async availability() {
|
|
28
|
+
async availability(options?: Record<string, unknown>) {
|
|
29
|
+
this.calls.push(["availability", options ?? {}])
|
|
28
30
|
return this.answer
|
|
29
31
|
}
|
|
30
32
|
async addListener(_name: "event", listener: (event: NativeEvent) => void) {
|
|
@@ -378,3 +380,61 @@ test("a request already stopped starts no native work, even when the bridge woul
|
|
|
378
380
|
assert.equal(native.named("respond").length, 0)
|
|
379
381
|
assert.equal(textIn(chunks), "")
|
|
380
382
|
})
|
|
383
|
+
|
|
384
|
+
test("guardrails reach the native side: the adapter's for every session and the availability check, a request's over it", async () => {
|
|
385
|
+
const adapter = afmText({ native, guardrails: "permissiveContentTransformations" })
|
|
386
|
+
assert.equal(await adapter.availability(), "available")
|
|
387
|
+
assert.deepEqual(native.named("availability").at(-1), { guardrails: "permissiveContentTransformations" })
|
|
388
|
+
await run(adapter, { threadId: "permissive" })
|
|
389
|
+
assert.equal(native.named("createSession").at(-1)?.guardrails, "permissiveContentTransformations")
|
|
390
|
+
await run(adapter, { threadId: "own", modelOptions: { guardrails: "default" } })
|
|
391
|
+
assert.equal(native.named("createSession").at(-1)?.guardrails, "default")
|
|
392
|
+
// None named: none sent, so the host's (serveAfm's) or Apple's default applies.
|
|
393
|
+
await run(afmText({ native }), { threadId: "none" })
|
|
394
|
+
assert.equal("guardrails" in (native.named("createSession").at(-1) ?? {}), false)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
test("a thread whose guardrails change starts a new session: a session's guardrails are its model's", async () => {
|
|
398
|
+
const adapter = afmText({ native })
|
|
399
|
+
native.replies = [[{ type: "done", text: "Hi." }], [{ type: "done", text: "Hi again." }]]
|
|
400
|
+
await run(adapter, { messages: [user("Hi")] })
|
|
401
|
+
await run(adapter, { messages: [user("Hi"), { role: "assistant", content: "Hi." }, user("Again")], modelOptions: { guardrails: "permissiveContentTransformations" } })
|
|
402
|
+
assert.equal(native.named("createSession").length, 2)
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
test("a thread moving between inherited guardrails and an explicit default starts a new session, both ways", async () => {
|
|
406
|
+
for (const [first, second] of [[undefined, "default"], ["default", undefined]] as const) {
|
|
407
|
+
native = new FakeNative()
|
|
408
|
+
const adapter = afmText({ native })
|
|
409
|
+
native.replies = [[{ type: "done", text: "Hi." }], [{ type: "done", text: "Hi again." }]]
|
|
410
|
+
await run(adapter, { messages: [user("Hi")], ...(first && { modelOptions: { guardrails: first } }) })
|
|
411
|
+
await run(adapter, { messages: [user("Hi"), { role: "assistant", content: "Hi." }, user("Again")], ...(second && { modelOptions: { guardrails: second } }) })
|
|
412
|
+
assert.equal(native.named("createSession").length, 2, `${first ?? "inherited"} then ${second ?? "inherited"}`)
|
|
413
|
+
}
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
test("a structured answer written as text (asText) must fit the schema: chat() alone doesn't check plain JSON Schema", async () => {
|
|
417
|
+
const outputSchema = { type: "object", properties: { city: { type: "string" }, unit: { type: "string", enum: ["c", "f"] } }, required: ["city", "unit"] }
|
|
418
|
+
const ask = (text: string) => {
|
|
419
|
+
native = new FakeNative()
|
|
420
|
+
native.replies = [[{ type: "done", text, asText: true }]]
|
|
421
|
+
return chat({ adapter: afmText({ native }), threadId: "structured", messages: [user("Weather?")], outputSchema, stream: false } as never) as unknown as Promise<unknown>
|
|
422
|
+
}
|
|
423
|
+
assert.deepEqual(await ask('```json\n{"city":"Paris","unit":"c"}\n```'), { city: "Paris", unit: "c" })
|
|
424
|
+
await assert.rejects(ask('{"city":123,"unit":"c"}'), /\/city: should be string, not number/)
|
|
425
|
+
await assert.rejects(ask('{"unit":"c"}'), /\/city: missing/)
|
|
426
|
+
await assert.rejects(ask('{"city":"Paris","unit":"kelvin"}'), /\/unit: "kelvin" is not one of "c", "f"/)
|
|
427
|
+
await assert.rejects(ask("Sorry, I can't help with that."), /wrote no JSON for the schema/)
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
test("a run's error keeps its code through chat(): on the thrown error, and on the stream's RUN_ERROR", async () => {
|
|
431
|
+
native.replies = [[{ type: "error", code: "guardrailViolation", message: "May contain unsafe content" }]]
|
|
432
|
+
const thrown = (await (chat({ adapter: afmText({ native }), stream: false, threadId: "errors", messages: [user("Searing Blood")] } as never) as unknown as Promise<unknown>).catch((e: unknown) => e)) as Error & { code?: string }
|
|
433
|
+
assert.equal(thrown.message, "May contain unsafe content")
|
|
434
|
+
assert.equal(thrown.code, "guardrailViolation")
|
|
435
|
+
native.replies = [[{ type: "error", code: "guardrailViolation", message: "May contain unsafe content" }]]
|
|
436
|
+
const chunks = await run(afmText({ native }), { threadId: "errors-stream" })
|
|
437
|
+
const error = chunks.find((c) => c.type === "RUN_ERROR")
|
|
438
|
+
assert.equal(error?.code, "guardrailViolation")
|
|
439
|
+
assert.deepEqual(error?.error, { message: "May contain unsafe content", code: "guardrailViolation" })
|
|
440
|
+
})
|
package/src/adapter.ts
CHANGED
|
@@ -24,8 +24,10 @@ import type { StructuredOutputOptions, StructuredOutputResult } from "@tanstack/
|
|
|
24
24
|
import { contentText, toNativeMessages } from "./messages.js"
|
|
25
25
|
import type { AfmChatModel } from "./model-meta.js"
|
|
26
26
|
import { Afm } from "./native.js"
|
|
27
|
-
import type { AfmNative, NativeEvent, NativeMessage, NativeUsage, UnavailableReason } from "./native.js"
|
|
27
|
+
import type { AfmGuardrails, AfmNative, NativeEvent, NativeMessage, NativeUsage, UnavailableReason } from "./native.js"
|
|
28
28
|
import { toNativeSchema } from "./schema.js"
|
|
29
|
+
import { parseJsonValue } from "./json-value.js"
|
|
30
|
+
import { schemaErrors } from "./validate.js"
|
|
29
31
|
|
|
30
32
|
export type AfmConfig = {
|
|
31
33
|
/**
|
|
@@ -34,13 +36,21 @@ export type AfmConfig = {
|
|
|
34
36
|
* plugin (`Afm`).
|
|
35
37
|
*/
|
|
36
38
|
native?: AfmNative
|
|
39
|
+
/**
|
|
40
|
+
* Apple's guardrails: "default" (the default), or "permissiveContentTransformations",
|
|
41
|
+
* for text the default ones block out of context, such as a card game's card
|
|
42
|
+
* names. A request's `modelOptions.guardrails` overrides it.
|
|
43
|
+
*/
|
|
44
|
+
guardrails?: AfmGuardrails
|
|
37
45
|
}
|
|
38
46
|
|
|
39
|
-
/** Per-call `modelOptions`: the response's sampling and length. */
|
|
47
|
+
/** Per-call `modelOptions`: the response's sampling and length, and the guardrails. */
|
|
40
48
|
export type AfmTextModelOptions = {
|
|
41
49
|
temperature?: number
|
|
42
50
|
/** Most tokens to generate. */
|
|
43
51
|
maximumResponseTokens?: number
|
|
52
|
+
/** Apple's guardrails for this request's session, over the adapter's `guardrails`. */
|
|
53
|
+
guardrails?: AfmGuardrails
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
export type Availability = "unavailable" | "downloadable" | "downloading" | "available"
|
|
@@ -167,6 +177,26 @@ const nextId = (prefix: string) => `${prefix}_${Date.now().toString(36)}_${(ids+
|
|
|
167
177
|
const signalOf = (options: TextOptions<AfmTextModelOptions>) =>
|
|
168
178
|
options.abortController?.signal ?? (options.request as RequestInit | undefined)?.signal ?? undefined
|
|
169
179
|
|
|
180
|
+
/**
|
|
181
|
+
* A structured answer the model wrote as text (permissive guardrails' fallback,
|
|
182
|
+
* which the framework didn't hold to the schema): its JSON value, if it fits the
|
|
183
|
+
* schema. chat() validates only schemas with a Standard Schema validator, not
|
|
184
|
+
* plain JSON Schema, so the adapter checks it here.
|
|
185
|
+
*/
|
|
186
|
+
function checkedAnswer(text: string, schema: unknown): unknown {
|
|
187
|
+
let data: unknown
|
|
188
|
+
try {
|
|
189
|
+
data = parseJsonValue(text)
|
|
190
|
+
} catch (error) {
|
|
191
|
+
throw Object.assign(new Error(`Apple's model wrote no JSON for the schema (its answer, written as text past its guardrails, was ${JSON.stringify(text.slice(0, 120))})`, { cause: error }), { code: "invalidJson" })
|
|
192
|
+
}
|
|
193
|
+
const problems = schemaErrors(schema, data)
|
|
194
|
+
if (problems.length > 0) {
|
|
195
|
+
throw Object.assign(new Error(`Apple's model's answer, written as text past its guardrails, doesn't fit the schema: ${problems.slice(0, 5).join("; ")}`), { code: "schemaMismatch" })
|
|
196
|
+
}
|
|
197
|
+
return data
|
|
198
|
+
}
|
|
199
|
+
|
|
170
200
|
const parse = (json: string): unknown => {
|
|
171
201
|
try {
|
|
172
202
|
return JSON.parse(json)
|
|
@@ -199,12 +229,20 @@ export class AfmTextAdapter extends BaseTextAdapter<
|
|
|
199
229
|
constructor(config: AfmConfig, model: AfmChatModel) {
|
|
200
230
|
super({}, model)
|
|
201
231
|
this.native = config.native ?? (globalThis as { afm?: AfmNative }).afm ?? Afm
|
|
232
|
+
this.guardrails = config.guardrails
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private readonly guardrails: AfmGuardrails | undefined
|
|
236
|
+
|
|
237
|
+
/** A request's guardrails: its own, else the adapter's (left out: the host's, else Apple's default). */
|
|
238
|
+
private guardrailsOf(options: TextOptions<AfmTextModelOptions>) {
|
|
239
|
+
return options.modelOptions?.guardrails ?? this.guardrails
|
|
202
240
|
}
|
|
203
241
|
|
|
204
242
|
/** Whether Apple's model can run here: "downloading" while Apple Intelligence gets it. */
|
|
205
243
|
async availability(): Promise<Availability> {
|
|
206
244
|
try {
|
|
207
|
-
const answer = await this.native.availability()
|
|
245
|
+
const answer = await this.native.availability(this.guardrails ? { guardrails: this.guardrails } : undefined)
|
|
208
246
|
return answer.available ? "available" : AVAILABILITY[answer.reason ?? "unavailable"]
|
|
209
247
|
} catch {
|
|
210
248
|
return "unavailable"
|
|
@@ -214,7 +252,7 @@ export class AfmTextAdapter extends BaseTextAdapter<
|
|
|
214
252
|
/** Tokens a session holds, where the OS says (26.4 and later). */
|
|
215
253
|
async contextSize(): Promise<number | undefined> {
|
|
216
254
|
try {
|
|
217
|
-
return (await this.native.availability()).contextSize
|
|
255
|
+
return (await this.native.availability(this.guardrails ? { guardrails: this.guardrails } : undefined)).contextSize
|
|
218
256
|
} catch {
|
|
219
257
|
return undefined
|
|
220
258
|
}
|
|
@@ -296,7 +334,8 @@ export class AfmTextAdapter extends BaseTextAdapter<
|
|
|
296
334
|
description: tool.description ?? "",
|
|
297
335
|
parameters: toNativeSchema(tool.inputSchema ?? { type: "object", properties: {} }, tool.name)
|
|
298
336
|
})),
|
|
299
|
-
messages
|
|
337
|
+
messages,
|
|
338
|
+
...(this.guardrailsOf(options) && { guardrails: this.guardrailsOf(options) })
|
|
300
339
|
})
|
|
301
340
|
return thread
|
|
302
341
|
}
|
|
@@ -319,7 +358,10 @@ export class AfmTextAdapter extends BaseTextAdapter<
|
|
|
319
358
|
maximumResponseTokens,
|
|
320
359
|
// A response keeps the mode it started with: a step with another mode
|
|
321
360
|
// (required, then auto) can't resume it, and starts a new session.
|
|
322
|
-
toolCallingModeOf(options)
|
|
361
|
+
toolCallingModeOf(options),
|
|
362
|
+
// A session's guardrails are its model's: other guardrails, another session.
|
|
363
|
+
// None named isn't "default": the host's (serveAfm's) may be permissive.
|
|
364
|
+
this.guardrailsOf(options) ?? "inherited"
|
|
323
365
|
])
|
|
324
366
|
const keys = options.messages.map(messageKey)
|
|
325
367
|
// A kept session, or one whose response waits for tool results.
|
|
@@ -493,6 +535,8 @@ export class AfmTextAdapter extends BaseTextAdapter<
|
|
|
493
535
|
model,
|
|
494
536
|
timestamp: timestamp(),
|
|
495
537
|
message,
|
|
538
|
+
// At the top too: chat() puts only this one on the error it throws.
|
|
539
|
+
...(typeof code === "string" && { code }),
|
|
496
540
|
error: { message, ...(typeof code === "string" && { code }) }
|
|
497
541
|
}
|
|
498
542
|
} finally {
|
|
@@ -539,7 +583,7 @@ export class AfmTextAdapter extends BaseTextAdapter<
|
|
|
539
583
|
const event = await thread.queue.next(signal)
|
|
540
584
|
if (event.type === "done") {
|
|
541
585
|
const usage = usageOf(event.usage)
|
|
542
|
-
return { data: JSON.parse(event.text) as unknown, rawText: event.text, model: this.model, ...(usage && { usage }) }
|
|
586
|
+
return { data: event.asText ? checkedAnswer(event.text, outputSchema) : (JSON.parse(event.text) as unknown), rawText: event.text, model: this.model, ...(usage && { usage }) }
|
|
543
587
|
}
|
|
544
588
|
if (event.type === "error") throw Object.assign(new Error(event.message), { code: event.code })
|
|
545
589
|
}
|
package/src/electron.ts
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import type { App, IpcMain, Session, WebContents, WebFrameMain } from "electron"
|
|
17
17
|
|
|
18
|
+
import type { AfmGuardrails } from "./native.js"
|
|
19
|
+
|
|
18
20
|
const PACKAGE = "@tanstack-ai-browser/afm"
|
|
19
21
|
const CALL = "afm:call"
|
|
20
22
|
const EVENT = "afm:event"
|
|
@@ -57,6 +59,12 @@ export type ServeAfmOptions = {
|
|
|
57
59
|
* but an http(s) one on another host than localhost.
|
|
58
60
|
*/
|
|
59
61
|
allow?: (url: string) => boolean
|
|
62
|
+
/**
|
|
63
|
+
* Apple's guardrails for every page's sessions that don't name their own
|
|
64
|
+
* (`afmText({ guardrails })` or a request's `modelOptions.guardrails`):
|
|
65
|
+
* "default" (the default), or "permissiveContentTransformations".
|
|
66
|
+
*/
|
|
67
|
+
guardrails?: AfmGuardrails
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
const appPages = (url: string) => {
|
|
@@ -79,6 +87,7 @@ let served = false
|
|
|
79
87
|
* macOS 26, or elsewhere, the pages hear the model is unavailable.
|
|
80
88
|
*/
|
|
81
89
|
export function serveAfm(options: ServeAfmOptions = {}): void {
|
|
90
|
+
const appGuardrails = options.guardrails
|
|
82
91
|
if (served) return
|
|
83
92
|
served = true
|
|
84
93
|
const node = (globalThis as unknown as { process: NodeProcess }).process
|
|
@@ -140,6 +149,10 @@ export function serveAfm(options: ServeAfmOptions = {}): void {
|
|
|
140
149
|
throw new Error("Apple's on-device model needs macOS 26 on a Mac with Apple silicon")
|
|
141
150
|
}
|
|
142
151
|
const options = { ...(typeof args === "object" && args !== null ? args : {}) } as Record<string, unknown>
|
|
152
|
+
// The app's guardrails, where the page names none.
|
|
153
|
+
if ((method === "createSession" || method === "availability") && options.guardrails === undefined && appGuardrails) {
|
|
154
|
+
options.guardrails = appGuardrails
|
|
155
|
+
}
|
|
143
156
|
// The frame's own session: another frame's can't be reached.
|
|
144
157
|
if (typeof options.sessionId === "string") {
|
|
145
158
|
const frameId = frameIdOf(frame)
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { test } from "node:test"
|
|
3
|
+
|
|
4
|
+
import { parseJsonValue } from "./json-value.js"
|
|
5
|
+
|
|
6
|
+
test("a text answer's JSON value comes whole: arrays, primitives, and strings with braces in them", () => {
|
|
7
|
+
assert.deepEqual(parseJsonValue('[{"name":"Searing Blood"}]'), [{ name: "Searing Blood" }])
|
|
8
|
+
assert.deepEqual(parseJsonValue(' [{"a":1},{"b":2}]\n'), [{ a: 1 }, { b: 2 }])
|
|
9
|
+
assert.equal(parseJsonValue("42"), 42)
|
|
10
|
+
assert.equal(parseJsonValue("true"), true)
|
|
11
|
+
assert.equal(parseJsonValue("null"), null)
|
|
12
|
+
assert.equal(parseJsonValue('"a {b} c"'), "a {b} c")
|
|
13
|
+
assert.deepEqual(parseJsonValue('{"text":"} {"}'), { text: "} {" })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("a fenced answer gives everything inside its fence", () => {
|
|
17
|
+
assert.deepEqual(parseJsonValue('```json\n[{"a":1},{"b":2}]\n```'), [{ a: 1 }, { b: 2 }])
|
|
18
|
+
assert.equal(parseJsonValue('```\n"a {b} c"\n```'), "a {b} c")
|
|
19
|
+
assert.deepEqual(parseJsonValue('\n```json\n{\n "x": [1, 2]\n}\n```\n'), { x: [1, 2] })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test("text that isn't a JSON value, or more than one, fails", () => {
|
|
23
|
+
assert.throws(() => parseJsonValue("Here you go: {\"a\":1}"))
|
|
24
|
+
assert.throws(() => parseJsonValue('{"a":1} {"b":2}'))
|
|
25
|
+
assert.throws(() => parseJsonValue("```json\nnot json\n```"))
|
|
26
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The JSON value in a model's text answer: the whole trimmed text, or, when the
|
|
3
|
+
* model wrapped it in a Markdown code fence, everything inside the fence. Any
|
|
4
|
+
* value: an object, an array, a string, a number, a boolean or null. Never by
|
|
5
|
+
* brace positions, which would cut an array down to one of its objects, or a
|
|
6
|
+
* string that contains braces out of its quotes.
|
|
7
|
+
*/
|
|
8
|
+
export function parseJsonValue(text: string): unknown {
|
|
9
|
+
const trimmed = text.trim()
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(trimmed)
|
|
12
|
+
} catch (error) {
|
|
13
|
+
const fenced = /^```[\w-]*[^\S\n]*\n([\s\S]*?)\n?```$/.exec(trimmed)
|
|
14
|
+
if (fenced) return JSON.parse(fenced[1].trim())
|
|
15
|
+
throw error
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/native.ts
CHANGED
|
@@ -60,17 +60,26 @@ export type NativeUsage = { inputTokens: number; cachedTokens?: number; outputTo
|
|
|
60
60
|
export type NativeEvent = { sessionId: string; turn: number } & (
|
|
61
61
|
| { type: "delta"; text: string }
|
|
62
62
|
| { type: "toolCalls"; calls: Array<{ id: string; name: string; arguments: string }> }
|
|
63
|
-
|
|
63
|
+
/** `asText`: a schema's answer written as text (permissive guardrails' fallback), not held to the schema. */
|
|
64
|
+
| { type: "done"; text: string; usage?: NativeUsage; asText?: boolean }
|
|
64
65
|
| { type: "error"; code: string; message: string }
|
|
65
66
|
)
|
|
66
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Apple's guardrails for the model: "default", or "permissiveContentTransformations",
|
|
70
|
+
* which lets through text the default ones can block out of context (a card game's
|
|
71
|
+
* "Searing Blood" or "Play with Fire").
|
|
72
|
+
*/
|
|
73
|
+
export type AfmGuardrails = "default" | "permissiveContentTransformations"
|
|
74
|
+
|
|
67
75
|
export interface AfmNative {
|
|
68
|
-
availability(): Promise<NativeAvailability>
|
|
76
|
+
availability(options?: { guardrails?: AfmGuardrails }): Promise<NativeAvailability>
|
|
69
77
|
createSession(options: {
|
|
70
78
|
sessionId: string
|
|
71
79
|
instructions?: string
|
|
72
80
|
tools: Array<NativeTool>
|
|
73
81
|
messages: Array<NativeMessage>
|
|
82
|
+
guardrails?: AfmGuardrails
|
|
74
83
|
}): Promise<void>
|
|
75
84
|
prewarm(options: { sessionId: string }): Promise<void>
|
|
76
85
|
/** Prompts a session; the response comes as events. A `schema` holds the answer to it (JSON in `done`). */
|
|
@@ -108,7 +117,7 @@ const capacitor = () =>
|
|
|
108
117
|
* its calls reject: Capacitor has no web version of it.
|
|
109
118
|
*/
|
|
110
119
|
export const Afm: AfmNative = {
|
|
111
|
-
availability: async () => (await capacitor()).plugin.availability(),
|
|
120
|
+
availability: async (options) => (await capacitor()).plugin.availability(options),
|
|
112
121
|
createSession: async (options) => (await capacitor()).plugin.createSession(options),
|
|
113
122
|
prewarm: async (options) => (await capacitor()).plugin.prewarm(options),
|
|
114
123
|
respond: async (options) => (await capacitor()).plugin.respond(options),
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { test } from "node:test"
|
|
3
|
+
|
|
4
|
+
import { schemaErrors } from "./validate.js"
|
|
5
|
+
|
|
6
|
+
const city = { type: "object", properties: { city: { type: "string" } }, required: ["city"] }
|
|
7
|
+
|
|
8
|
+
test("a value that fits its schema has no errors", () => {
|
|
9
|
+
assert.deepEqual(schemaErrors(city, { city: "Paris" }), [])
|
|
10
|
+
assert.deepEqual(schemaErrors({ type: "array", items: { type: "integer" }, minItems: 1 }, [1, 2]), [])
|
|
11
|
+
assert.deepEqual(schemaErrors({ type: ["string", "null"] }, null), [])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test("missing required fields, wrong types and values outside an enum are errors, with their paths", () => {
|
|
15
|
+
assert.deepEqual(schemaErrors(city, {}), ["/city: missing"])
|
|
16
|
+
assert.deepEqual(schemaErrors(city, { city: 123 }), ["/city: should be string, not number"])
|
|
17
|
+
assert.deepEqual(schemaErrors(city, "Paris"), ["/: should be object, not string"])
|
|
18
|
+
const action = { type: "object", properties: { operation: { type: "string", enum: ["add", "remove"] } }, required: ["operation"] }
|
|
19
|
+
assert.deepEqual(schemaErrors(action, { operation: "burn" }), ['/operation: "burn" is not one of "add", "remove"'])
|
|
20
|
+
assert.deepEqual(schemaErrors({ type: "integer" }, 1.5), ["/: should be integer, not number"])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("nested arrays, $refs, choices, extra properties and bounds are checked", () => {
|
|
24
|
+
const schema = {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: { actions: { type: "array", items: { $ref: "#/$defs/Action" }, maxItems: 2 } },
|
|
27
|
+
required: ["actions"],
|
|
28
|
+
$defs: { Action: { type: "object", properties: { n: { type: "integer", minimum: 1, maximum: 4 } }, required: ["n"], additionalProperties: false } }
|
|
29
|
+
}
|
|
30
|
+
assert.deepEqual(schemaErrors(schema, { actions: [{ n: 2 }] }), [])
|
|
31
|
+
assert.deepEqual(schemaErrors(schema, { actions: [{ n: 0 }, { n: 2, x: 1 }, { n: 3 }] }), [
|
|
32
|
+
"/actions: more than 2 items",
|
|
33
|
+
"/actions/0/n: less than 1",
|
|
34
|
+
"/actions/1/x: not a property of the schema"
|
|
35
|
+
])
|
|
36
|
+
assert.deepEqual(schemaErrors({ anyOf: [{ type: "string" }, { type: "integer" }] }, true), ["/: fits 0 of its anyOf choices"])
|
|
37
|
+
assert.deepEqual(schemaErrors({ const: "a" }, "b"), ['/: should be "a", not "b"'])
|
|
38
|
+
})
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a value doesn't fit a JSON Schema: each mismatch with its path
|
|
3
|
+
* ("/actions/0/operation: not one of add, remove"). Empty when it fits. It reads
|
|
4
|
+
* what afm converts for Apple's framework and the framework holds answers to:
|
|
5
|
+
* types (with null), objects (properties, required, additionalProperties),
|
|
6
|
+
* arrays (items, minItems, maxItems), enum, const, anyOf, oneOf, allOf, $ref to
|
|
7
|
+
* $defs or definitions, string lengths and patterns, and numeric bounds. Other
|
|
8
|
+
* keywords are left unchecked.
|
|
9
|
+
*/
|
|
10
|
+
type Json = Record<string, unknown>
|
|
11
|
+
|
|
12
|
+
const isObject = (value: unknown): value is Json => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
13
|
+
|
|
14
|
+
const typeOf = (value: unknown) => (value === null ? "null" : Array.isArray(value) ? "array" : typeof value)
|
|
15
|
+
|
|
16
|
+
function equal(a: unknown, b: unknown): boolean {
|
|
17
|
+
if (a === b) return true
|
|
18
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => equal(v, b[i]))
|
|
19
|
+
if (isObject(a) && isObject(b)) {
|
|
20
|
+
const keys = Object.keys(a)
|
|
21
|
+
return keys.length === Object.keys(b).length && keys.every((k) => Object.hasOwn(b, k) && equal(a[k], b[k]))
|
|
22
|
+
}
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fitsType(type: string, value: unknown) {
|
|
27
|
+
if (type === "integer") return typeof value === "number" && Number.isInteger(value)
|
|
28
|
+
if (type === "number") return typeof value === "number" && Number.isFinite(value)
|
|
29
|
+
return typeOf(value) === type
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const show = (value: unknown) => JSON.stringify(value)
|
|
33
|
+
|
|
34
|
+
export function schemaErrors(schema: unknown, value: unknown): Array<string> {
|
|
35
|
+
const root = isObject(schema) ? schema : {}
|
|
36
|
+
const errors: Array<string> = []
|
|
37
|
+
|
|
38
|
+
const resolve = (ref: string): unknown => {
|
|
39
|
+
const match = /^#\/(\$defs|definitions)\/(.+)$/.exec(ref)
|
|
40
|
+
const defs = match ? root[match[1]] : undefined
|
|
41
|
+
return match && isObject(defs) ? defs[decodeURIComponent(match[2])] : undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const check = (s: unknown, v: unknown, path: string, out: Array<string>) => {
|
|
45
|
+
if (s === true || s === undefined) return
|
|
46
|
+
if (s === false) return void out.push(`${path || "/"}: not allowed`)
|
|
47
|
+
if (!isObject(s)) return
|
|
48
|
+
if (typeof s.$ref === "string") {
|
|
49
|
+
const target = resolve(s.$ref)
|
|
50
|
+
if (target === undefined) return void out.push(`${path || "/"}: unknown $ref ${s.$ref}`)
|
|
51
|
+
check(target, v, path, out)
|
|
52
|
+
}
|
|
53
|
+
const at = path || "/"
|
|
54
|
+
if ("const" in s && !equal(s.const, v)) out.push(`${at}: should be ${show(s.const)}, not ${show(v)}`)
|
|
55
|
+
if (Array.isArray(s.enum) && !s.enum.some((e) => equal(e, v))) out.push(`${at}: ${show(v)} is not one of ${s.enum.map(show).join(", ")}`)
|
|
56
|
+
if (s.type !== undefined) {
|
|
57
|
+
const types = (Array.isArray(s.type) ? s.type : [s.type]).filter((t): t is string => typeof t === "string")
|
|
58
|
+
if (types.length > 0 && !types.some((t) => fitsType(t, v))) {
|
|
59
|
+
out.push(`${at}: should be ${types.join(" or ")}, not ${typeOf(v)}`)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(s.allOf)) for (const part of s.allOf) check(part, v, path, out)
|
|
64
|
+
for (const key of ["anyOf", "oneOf"] as const) {
|
|
65
|
+
const choices = s[key]
|
|
66
|
+
if (!Array.isArray(choices)) continue
|
|
67
|
+
const fitting = choices.filter((choice) => {
|
|
68
|
+
const sub: Array<string> = []
|
|
69
|
+
check(choice, v, path, sub)
|
|
70
|
+
return sub.length === 0
|
|
71
|
+
}).length
|
|
72
|
+
if (key === "anyOf" ? fitting === 0 : fitting !== 1) out.push(`${at}: fits ${fitting} of its ${key} choices`)
|
|
73
|
+
}
|
|
74
|
+
if (isObject(v)) {
|
|
75
|
+
const properties = isObject(s.properties) ? s.properties : {}
|
|
76
|
+
if (Array.isArray(s.required)) {
|
|
77
|
+
for (const key of s.required) if (typeof key === "string" && !Object.hasOwn(v, key)) out.push(`${path}/${key}: missing`)
|
|
78
|
+
}
|
|
79
|
+
for (const [key, item] of Object.entries(v)) {
|
|
80
|
+
if (Object.hasOwn(properties, key)) check(properties[key], item, `${path}/${key}`, out)
|
|
81
|
+
else if (s.additionalProperties === false) out.push(`${path}/${key}: not a property of the schema`)
|
|
82
|
+
else if (isObject(s.additionalProperties)) check(s.additionalProperties, item, `${path}/${key}`, out)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (Array.isArray(v)) {
|
|
86
|
+
if (typeof s.minItems === "number" && v.length < s.minItems) out.push(`${at}: fewer than ${s.minItems} items`)
|
|
87
|
+
if (typeof s.maxItems === "number" && v.length > s.maxItems) out.push(`${at}: more than ${s.maxItems} items`)
|
|
88
|
+
if (Array.isArray(s.items)) s.items.forEach((item, i) => i < v.length && check(item, v[i], `${path}/${i}`, out))
|
|
89
|
+
else if (s.items !== undefined) v.forEach((item, i) => check(s.items, item, `${path}/${i}`, out))
|
|
90
|
+
}
|
|
91
|
+
if (typeof v === "string") {
|
|
92
|
+
if (typeof s.minLength === "number" && [...v].length < s.minLength) out.push(`${at}: shorter than ${s.minLength}`)
|
|
93
|
+
if (typeof s.maxLength === "number" && [...v].length > s.maxLength) out.push(`${at}: longer than ${s.maxLength}`)
|
|
94
|
+
if (typeof s.pattern === "string") {
|
|
95
|
+
try {
|
|
96
|
+
if (!new RegExp(s.pattern, "u").test(v)) out.push(`${at}: doesn't match ${s.pattern}`)
|
|
97
|
+
} catch {
|
|
98
|
+
// A pattern JavaScript can't read isn't checked.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (typeof v === "number") {
|
|
103
|
+
if (typeof s.minimum === "number" && v < s.minimum) out.push(`${at}: less than ${s.minimum}`)
|
|
104
|
+
if (typeof s.maximum === "number" && v > s.maximum) out.push(`${at}: more than ${s.maximum}`)
|
|
105
|
+
if (typeof s.exclusiveMinimum === "number" && v <= s.exclusiveMinimum) out.push(`${at}: not more than ${s.exclusiveMinimum}`)
|
|
106
|
+
if (typeof s.exclusiveMaximum === "number" && v >= s.exclusiveMaximum) out.push(`${at}: not less than ${s.exclusiveMaximum}`)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
check(root, value, "", errors)
|
|
111
|
+
return errors
|
|
112
|
+
}
|