@tanstack-ai-browser/afm 0.2.1 → 0.2.2

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 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
- const stream = chat({ adapter, messages, tools })
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 is reported with the code `guardrailViolation`.
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). */
@@ -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;AAGxG,MAAM,MAAM,SAAS,GAAG;IACtB;;;;OAIG;IACH,MAAM,CAAC,EAAE,SAAS,CAAA;CACnB,CAAA;AAED,mEAAmE;AACnE,MAAM,MAAM,mBAAmB,GAAG;IAChC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,+BAA+B;IAC/B,qBAAqB,CAAC,EAAE,MAAM,CAAA;CAC/B,CAAA;AAED,MAAM,MAAM,YAAY,GAAG,aAAa,GAAG,cAAc,GAAG,aAAa,GAAG,WAAW,CAAA;AAEvF,KAAK,kBAAkB,GAAG;IACxB,IAAI,EAAE,OAAO,CAAA;IACb,KAAK,EAAE,OAAO,CAAA;IACd,KAAK,EAAE,OAAO,CAAA;IACd,KAAK,EAAE,OAAO,CAAA;IACd,QAAQ,EAAE,OAAO,CAAA;CAClB,CAAA;AA4HD,qBAAa,cAAe,SAAQ,eAAe,CACjD,YAAY,EACZ,mBAAmB,EACnB,SAAS,CAAC,MAAM,CAAC,EACjB,kBAAkB,CACnB;IACC,SAAkB,IAAI,EAAG,MAAM,CAAS;IACxC,QAAQ,CAAC,IAAI,EAAG,KAAK,CAAS;IAC9B,SAAkB,QAAQ,EAAG,KAAK,CAAS;IAC3C,SAAkB,GAAG,EAAG,KAAK,CAAS;IACtC,SAAkB,eAAe,YAAI,MAAM,EAAU;IACrD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAW;IAClC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA4B;IACpD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA4B;IACnD;;;OAGG;IACH,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA4B;IACnD,OAAO,CAAC,SAAS,CAA8B;IAE/C,YAAY,MAAM,EAAE,SAAS,EAAE,KAAK,EAAE,YAAY,EAGjD;IAED,0FAA0F;IACpF,YAAY,IAAI,OAAO,CAAC,YAAY,CAAC,CAO1C;IAED,kEAAkE;IAC5D,WAAW,IAAI,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAM/C;IAED,wEAAwE;IAClE,MAAM,CAAC,OAAO,GAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAMlE;IAED;;;;OAIG;IACG,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAK7B;IAED,yEAAyE;IACzE,OAAO,CAAC,MAAM;IAQd,6EAA6E;IAC7E,OAAO,CAAC,KAAK;IAOb,+EAA+E;IAC/E,OAAO,CAAC,IAAI;IAWZ,6DAA6D;YAC/C,IAAI;IA8BlB;;;;OAIG;YACW,KAAK;IAmDZ,UAAU,CAAC,OAAO,EAAE,WAAW,CAAC,mBAAmB,CAAC,GAAG,aAAa,CAAC,iBAAiB,CAAC,CAiJ7F;IAED;;;;OAIG;IACG,gBAAgB,CACpB,OAAO,EAAE,uBAAuB,CAAC,mBAAmB,CAAC,GACpD,OAAO,CAAC,sBAAsB,CAAC,OAAO,CAAC,CAAC,CAmC1C;CACF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,OAAO,CAAC,MAAM,CAAC,EAAE,SAAS,GAAG,cAAc,CAAA;AAC3D,wBAAgB,OAAO,CAAC,KAAK,EAAE,YAAY,EAAE,MAAM,CAAC,EAAE,SAAS,GAAG,cAAc,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.
@@ -472,7 +504,7 @@ export class AfmTextAdapter extends BaseTextAdapter {
472
504
  const event = await thread.queue.next(signal);
473
505
  if (event.type === "done") {
474
506
  const usage = usageOf(event.usage);
475
- return { data: JSON.parse(event.text), rawText: event.text, model: this.model, ...(usage && { usage }) };
507
+ return { data: event.asText ? checkedAnswer(event.text, outputSchema) : JSON.parse(event.text), rawText: event.text, model: this.model, ...(usage && { usage }) };
476
508
  }
477
509
  if (event.type === "error")
478
510
  throw Object.assign(new Error(event.message), { code: event.code });
@@ -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.
@@ -1 +1 @@
1
- {"version":3,"file":"electron.d.ts","sourceRoot":"","sources":["../src/electron.ts"],"names":[],"mappings":"AA6CA,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;CACjC,CAAA;AAgBD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,OAAO,GAAE,eAAoB,GAAG,IAAI,CAmG5D"}
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";
@@ -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(): Promise<NativeAvailability>;
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;
@@ -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":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAA;AAE3D,oEAAoE;AACpE,MAAM,MAAM,iBAAiB,GACzB,mBAAmB,GACnB,6BAA6B,GAC7B,eAAe,GACf,eAAe,GACf,aAAa,CAAA;AAEjB,MAAM,MAAM,kBAAkB,GAAG;IAC/B,SAAS,EAAE,OAAO,CAAA;IAClB,MAAM,CAAC,EAAE,iBAAiB,CAAA;IAC1B,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,CAAA;AAED;;;;GAIG;AACH,MAAM,MAAM,YAAY,GACpB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,KAAK,CAAC,cAAc,CAAC,CAAA;CAAE,GACzF;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,YAAY,CAAC;IAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAAC,eAAe,CAAC,EAAE,MAAM,CAAA;CAAE,GAC1F;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,OAAO,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAC9D;IAAE,IAAI,EAAE,SAAS,GAAG,QAAQ,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GAClE;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAChB;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,KAAK,CAAC,YAAY,CAAC,CAAA;CAAE,GACnF;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAA;AAEvC,MAAM,MAAM,cAAc,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,YAAY,CAAA;CAAE,CAAA;AAE5G,6DAA6D;AAC7D,MAAM,MAAM,sBAAsB,GAAG;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,WAAW,EAAE,KAAK,CAAC,YAAY,CAAC,CAAA;CAAE,CAAA;AAE7F,MAAM,MAAM,UAAU,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,sBAAsB,CAAA;CAAE,CAAA;AAElG,2DAA2D;AAC3D,MAAM,MAAM,aAAa,GACrB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAAE,GACvG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAA;AAEpE;;;;GAIG;AACH,MAAM,MAAM,WAAW,GAAG;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAAA;AAE9F,oFAAoF;AACpF,MAAM,MAAM,WAAW,GAAG;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,CAC5D;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC/B;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,KAAK,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAAE;AACtF,6GAA6G;GAC3G;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,WAAW,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,GACrE;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CACnD,CAAA;AAED;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,SAAS,GAAG,kCAAkC,CAAA;AAE1E,MAAM,WAAW,SAAS;IACxB,YAAY,CAAC,OAAO,CAAC,EAAE;QAAE,UAAU,CAAC,EAAE,aAAa,CAAA;KAAE,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAA;IACnF,aAAa,CAAC,OAAO,EAAE;QACrB,SAAS,EAAE,MAAM,CAAA;QACjB,YAAY,CAAC,EAAE,MAAM,CAAA;QACrB,KAAK,EAAE,KAAK,CAAC,UAAU,CAAC,CAAA;QACxB,QAAQ,EAAE,KAAK,CAAC,aAAa,CAAC,CAAA;QAC9B,UAAU,CAAC,EAAE,aAAa,CAAA;KAC3B,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACjB,OAAO,CAAC,OAAO,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACtD,2GAA2G;IAC3G,OAAO,CAAC,OAAO,EAAE;QACf,SAAS,EAAE,MAAM,CAAA;QACjB,IAAI,EAAE,MAAM,CAAA;QACZ,MAAM,EAAE,MAAM,CAAA;QACd,OAAO,CAAC,EAAE;YACR,WAAW,CAAC,EAAE,MAAM,CAAA;YACpB,qBAAqB,CAAC,EAAE,MAAM,CAAA;YAC9B,wHAAwH;YACxH,eAAe,CAAC,EAAE,SAAS,GAAG,UAAU,GAAG,YAAY,CAAA;SACxD,CAAA;QACD,MAAM,CAAC,EAAE,sBAAsB,CAAA;KAChC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACjB,wFAAwF;IACxF,gBAAgB,CAAC,OAAO,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/F,kCAAkC;IAClC,MAAM,CAAC,OAAO,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACrD,aAAa,CAAC,OAAO,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5D,WAAW,CAAC,SAAS,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAA;CACvG;AAWD;;;GAGG;AACH,eAAO,MAAM,GAAG,EAAE,SASjB,CAAA"}
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,2 @@
1
+ export declare function schemaErrors(schema: unknown, value: unknown): Array<string>;
2
+ //# sourceMappingURL=validate.d.ts.map
@@ -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"}
@@ -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
+ }
@@ -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
@@ -1 +1 @@
1
- 2e804dcb3921aad737e0924db1975955882393feff18f01dddd48bb5a7855ad3
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
- /// Whether the model can run here, why not, and the tokens a session holds.
35
- public static func availability() -> [String: Any] {
36
- let model = SystemLanguageModel.default
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 }]) and `messages`, the conversation it starts from. A session with
59
- /// the same id is ended first.
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: .default, tools: tools, transcript: transcript)
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
- text = try await session.respond(to: prompt, schema: schema, options: options).content.jsonString
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.1",
3
+ "version": "0.2.2",
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
+ }
@@ -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,49 @@ 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
+ })
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.
@@ -539,7 +581,7 @@ export class AfmTextAdapter extends BaseTextAdapter<
539
581
  const event = await thread.queue.next(signal)
540
582
  if (event.type === "done") {
541
583
  const usage = usageOf(event.usage)
542
- return { data: JSON.parse(event.text) as unknown, rawText: event.text, model: this.model, ...(usage && { usage }) }
584
+ return { data: event.asText ? checkedAnswer(event.text, outputSchema) : (JSON.parse(event.text) as unknown), rawText: event.text, model: this.model, ...(usage && { usage }) }
543
585
  }
544
586
  if (event.type === "error") throw Object.assign(new Error(event.message), { code: event.code })
545
587
  }
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
@@ -11,6 +11,7 @@ export { Afm } from "./native.js"
11
11
  export { serveAfm } from "./electron.js"
12
12
  export type { ServeAfmOptions } from "./electron.js"
13
13
  export type {
14
+ AfmGuardrails,
14
15
  AfmNative,
15
16
  NativeAvailability,
16
17
  NativeEvent,
@@ -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
- | { type: "done"; text: string; usage?: NativeUsage }
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
+ })
@@ -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
+ }