@tanstack-ai-browser/afm 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/Package.swift +33 -0
  3. package/README.md +62 -2
  4. package/dist/adapter.d.ts +96 -0
  5. package/dist/adapter.d.ts.map +1 -0
  6. package/dist/adapter.js +490 -0
  7. package/dist/electron.d.ts +22 -0
  8. package/dist/electron.d.ts.map +1 -0
  9. package/dist/electron.js +131 -0
  10. package/dist/index.d.ts +17 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +12 -0
  13. package/dist/messages.d.ts +12 -0
  14. package/dist/messages.d.ts.map +1 -0
  15. package/dist/messages.js +29 -0
  16. package/dist/model-meta.d.ts +23 -0
  17. package/dist/model-meta.d.ts.map +1 -0
  18. package/dist/model-meta.js +20 -0
  19. package/dist/native.d.ts +163 -0
  20. package/dist/native.d.ts.map +1 -0
  21. package/dist/native.js +21 -0
  22. package/dist/schema.d.ts +17 -0
  23. package/dist/schema.d.ts.map +1 -0
  24. package/dist/schema.js +133 -0
  25. package/electron/AfmNode.swift +81 -0
  26. package/electron/afm.c +94 -0
  27. package/electron/afm.node +0 -0
  28. package/electron/afm.node.sha256 +1 -0
  29. package/electron/build.mjs +54 -0
  30. package/electron/preload.cjs +35 -0
  31. package/electron/sources-hash.mjs +40 -0
  32. package/ios/Sources/AfmCore/Bridge.swift +315 -0
  33. package/ios/Sources/AfmCore/Conversation.swift +49 -0
  34. package/ios/Sources/AfmCore/Schema.swift +90 -0
  35. package/ios/Sources/AfmPlugin/AfmPlugin.swift +89 -0
  36. package/package.json +98 -4
  37. package/src/adapter.test.ts +380 -0
  38. package/src/adapter.ts +574 -0
  39. package/src/electron.ts +180 -0
  40. package/src/index.ts +27 -0
  41. package/src/messages.ts +37 -0
  42. package/src/model-meta.ts +37 -0
  43. package/src/native-build.test.ts +13 -0
  44. package/src/native.ts +119 -0
  45. package/src/schema.test.ts +146 -0
  46. package/src/schema.ts +153 -0
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Apple's on-device model in an Electron app on macOS: `serveAfm()` in the
3
+ * main process runs it, through the package's Node-API addon
4
+ * (electron/afm.node, built from the same Swift as the Capacitor plugin), and
5
+ * adds the package's preload (electron/preload.cjs) to the app's pages, which
6
+ * puts it at `window.afm` for `afmText()`.
7
+ *
8
+ * Each frame's sessions are its own: their native ids are the frame's id and
9
+ * the page's, a frame's calls reach only its sessions, and their events go to
10
+ * that frame only, while `allow()` still allows its page. A frame's sessions
11
+ * end when it navigates or closes.
12
+ *
13
+ * Electron is required when `serveAfm()` runs, not imported: bundlers of the
14
+ * app's pages (web, iOS) never see it, and drop this code.
15
+ */
16
+ import type { App, IpcMain, Session, WebContents, WebFrameMain } from "electron"
17
+
18
+ const PACKAGE = "@tanstack-ai-browser/afm"
19
+ const CALL = "afm:call"
20
+ const EVENT = "afm:event"
21
+
22
+ /** The addon (electron/afm.c): AfmBridge's calls, and its events, in JSON. */
23
+ type Addon = {
24
+ call(method: string, options: string): string
25
+ setListener(listener: ((json: string) => void) | null): void
26
+ }
27
+
28
+ type Electron = {
29
+ app: App
30
+ ipcMain: IpcMain
31
+ session: { defaultSession: Session }
32
+ }
33
+
34
+ /** A native session's frame, and the page's id for it. */
35
+ type Owner = { frame: WebFrameMain; frameId: string; contentsId: number; sessionId: string }
36
+
37
+ /** The bits of Node used here, typed here: this is a browser package otherwise. */
38
+ type NodeRequire = ((id: string) => unknown) & { resolve(id: string): string }
39
+ type NodeProcess = {
40
+ platform: string
41
+ execPath: string
42
+ getBuiltinModule(id: "node:module"): { createRequire(path: string): NodeRequire }
43
+ getBuiltinModule(id: "node:path"): { join(...parts: Array<string>): string; dirname(path: string): string }
44
+ }
45
+
46
+ export type ServeAfmOptions = {
47
+ /**
48
+ * The folder with the package's Electron files (afm.node and preload.cjs):
49
+ * by default the package's own `electron` folder, found from the app's
50
+ * node_modules. An app that leaves node_modules out of its package copies
51
+ * the two files in and passes their folder. Inside an asar archive, the
52
+ * unpacked afm.node beside it is loaded (native code can't load from an archive).
53
+ */
54
+ directory?: string
55
+ /**
56
+ * Which pages may use the model, by URL. By default the app's own: any page
57
+ * but an http(s) one on another host than localhost.
58
+ */
59
+ allow?: (url: string) => boolean
60
+ }
61
+
62
+ const appPages = (url: string) => {
63
+ try {
64
+ const { protocol, hostname } = new URL(url)
65
+ if (protocol !== "http:" && protocol !== "https:") return true
66
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"
67
+ } catch {
68
+ return false
69
+ }
70
+ }
71
+
72
+ const unpacked = (file: string) => file.replace(/app\.asar(?=[\\/])/, "app.asar.unpacked")
73
+
74
+ let served = false
75
+
76
+ /**
77
+ * Runs Apple's on-device model for the app's pages, where `afmText()` finds it.
78
+ * Call it once in the main process, before opening windows. On a Mac before
79
+ * macOS 26, or elsewhere, the pages hear the model is unavailable.
80
+ */
81
+ export function serveAfm(options: ServeAfmOptions = {}): void {
82
+ if (served) return
83
+ served = true
84
+ const node = (globalThis as unknown as { process: NodeProcess }).process
85
+ const { createRequire } = node.getBuiltinModule("node:module")
86
+ const path = node.getBuiltinModule("node:path")
87
+ // Electron answers `require("electron")` from anywhere in the main process.
88
+ const electron = createRequire(node.execPath)("electron") as Electron
89
+ const { app } = electron
90
+ const allow = options.allow ?? appPages
91
+
92
+ let directory = options.directory
93
+ if (!directory) {
94
+ try {
95
+ const fromApp = createRequire(path.join(app.getAppPath(), "package.json"))
96
+ directory = path.join(path.dirname(fromApp.resolve(`${PACKAGE}/package.json`)), "electron")
97
+ } catch (error) {
98
+ console.error(`Apple's on-device model is unavailable: ${PACKAGE} isn't in the app's node_modules (pass serveAfm's directory)`, error)
99
+ }
100
+ }
101
+
102
+ let addon: Addon | undefined
103
+ if (directory && node.platform === "darwin") {
104
+ try {
105
+ addon = createRequire(node.execPath)(unpacked(path.join(directory, "afm.node"))) as Addon
106
+ } catch (error) {
107
+ console.error("Apple's on-device model is unavailable: its addon didn't load", error)
108
+ }
109
+ }
110
+
111
+ // The native sessions, by native id: `${frame id}/${the page's id}`.
112
+ const owners = new Map<string, Owner>()
113
+ const frameIdOf = (frame: WebFrameMain) => `${frame.processId}:${frame.frameToken}`
114
+ const endSessions = (ended: (owner: Owner) => boolean) => {
115
+ for (const [id, owner] of owners) {
116
+ if (!ended(owner)) continue
117
+ owners.delete(id)
118
+ addon?.call("deleteSession", JSON.stringify({ sessionId: id }))
119
+ }
120
+ }
121
+ // A page's sessions end with its document: a navigation, or its contents closing.
122
+ const watched = new WeakSet<WebContents>()
123
+ const watch = (contents: WebContents) => {
124
+ if (watched.has(contents)) return
125
+ watched.add(contents)
126
+ const contentsId = contents.id
127
+ contents.on("did-start-navigation", ({ isSameDocument, frame }) => {
128
+ if (isSameDocument || !frame) return
129
+ const frameId = frameIdOf(frame)
130
+ endSessions((owner) => owner.frameId === frameId)
131
+ })
132
+ contents.once("destroyed", () => endSessions((owner) => owner.contentsId === contentsId))
133
+ }
134
+
135
+ electron.ipcMain.handle(CALL, (event, method: string, args: unknown) => {
136
+ const frame = event.senderFrame
137
+ if (!frame || !allow(frame.url)) throw new Error("This page may not use Apple's on-device model")
138
+ if (!addon) {
139
+ if (method === "availability") return { available: false, reason: "unsupportedOS" }
140
+ throw new Error("Apple's on-device model needs macOS 26 on a Mac with Apple silicon")
141
+ }
142
+ const options = { ...(typeof args === "object" && args !== null ? args : {}) } as Record<string, unknown>
143
+ // The frame's own session: another frame's can't be reached.
144
+ if (typeof options.sessionId === "string") {
145
+ const frameId = frameIdOf(frame)
146
+ const id = `${frameId}/${options.sessionId}`
147
+ if (method === "createSession") {
148
+ owners.set(id, { frame, frameId, contentsId: event.sender.id, sessionId: options.sessionId })
149
+ watch(event.sender)
150
+ } else if (method === "deleteSession") {
151
+ owners.delete(id)
152
+ }
153
+ options.sessionId = id
154
+ }
155
+ const reply = JSON.parse(addon.call(method, JSON.stringify(options))) as { result?: unknown; error?: string }
156
+ if (reply.error) throw new Error(reply.error)
157
+ return reply.result ?? null
158
+ })
159
+ // Each event to its session's frame only, with the page's id, while its page is allowed.
160
+ addon?.setListener((json) => {
161
+ const event = JSON.parse(json) as { sessionId?: unknown }
162
+ const owner = typeof event.sessionId === "string" ? owners.get(event.sessionId) : undefined
163
+ if (!owner || owner.frame.isDestroyed() || owner.frame.detached || !allow(owner.frame.url)) return
164
+ owner.frame.send(EVENT, { ...event, sessionId: owner.sessionId })
165
+ })
166
+
167
+ // The preload, in every session's pages: window.afm.
168
+ if (!directory) return
169
+ const preload = path.join(directory, "preload.cjs")
170
+ const preloaded = new WeakSet<Session>()
171
+ const addPreload = (session: Session) => {
172
+ if (preloaded.has(session)) return
173
+ preloaded.add(session)
174
+ session.registerPreloadScript({ type: "frame", id: "tanstack-ai-browser-afm", filePath: preload })
175
+ }
176
+ // Now if the app is ready, so a window opened next has it.
177
+ if (app.isReady()) addPreload(electron.session.defaultSession)
178
+ else void app.whenReady().then(() => addPreload(electron.session.defaultSession))
179
+ app.on("session-created", addPreload)
180
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * TanStack AI adapter for Apple's on-device Foundation Model, through the
3
+ * Foundation Models framework, with its native tool calling: a Capacitor
4
+ * plugin for iOS (`Afm`), Electron's main process on macOS (`serveAfm()`), or
5
+ * any host implementing `AfmNative`. The model catalog (model-meta.ts) is
6
+ * exported here too.
7
+ */
8
+ export { AfmTextAdapter, afmText } from "./adapter.js"
9
+ export type { Availability, AfmConfig, AfmTextModelOptions } from "./adapter.js"
10
+ export { Afm } from "./native.js"
11
+ export { serveAfm } from "./electron.js"
12
+ export type { ServeAfmOptions } from "./electron.js"
13
+ export type {
14
+ AfmNative,
15
+ NativeAvailability,
16
+ NativeEvent,
17
+ NativeGenerationSchema,
18
+ NativeMessage,
19
+ NativeProperty,
20
+ NativeSchema,
21
+ NativeTool,
22
+ NativeUsage,
23
+ UnavailableReason
24
+ } from "./native.js"
25
+ export { toNativeSchema } from "./schema.js"
26
+ export { AFM_CHAT_MODELS, AFM_MODEL_META } from "./model-meta.js"
27
+ export type { AfmChatModel, AfmModelMeta } from "./model-meta.js"
@@ -0,0 +1,37 @@
1
+ /**
2
+ * TanStack AI's messages as a native session's conversation (`NativeMessage`):
3
+ * text, the assistant's tool calls, and the tools' results. The framework
4
+ * reads text only here, so other parts are left out.
5
+ */
6
+ import type { ModelMessage } from "@tanstack/ai"
7
+
8
+ import type { NativeMessage } from "./native.js"
9
+
10
+ /** A message's text: its string, or its text parts joined. */
11
+ export function contentText(content: ModelMessage["content"]): string {
12
+ if (typeof content === "string") return content
13
+ if (!Array.isArray(content)) return ""
14
+ return content
15
+ .map((part) => (part.type === "text" ? part.content : ""))
16
+ .filter(Boolean)
17
+ .join("\n")
18
+ }
19
+
20
+ /** The messages as a session's conversation, each tool result named by its call's tool. */
21
+ export function toNativeMessages(messages: ReadonlyArray<ModelMessage>): Array<NativeMessage> {
22
+ const toolNames = new Map<string, string>()
23
+ return messages.map((m): NativeMessage => {
24
+ if (m.role === "tool") {
25
+ const id = m.toolCallId ?? ""
26
+ return { role: "tool", toolCallId: id, name: m.name ?? toolNames.get(id) ?? "", text: contentText(m.content) }
27
+ }
28
+ if (m.role === "assistant") {
29
+ const toolCalls = (m.toolCalls ?? []).map((call) => {
30
+ toolNames.set(call.id, call.function.name)
31
+ return { id: call.id, name: call.function.name, arguments: call.function.arguments || "{}" }
32
+ })
33
+ return { role: "assistant", text: contentText(m.content), ...(toolCalls.length > 0 && { toolCalls }) }
34
+ }
35
+ return { role: "user", text: contentText(m.content) }
36
+ })
37
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The model the framework runs: Apple's system language model
3
+ * (`SystemLanguageModel.default`), the on-device model of Apple Intelligence.
4
+ * It calls tools natively, and holds structured answers to a schema. It doesn't
5
+ * reason, so `chat()` offers no `reasoning` option for it.
6
+ */
7
+
8
+ /** What a Foundation Models model supports. */
9
+ export type AfmModelMeta = {
10
+ /** The model's name here. */
11
+ name: string
12
+ supports: {
13
+ input: ReadonlyArray<"text">
14
+ output: ReadonlyArray<"text">
15
+ capabilities: ReadonlyArray<"tools" | "structured-output">
16
+ }
17
+ }
18
+
19
+ const SYSTEM_MODEL = {
20
+ name: "system",
21
+ supports: {
22
+ input: ["text"],
23
+ output: ["text"],
24
+ capabilities: ["tools", "structured-output"]
25
+ }
26
+ } as const satisfies AfmModelMeta
27
+
28
+ /** The models the framework runs: Apple's system language model. */
29
+ export const AFM_CHAT_MODELS = [SYSTEM_MODEL.name] as const
30
+
31
+ /** A model the framework runs. */
32
+ export type AfmChatModel = (typeof AFM_CHAT_MODELS)[number]
33
+
34
+ /** Each model's metadata, by name. */
35
+ export const AFM_MODEL_META: Readonly<Record<AfmChatModel, AfmModelMeta>> = {
36
+ [SYSTEM_MODEL.name]: SYSTEM_MODEL
37
+ }
@@ -0,0 +1,13 @@
1
+ import assert from "node:assert/strict"
2
+ import { readFileSync } from "node:fs"
3
+ import { test } from "node:test"
4
+
5
+ import { hashFile, sourcesHash } from "../electron/sources-hash.mjs"
6
+
7
+ test("the committed Electron addon was built from the current Swift and C sources", () => {
8
+ assert.equal(
9
+ readFileSync(hashFile, "utf8").trim(),
10
+ sourcesHash(),
11
+ "electron/afm.node is stale: rebuild it (node electron/build.mjs, on a Mac with Xcode) and commit it with electron/afm.node.sha256"
12
+ )
13
+ })
package/src/native.ts ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The native side of the adapter: Apple's Foundation Models framework, reached
3
+ * through the Capacitor plugin on iOS (ios/Sources), or the Electron main
4
+ * process (`serveAfm()`, whose preload puts it at `window.afm`). Any host can
5
+ * stand in for them by implementing `AfmNative`.
6
+ */
7
+ import type { PluginListenerHandle } from "@capacitor/core"
8
+
9
+ /** Why the model can't run: Apple's reasons, or an OS before 26. */
10
+ export type UnavailableReason =
11
+ | "deviceNotEligible"
12
+ | "appleIntelligenceNotEnabled"
13
+ | "modelNotReady"
14
+ | "unsupportedOS"
15
+ | "unavailable"
16
+
17
+ export type NativeAvailability = {
18
+ available: boolean
19
+ reason?: UnavailableReason
20
+ /** Tokens a session holds (iOS/macOS 26.4 and later). */
21
+ contextSize?: number
22
+ }
23
+
24
+ /**
25
+ * A schema as the framework takes it: JSON Schema read into an ordered form
26
+ * (`schema.ts`), properties in the order given, and named definitions for
27
+ * `reference`s.
28
+ */
29
+ export type NativeSchema =
30
+ | { kind: "object"; name: string; description?: string; properties: Array<NativeProperty> }
31
+ | { kind: "array"; items: NativeSchema; minimumElements?: number; maximumElements?: number }
32
+ | { kind: "string"; choices?: Array<string>; constant?: string }
33
+ | { kind: "integer" | "number"; minimum?: number; maximum?: number }
34
+ | { kind: "boolean" }
35
+ | { kind: "null" }
36
+ | { kind: "anyOf"; name: string; description?: string; choices: Array<NativeSchema> }
37
+ | { kind: "reference"; name: string }
38
+
39
+ export type NativeProperty = { name: string; description?: string; optional: boolean; schema: NativeSchema }
40
+
41
+ /** A root schema and the definitions its references name. */
42
+ export type NativeGenerationSchema = { root: NativeSchema; definitions: Array<NativeSchema> }
43
+
44
+ export type NativeTool = { name: string; description: string; parameters: NativeGenerationSchema }
45
+
46
+ /** A message of the conversation a session starts from. */
47
+ export type NativeMessage =
48
+ | { role: "user"; text: string }
49
+ | { role: "assistant"; text: string; toolCalls?: Array<{ id: string; name: string; arguments: string }> }
50
+ | { role: "tool"; toolCallId: string; name: string; text: string }
51
+
52
+ /**
53
+ * A response's tokens, over every pass of the model (a tool call's too).
54
+ * `cachedTokens` is there only where the framework counts it right: for a
55
+ * session without tools.
56
+ */
57
+ export type NativeUsage = { inputTokens: number; cachedTokens?: number; outputTokens: number }
58
+
59
+ /** What a session sends while it responds, for the `turn` of the `respond` call. */
60
+ export type NativeEvent = { sessionId: string; turn: number } & (
61
+ | { type: "delta"; text: string }
62
+ | { type: "toolCalls"; calls: Array<{ id: string; name: string; arguments: string }> }
63
+ | { type: "done"; text: string; usage?: NativeUsage }
64
+ | { type: "error"; code: string; message: string }
65
+ )
66
+
67
+ export interface AfmNative {
68
+ availability(): Promise<NativeAvailability>
69
+ createSession(options: {
70
+ sessionId: string
71
+ instructions?: string
72
+ tools: Array<NativeTool>
73
+ messages: Array<NativeMessage>
74
+ }): Promise<void>
75
+ prewarm(options: { sessionId: string }): Promise<void>
76
+ /** Prompts a session; the response comes as events. A `schema` holds the answer to it (JSON in `done`). */
77
+ respond(options: {
78
+ sessionId: string
79
+ turn: number
80
+ prompt: string
81
+ options?: {
82
+ temperature?: number
83
+ maximumResponseTokens?: number
84
+ /** Whether the model may call the session's tools, must, or mustn't (iOS/macOS 27). Left out, the framework decides. */
85
+ toolCallingMode?: "allowed" | "required" | "disallowed"
86
+ }
87
+ schema?: NativeGenerationSchema
88
+ }): Promise<void>
89
+ /** A tool's result: the session's waiting call returns it, and the response goes on. */
90
+ submitToolResult(options: { sessionId: string; callId: string; output: string }): Promise<void>
91
+ /** Stops a session's response. */
92
+ cancel(options: { sessionId: string }): Promise<void>
93
+ deleteSession(options: { sessionId: string }): Promise<void>
94
+ addListener(eventName: "event", listener: (event: NativeEvent) => void): Promise<PluginListenerHandle>
95
+ }
96
+
97
+ let loading: Promise<{ plugin: AfmNative }> | undefined
98
+ /**
99
+ * The plugin, loading Capacitor on first use: pages elsewhere never load it.
100
+ * It's wrapped: a Capacitor plugin answers every property, `then` too, so a
101
+ * promise of the plugin itself would wait on it forever.
102
+ */
103
+ const capacitor = () =>
104
+ (loading ??= import("@capacitor/core").then(({ registerPlugin }) => ({ plugin: registerPlugin<AfmNative>("Afm") })))
105
+
106
+ /**
107
+ * The Capacitor plugin (iOS), loading Capacitor on its first call. Elsewhere
108
+ * its calls reject: Capacitor has no web version of it.
109
+ */
110
+ export const Afm: AfmNative = {
111
+ availability: async () => (await capacitor()).plugin.availability(),
112
+ createSession: async (options) => (await capacitor()).plugin.createSession(options),
113
+ prewarm: async (options) => (await capacitor()).plugin.prewarm(options),
114
+ respond: async (options) => (await capacitor()).plugin.respond(options),
115
+ submitToolResult: async (options) => (await capacitor()).plugin.submitToolResult(options),
116
+ cancel: async (options) => (await capacitor()).plugin.cancel(options),
117
+ deleteSession: async (options) => (await capacitor()).plugin.deleteSession(options),
118
+ addListener: async (name, listener) => (await capacitor()).plugin.addListener(name, listener)
119
+ }
@@ -0,0 +1,146 @@
1
+ import assert from "node:assert/strict"
2
+ import { test } from "node:test"
3
+
4
+ import { toNativeSchema } from "./schema.js"
5
+
6
+ test("an object keeps its properties' order; required, nullable and descriptions carry over", () => {
7
+ const schema = toNativeSchema(
8
+ {
9
+ type: "object",
10
+ description: "Where",
11
+ properties: {
12
+ city: { type: "string", description: "The city" },
13
+ units: { type: "string", enum: ["celsius", "fahrenheit"] },
14
+ days: { type: ["integer", "null"], minimum: 1, maximum: 7 },
15
+ hourly: { type: "boolean" }
16
+ },
17
+ required: ["city", "days"]
18
+ },
19
+ "get_weather"
20
+ )
21
+ assert.deepEqual(schema, {
22
+ root: {
23
+ kind: "object",
24
+ name: "get_weather",
25
+ description: "Where",
26
+ properties: [
27
+ { name: "city", description: "The city", optional: false, schema: { kind: "string" } },
28
+ { name: "units", optional: true, schema: { kind: "string", choices: ["celsius", "fahrenheit"] } },
29
+ { name: "days", optional: true, schema: { kind: "integer", minimum: 1, maximum: 7 } },
30
+ { name: "hourly", optional: true, schema: { kind: "boolean" } }
31
+ ]
32
+ },
33
+ definitions: []
34
+ })
35
+ })
36
+
37
+ test("nested objects and arrays are named by their path", () => {
38
+ const { root } = toNativeSchema(
39
+ {
40
+ type: "object",
41
+ properties: {
42
+ stops: { type: "array", minItems: 1, maxItems: 3, items: { type: "object", properties: { name: { type: "string" } } } }
43
+ }
44
+ },
45
+ "plan-trip"
46
+ )
47
+ assert.deepEqual(root, {
48
+ kind: "object",
49
+ name: "plan_trip",
50
+ properties: [
51
+ {
52
+ name: "stops",
53
+ optional: true,
54
+ schema: {
55
+ kind: "array",
56
+ minimumElements: 1,
57
+ maximumElements: 3,
58
+ items: { kind: "object", name: "plan_trip_stops_item", properties: [{ name: "name", optional: true, schema: { kind: "string" } }] }
59
+ }
60
+ }
61
+ ]
62
+ })
63
+ })
64
+
65
+ test("anyOf becomes a choice (null left out); one non-null choice is the schema itself; const is a constant", () => {
66
+ const { root } = toNativeSchema(
67
+ {
68
+ type: "object",
69
+ properties: {
70
+ target: { anyOf: [{ type: "string" }, { type: "number" }] },
71
+ note: { anyOf: [{ type: "string" }, { type: "null" }] },
72
+ kind: { const: "search" }
73
+ }
74
+ },
75
+ "tool"
76
+ )
77
+ assert.equal(root.kind, "object")
78
+ const [target, note, kind] = root.kind === "object" ? root.properties : []
79
+ assert.deepEqual(target.schema, { kind: "anyOf", name: "tool_target", choices: [{ kind: "string" }, { kind: "number" }] })
80
+ assert.deepEqual(note, { name: "note", optional: true, schema: { kind: "string" } })
81
+ assert.deepEqual(kind.schema, { kind: "string", constant: "search" })
82
+ })
83
+
84
+ test("$refs to object definitions are references; to plain values, written in place", () => {
85
+ const schema = toNativeSchema(
86
+ {
87
+ type: "object",
88
+ properties: { home: { $ref: "#/$defs/Place" }, mood: { $ref: "#/$defs/Mood" } },
89
+ $defs: {
90
+ Place: { type: "object", properties: { city: { type: "string" } } },
91
+ Mood: { type: "string", enum: ["happy", "sad"] }
92
+ }
93
+ },
94
+ "profile"
95
+ )
96
+ assert.deepEqual(schema.root.kind === "object" && schema.root.properties.map((p) => p.schema), [
97
+ { kind: "reference", name: "Place" },
98
+ { kind: "string", choices: ["happy", "sad"] }
99
+ ])
100
+ assert.deepEqual(schema.definitions, [
101
+ { kind: "object", name: "Place", properties: [{ name: "city", optional: true, schema: { kind: "string" } }] }
102
+ ])
103
+ })
104
+
105
+ test("enums hold to their exact members: strings as choices, integers (with strings) as a choice of exact values; what the framework can't hold is refused", () => {
106
+ const { root } = toNativeSchema(
107
+ {
108
+ type: "object",
109
+ properties: {
110
+ unit: { type: "string", enum: ["c", "f"] },
111
+ stars: { type: "integer", enum: [1, 10], description: "A rating." },
112
+ size: { enum: ["auto", 8] },
113
+ on: { type: "boolean", enum: [true, false] }
114
+ },
115
+ required: ["unit", "stars", "size", "on"]
116
+ },
117
+ "t"
118
+ )
119
+ assert.equal(root.kind, "object")
120
+ const property = (name: string) => (root.kind === "object" ? root.properties.find((p) => p.name === name) : undefined)
121
+ assert.deepEqual(property("unit")?.schema, { kind: "string", choices: ["c", "f"] })
122
+ assert.deepEqual(property("stars")?.schema, {
123
+ kind: "anyOf",
124
+ name: "t_stars",
125
+ description: "A rating.",
126
+ choices: [
127
+ { kind: "integer", minimum: 1, maximum: 1 },
128
+ { kind: "integer", minimum: 10, maximum: 10 }
129
+ ]
130
+ })
131
+ assert.equal(property("stars")?.description, "A rating.")
132
+ assert.deepEqual(property("size")?.schema, {
133
+ kind: "anyOf",
134
+ name: "t_size",
135
+ choices: [
136
+ { kind: "string", constant: "auto" },
137
+ { kind: "integer", minimum: 8, maximum: 8 }
138
+ ]
139
+ })
140
+ assert.deepEqual(property("on")?.schema, { kind: "boolean" })
141
+ // One integer is that integer.
142
+ assert.deepEqual(toNativeSchema({ type: "integer", enum: [3] }, "n").root, { kind: "integer", minimum: 3, maximum: 3 })
143
+ assert.throws(() => toNativeSchema({ type: "boolean", enum: [true] }, "flag"), /enum of only true isn't supported/)
144
+ assert.throws(() => toNativeSchema({ type: "number", enum: [0.5, 1.5] }, "ratio"), /decimal numbers isn't supported/)
145
+ assert.throws(() => toNativeSchema({ enum: ["a", true] }, "mixed"), /mixing string and boolean/)
146
+ })