From aec8a4826efce8594974612571eca961e39a8024 Mon Sep 17 00:00:00 2001 From: Cody Bromley Date: Thu, 12 Mar 2026 22:41:27 -0500 Subject: [PATCH 01/12] fix: standardize references to Apple Foundation Models framework across documentation and code --- CHANGELOG.md | 2 +- README.md | 13 +++++--- docs/.vitepress/config.ts | 6 ++-- docs/.vitepress/generate-llms.ts | 4 +-- docs/.vitepress/theme/HeroScene.vue | 52 ++++++++++++++--------------- docs/.vitepress/theme/custom.css | 38 +++++++++++++++++---- docs/index.md | 2 +- docs/tsconfig.json | 13 ++++++++ package.json | 2 +- src/schema.ts | 2 +- 10 files changed, 89 insertions(+), 45 deletions(-) create mode 100644 docs/tsconfig.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bd04af..a29860b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,7 +121,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- TypeScript/Node.js bindings for Apple's Foundation Models framework via koffi FFI +- TypeScript/Node.js bindings for Apple Foundation Models framework via koffi FFI - `SystemLanguageModel` class with availability checks and `waitUntilAvailable()` - `LanguageModelSession` with `respond()`, `streamResponse()`, and `respondWithJsonSchema()` for text, streaming, and structured generation - `GenerationSchema` and `GenerationSchemaProperty` for typed structured output with generation guides diff --git a/README.md b/README.md index 47368f2..9dce70d 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,11 @@ model.dispose(); ## Requirements -- Mac running macOS 26 (Tahoe) or later on Apple Silicon +- Apple Silicon (M-Series) Mac running macOS 26 or later - Apple Intelligence enabled in System Settings - Node.js 20+ -Xcode **not** required (prebuilt dylib is bundled with the npm package) +Unless you are building from source, Xcode is not required. ## Development @@ -84,5 +84,10 @@ Issues and PRs welcome. If something doesn't work on your machine or you find a ## License -Apache 2.0 - See [LICENSE.md](LICENSE.md) -The npm package bundles Apple's Foundation Models C bindings and prebuilt dylib (also Apache 2.0 - see [NOTICE](NOTICE)) +© 2026 Cody Bromley and contributors. + +TSDM is licensed under the Apache 2.0 license.For complete licensing information, see this project's [LICENSE file](LICENSE.md). + +The `tsfm-sdk` package available from NPM contains precompiled C bindings and libraries for working with macOS 26 Foundation Models adapted from [python-apple-fm-sdk](https://github.com/apple/python-apple-fm-sdk) which is Copyright Apple Inc. and licensed under the Apache 2.0 license. + +This project is unaffiliated with Apple, Inc. The terms "Apple" and "Apple Intelligence" are trademarks of Apple Inc., registered in the U.S. and other countries and regions. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 0e5a48f..1cdbd93 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -79,7 +79,7 @@ function guideSidebar() { export default defineConfig({ title: "tsfm", description: - "TypeScript SDK for Apple's Foundation Models framework — on-device Apple Intelligence in Node.js", + "TypeScript SDK for Apple Foundation Models framework — on-device Apple Intelligence in Node.js", base: "/", cleanUrls: true, @@ -109,7 +109,7 @@ export default defineConfig({ { property: "og:description", content: - "TypeScript SDK for Apple's Foundation Models — on-device AI inference in Node.js. No keys. No fees. It just works.", + "TypeScript SDK for Apple Foundation Models — on-device AI inference in Node.js. No keys. No fees. It just works.", }, ], ["meta", { property: "og:image", content: "https://tsfm.dev/og-image.png" }], @@ -125,7 +125,7 @@ export default defineConfig({ { name: "twitter:description", content: - "TypeScript SDK for Apple's Foundation Models. On-device AI inference in Node.js. No keys. No fees. It just works.", + "TypeScript SDK for Apple Foundation Models. On-device AI inference in Node.js. No keys. No fees. It just works.", }, ], ["meta", { name: "twitter:image", content: "https://tsfm.dev/og-image.png" }], diff --git a/docs/.vitepress/generate-llms.ts b/docs/.vitepress/generate-llms.ts index 8404ae6..d14078d 100644 --- a/docs/.vitepress/generate-llms.ts +++ b/docs/.vitepress/generate-llms.ts @@ -165,7 +165,7 @@ export function generateLlmTxt(): string { const lines: string[] = [ "# tsfm", "", - "> TypeScript SDK for Apple's Foundation Models framework — on-device Apple Intelligence inference in Node.js via FFI. macOS 26+, Apple Silicon only.", + "> TypeScript SDK for Apple Foundation Models framework — on-device Apple Intelligence inference in Node.js via FFI. macOS 26+, Apple Silicon only.", "", ]; @@ -218,7 +218,7 @@ export async function generateLlmsFullTxt(srcDir: string): Promise { const parts: string[] = [ "# tsfm — Complete Documentation", "", - "> TypeScript SDK for Apple's Foundation Models framework — on-device Apple Intelligence inference in Node.js via FFI. macOS 26+, Apple Silicon only.", + "> TypeScript SDK for Apple Foundation Models framework — on-device Apple Intelligence inference in Node.js via FFI. macOS 26+, Apple Silicon only.", "", ]; diff --git a/docs/.vitepress/theme/HeroScene.vue b/docs/.vitepress/theme/HeroScene.vue index 6436141..3ad9c86 100644 --- a/docs/.vitepress/theme/HeroScene.vue +++ b/docs/.vitepress/theme/HeroScene.vue @@ -38,34 +38,34 @@ onMounted(async () => { el.appendChild(renderer.domElement); // --- Lattice shape --- + // 11 nodes: two poles (top/bot), a center, and two rings of 4 const S = 0.7; const top = [0, 1, 0], bot = [0, -1, 0], mid = [0, 0, 0]; - const ulf = [-S, .4, S], ulb = [-S, .4, -S]; // upper-left front/back - const urf = [S, .4, S], urb = [S, .4, -S]; // upper-right front/back - const llf = [-S, -.4, S], llb = [-S, -.4, -S]; // lower-left front/back - const lrf = [S, -.4, S], lrb = [S, -.4, -S]; // lower-right front/back - - const allNodes = [top, ulf, ulb, urf, urb, mid, llf, llb, lrf, lrb, bot]; - - // Each pair of entries = one line segment - type P = number[]; - const seg = (...pairs: [P, P][]) => pairs.flatMap(([a, b]) => [...a, ...b]); - const linePoints = seg( - // top/bottom spokes - [top, ulf], [top, ulb], [top, urf], [top, urb], [top, mid], - [bot, llf], [bot, llb], [bot, lrf], [bot, lrb], [bot, mid], - // center spokes - [mid, ulf], [mid, ulb], [mid, urf], [mid, urb], - [mid, llf], [mid, llb], [mid, lrf], [mid, lrb], - // front face - [ulf, urf], [urf, lrf], [lrf, llf], [llf, ulf], [ulf, lrf], [urf, llf], - // back face - [ulb, urb], [urb, lrb], [lrb, llb], [llb, ulb], [ulb, lrb], [urb, llb], - // left face - [ulf, ulb], [ulb, llb], [llb, llf], [llf, ulf], [ulf, llb], [ulb, llf], - // right face - [urf, urb], [urb, lrb], [lrb, lrf], [lrf, urf], [urf, lrb], [urb, lrf], - ); + const ulf = [-S, .4, S], ulb = [-S, .4, -S]; + const urf = [S, .4, S], urb = [S, .4, -S]; + const llf = [-S, -.4, S], llb = [-S, -.4, -S]; + const lrf = [S, -.4, S], lrb = [S, -.4, -S]; + + const upper = [ulf, ulb, urf, urb]; + const lower = [llf, llb, lrf, lrb]; + const allNodes = [top, ...upper, mid, ...lower, bot]; + + // hub: connect one node to many others + const hub = (c: number[], ...spokes: number[][]) => + spokes.flatMap((s) => [...c, ...s]); + // quad: 4 outline edges + 2 diagonal crossbars + const quad = (a: number[], b: number[], c: number[], d: number[]) => + [...a, ...b, ...b, ...c, ...c, ...d, ...d, ...a, ...a, ...c, ...b, ...d]; + + const linePoints = [ + ...hub(top, ...upper, mid), + ...hub(bot, ...lower, mid), + ...hub(mid, ...upper, ...lower), + ...quad(ulf, urf, lrf, llf), // front + ...quad(ulb, urb, lrb, llb), // back + ...quad(ulf, ulb, llb, llf), // left + ...quad(urf, urb, lrb, lrf), // right + ]; const group = new THREE.Group(); scene.add(group); diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css index 2536bac..be5d042 100644 --- a/docs/.vitepress/theme/custom.css +++ b/docs/.vitepress/theme/custom.css @@ -189,15 +189,27 @@ body { } @keyframes hero-hue { - 0% { filter: hue-rotate(0deg); } - 50% { filter: hue-rotate(40deg); } - 100% { filter: hue-rotate(0deg); } + 0% { + filter: hue-rotate(0deg); + } + 50% { + filter: hue-rotate(40deg); + } + 100% { + filter: hue-rotate(0deg); + } } @keyframes hero-hue-glow { - 0% { filter: blur(56px) hue-rotate(0deg); } - 50% { filter: blur(56px) hue-rotate(40deg); } - 100% { filter: blur(56px) hue-rotate(0deg); } + 0% { + filter: blur(56px) hue-rotate(0deg); + } + 50% { + filter: blur(56px) hue-rotate(40deg); + } + 100% { + filter: blur(56px) hue-rotate(0deg); + } } .VPHero .main::after { @@ -263,10 +275,24 @@ body { line-height: 1.4; } +.VPHero .tagline { + max-width: 32rem; +} + +@media (max-width: 960px) { + .VPHero .tagline { + max-width: 26rem; + } +} + @media (max-width: 640px) { .home-explore { grid-template-columns: 1fr; } + + .VPHero .tagline { + max-width: 24rem; + } } /* Buttons */ diff --git a/docs/index.md b/docs/index.md index a67f41b..89f1d20 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,7 +6,7 @@ titleTemplate: false hero: name: tsfm text: On-device Apple Intelligence in Node.js - tagline: "TypeScript SDK for Apple's Foundation Models.
No keys. No fees. It just works." + tagline: "TypeScript SDK for Apple Foundation Models. No keys. No fees. It just works." image: src: /logo.svg alt: tsfm diff --git a/docs/tsconfig.json b/docs/tsconfig.json new file mode 100644 index 0000000..6d01f06 --- /dev/null +++ b/docs/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": [ + ".vitepress/**/*" + ] +} \ No newline at end of file diff --git a/package.json b/package.json index 82189de..cefa32a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tsfm-sdk", "version": "0.3.1", - "description": "Unofficial TypeScript bindings for Apple's Foundation Models framework (Apple Intelligence on-device LLM)", + "description": "Unofficial TypeScript bindings for Apple Foundation Models framework (Apple Intelligence on-device LLM)", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/src/schema.ts b/src/schema.ts index 31f238a..2c2cff8 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -247,7 +247,7 @@ export class GenerationSchema { } // --------------------------------------------------------------------------- -// JSON Schema normalization for Apple's Foundation Models C API +// JSON Schema normalization for Apple Foundation Models C API // --------------------------------------------------------------------------- /** From f5357a0a8f31105fa1971d43177b6b04ffeee3c9 Mon Sep 17 00:00:00 2001 From: Cody Bromley Date: Fri, 13 Mar 2026 08:58:48 -0500 Subject: [PATCH 02/12] feat: add generable() typed schema builder and improve test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add generable() function for declarative structured output schemas with full TypeScript type inference — the TS equivalent of Python SDK's @generable decorator. Also add streaming session reset on early break, FFI type casts for the provider interface, and comprehensive test coverage for schema, session, and compat utils (100% across all metrics). --- src/core.ts | 4 +- src/index.ts | 4 + src/schema.ts | 162 ++++++++++++++++++++++++-- src/session.ts | 66 ++++++----- src/tool.ts | 2 +- src/transcript.ts | 8 +- tests/unit/compat/responses.test.ts | 41 +++++++ tests/unit/schema.test.ts | 169 ++++++++++++++++++++++++++++ tests/unit/session.test.ts | 43 ++++++- 9 files changed, 460 insertions(+), 39 deletions(-) diff --git a/src/core.ts b/src/core.ts index e32fec1..8c812f3 100644 --- a/src/core.ts +++ b/src/core.ts @@ -55,7 +55,7 @@ export class SystemLanguageModel { this._nativeModel = fn.FMSystemLanguageModelCreate( opts.useCase ?? SystemLanguageModelUseCase.GENERAL, opts.guardrails ?? SystemLanguageModelGuardrails.DEFAULT, - ); + ) as NativePointer | null; if (!this._nativeModel) { throw new FoundationModelsError("Failed to create SystemLanguageModel"); } @@ -74,7 +74,7 @@ export class SystemLanguageModel { isAvailable(): AvailabilityResult { const fn = getFunctions(); const reasonOut = [0]; - const available: boolean = fn.FMSystemLanguageModelIsAvailable(this._nativeModel, reasonOut); + const available = fn.FMSystemLanguageModelIsAvailable(this._nativeModel, reasonOut) as boolean; if (available) return { available: true }; const code: number = reasonOut[0]; const reason = Object.values(SystemLanguageModelUnavailableReason).includes(code) diff --git a/src/index.ts b/src/index.ts index 2999f72..ce66ab8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,10 @@ export { GenerationGuide, GuideType, GeneratedContent, + generable, + type Generable, + type PropertyDef, + type InferSchema, type PropertyType, type JsonSchema, type JsonObject, diff --git a/src/schema.ts b/src/schema.ts index 2c2cff8..0b75e35 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -187,7 +187,7 @@ export class GenerationSchemaProperty { opts.description ?? null, type, opts.optional ?? false, - ); + ) as NativePointer; for (const guide of opts.guides ?? []) { guide._applyToProperty(this._nativeProperty); @@ -205,7 +205,7 @@ export class GenerationSchema { constructor(name: string, description?: string) { const fn = getFunctions(); - this._nativeSchema = fn.FMGenerationSchemaCreate(name, description ?? null); + this._nativeSchema = fn.FMGenerationSchemaCreate(name, description ?? null) as NativePointer; } addProperty(property: GenerationSchemaProperty): this { @@ -239,13 +239,155 @@ export class GenerationSchema { this._nativeSchema, errorCode, null, - ); + ) as NativePointer | null; const json = decodeAndFreeString(pointer); if (!json) throw statusToError(errorCode[0], "Failed to serialize GenerationSchema"); return JSON.parse(json); } } +// --------------------------------------------------------------------------- +// generable() — declarative schema builder with typed parsing +// --------------------------------------------------------------------------- + +/** Property definition for scalar types. */ +export interface ScalarPropertyDef { + type: "string" | "integer" | "number" | "boolean"; + description?: string; + optional?: boolean; + guides?: GenerationGuide[]; +} + +/** Property definition for array types. */ +export interface ArrayPropertyDef { + type: "array"; + items: PropertyDef; + description?: string; + optional?: boolean; + guides?: GenerationGuide[]; +} + +/** Property definition for nested object types. */ +export interface ObjectPropertyDef { + type: "object"; + properties: Record; + description?: string; + optional?: boolean; +} + +/** Union of all property definition shapes. */ +export type PropertyDef = ScalarPropertyDef | ArrayPropertyDef | ObjectPropertyDef; + +/** Maps a scalar type string to its TypeScript type. */ +type InferScalar = T extends "string" + ? string + : T extends "integer" | "number" + ? number + : T extends "boolean" + ? boolean + : unknown; + +/** Maps a single PropertyDef to its TypeScript type. */ +type InferPropertyType = D extends { + type: "array"; + items: infer I extends PropertyDef; +} + ? InferPropertyType[] + : D extends { type: "object"; properties: infer P extends Record } + ? InferSchema

+ : D extends { type: infer T extends string } + ? InferScalar + : unknown; + +/** Keys of T where optional is not literally true. */ +type RequiredKeys> = { + [K in keyof T]: T[K] extends { optional: true } ? never : K; +}[keyof T]; + +/** Keys of T where optional is literally true. */ +type OptionalKeys> = { + [K in keyof T]: T[K] extends { optional: true } ? K : never; +}[keyof T]; + +/** Maps a record of PropertyDefs to a typed object, respecting optional fields. */ +export type InferSchema> = { + [K in RequiredKeys]: InferPropertyType; +} & { + [K in OptionalKeys]?: InferPropertyType; +}; + +/** The return type of `generable()`. */ +export interface Generable> { + /** The GenerationSchema ready to pass to `respondWithSchema()`. */ + readonly schema: GenerationSchema; + /** Parse a GeneratedContent into a fully typed object. */ + parse(content: GeneratedContent): InferSchema; +} + +/** Recursively adds a property definition to a GenerationSchema. */ +function addPropertyDef(schema: GenerationSchema, name: string, def: PropertyDef): void { + if (def.type === "object") { + const nested = new GenerationSchema(name, def.description); + for (const [key, nestedDef] of Object.entries(def.properties)) { + addPropertyDef(nested, key, nestedDef); + } + schema.addReferenceSchema(nested); + schema.property(name, "object", { optional: def.optional }); + } else if (def.type === "array" && def.items.type === "object") { + const itemSchema = new GenerationSchema(name, def.items.description); + for (const [key, nestedDef] of Object.entries(def.items.properties)) { + addPropertyDef(itemSchema, key, nestedDef); + } + schema.addReferenceSchema(itemSchema); + schema.property(name, "array", { + description: def.description, + optional: def.optional, + guides: def.guides, + }); + } else { + schema.property(name, def.type, { + description: def.description, + optional: def.optional, + guides: "guides" in def ? def.guides : undefined, + }); + } +} + +/** + * Define a typed schema for structured generation. + * + * Returns an object with a `schema` (for `respondWithSchema()`) and a typed + * `parse()` method that converts `GeneratedContent` into a plain object. + * + * ```ts + * const MovieReview = generable("MovieReview", { + * title: { type: "string", description: "Movie title" }, + * rating: { type: "integer", guides: [GenerationGuide.range(1, 5)] }, + * review: { type: "string" }, + * }); + * + * const content = await session.respondWithSchema("Review Inception", MovieReview.schema); + * const review = MovieReview.parse(content); + * // review.title: string, review.rating: number, review.review: string + * ``` + */ +export function generable>( + name: string, + properties: T, + description?: string, +): Generable { + const schema = new GenerationSchema(name, description); + for (const [key, def] of Object.entries(properties)) { + addPropertyDef(schema, key, def); + } + return { + schema, + parse(content: GeneratedContent): InferSchema { + return content.toObject() as InferSchema; + }, + }; +} + // --------------------------------------------------------------------------- // JSON Schema normalization for Apple Foundation Models C API // --------------------------------------------------------------------------- @@ -334,18 +476,24 @@ export class GeneratedContent { static fromJson(jsonString: string): GeneratedContent { const fn = getFunctions(); const errorCode = [0]; - const pointer = fn.FMGeneratedContentCreateFromJSON(jsonString, errorCode, null); + const pointer = fn.FMGeneratedContentCreateFromJSON( + jsonString, + errorCode, + null, + ) as NativePointer | null; if (!pointer) throw statusToError(errorCode[0], "Failed to create GeneratedContent from JSON"); return new GeneratedContent(pointer); } get isComplete(): boolean { - return getFunctions().FMGeneratedContentIsComplete(this._nativeContent); + return getFunctions().FMGeneratedContentIsComplete(this._nativeContent) as boolean; } /** Returns the raw JSON string of the generated content. */ toJson(): string { - const pointer = getFunctions().FMGeneratedContentGetJSONString(this._nativeContent); + const pointer = getFunctions().FMGeneratedContentGetJSONString( + this._nativeContent, + ) as NativePointer | null; return decodeAndFreeString(pointer) ?? "{}"; } @@ -377,7 +525,7 @@ export class GeneratedContent { propertyName, null, null, - ); + ) as NativePointer | null; const raw = decodeAndFreeString(pointer); if (raw !== null) { try { diff --git a/src/session.ts b/src/session.ts index d8b09fe..158b97b 100644 --- a/src/session.ts +++ b/src/session.ts @@ -67,7 +67,9 @@ export class LanguageModelSession { private _weakRef: WeakRef | null = null; get transcript(): Transcript { - if (!this._transcript) throw new FoundationModelsError("Session not initialized"); + if (!this._transcript) { + throw new FoundationModelsError("Session not initialized"); + } return this._transcript; } @@ -107,7 +109,7 @@ export class LanguageModelSession { opts.instructions ?? null, toolPointersArg, tools.length, - ); + ) as NativePointer | null; if (!pointer) throw new FoundationModelsError("Failed to create LanguageModelSession"); this._init(pointer, new Transcript(pointer)); @@ -135,7 +137,7 @@ export class LanguageModelSession { opts.model?._nativeModel ?? null, toolPointersArg, tools.length, - ); + ) as NativePointer | null; if (!pointer) throw new FoundationModelsError("Failed to create session from transcript"); @@ -150,7 +152,7 @@ export class LanguageModelSession { /** Whether the session is currently processing a request (backed by C API). */ get isResponding(): boolean { if (!this._nativeSession) return false; - return getFunctions().FMLanguageModelSessionIsResponding(this._nativeSession); + return getFunctions().FMLanguageModelSessionIsResponding(this._nativeSession) as boolean; } /** @@ -257,7 +259,7 @@ export class LanguageModelSession { this._nativeSession, prompt, optionsJson, - ); + ) as NativePointer; // FMLanguageModelSessionResponseStreamIterate spawns a single Swift Task // that calls the callback once per chunk, then once more with null content @@ -315,6 +317,9 @@ export class LanguageModelSession { clearInterval(keepAlive); if (!streamDone) { unregisterCallback(callback); + // Reset the session after an early break so subsequent calls + // don't stall waiting for the cancelled stream to finish. + if (this._nativeSession) fn.FMLanguageModelSessionReset(this._nativeSession); } fn.FMRelease(streamPointer); release(); @@ -394,7 +399,7 @@ export class LanguageModelSession { // contentRef may be null on error; FMGeneratedContentGetJSONString // and FMRelease are no-ops on null per the C API contract. const msg = decodeAndFreeString( - getFunctions().FMGeneratedContentGetJSONString(contentRef), + getFunctions().FMGeneratedContentGetJSONString(contentRef) as NativePointer | null, ); getFunctions().FMRelease(contentRef); reject(statusToError(status, msg ?? undefined)); @@ -410,8 +415,15 @@ export class LanguageModelSession { private _respondText(prompt: string, options: GenerationOptions | undefined): Promise { const fn = getFunctions(); const optionsJson = serializeOptions(options); - return this._runResponseCallback((callback) => - fn.FMLanguageModelSessionRespond(this._nativeSession, prompt, optionsJson, null, callback), + return this._runResponseCallback( + (callback) => + fn.FMLanguageModelSessionRespond( + this._nativeSession, + prompt, + optionsJson, + null, + callback, + ) as NativePointer, ); } @@ -422,15 +434,16 @@ export class LanguageModelSession { ): Promise { const fn = getFunctions(); const optionsJson = serializeOptions(options); - return this._runStructuredCallback((callback) => - fn.FMLanguageModelSessionRespondWithSchema( - this._nativeSession, - prompt, - schema._nativeSchema, - optionsJson, - null, - callback, - ), + return this._runStructuredCallback( + (callback) => + fn.FMLanguageModelSessionRespondWithSchema( + this._nativeSession, + prompt, + schema._nativeSchema, + optionsJson, + null, + callback, + ) as NativePointer, ); } @@ -442,15 +455,16 @@ export class LanguageModelSession { const fn = getFunctions(); const optionsJson = serializeOptions(options); const schemaJson = JSON.stringify(afmSchemaFormat(jsonSchema)); - return this._runStructuredCallback((callback) => - fn.FMLanguageModelSessionRespondWithSchemaFromJSON( - this._nativeSession, - prompt, - schemaJson, - optionsJson, - null, - callback, - ), + return this._runStructuredCallback( + (callback) => + fn.FMLanguageModelSessionRespondWithSchemaFromJSON( + this._nativeSession, + prompt, + schemaJson, + optionsJson, + null, + callback, + ) as NativePointer, ); } } diff --git a/src/tool.ts b/src/tool.ts index cff4cb0..1ad3ead 100644 --- a/src/tool.ts +++ b/src/tool.ts @@ -111,7 +111,7 @@ export abstract class Tool { this._callback, errorCode, null, - ); + ) as NativePointer | null; if (!pointer) { const err = statusToError(errorCode[0], `Failed to create tool '${this.name}'`); diff --git a/src/transcript.ts b/src/transcript.ts index 282b2f1..1590e8a 100644 --- a/src/transcript.ts +++ b/src/transcript.ts @@ -72,7 +72,7 @@ export class Transcript { this._nativeSession, null, null, - ); + ) as NativePointer | null; const json = decodeAndFreeString(pointer); if (!json) throw new FoundationModelsError("Failed to export transcript"); return json; @@ -94,7 +94,11 @@ export class Transcript { static fromJson(json: string): Transcript { const fn = getFunctions(); const errorCode = [0]; - const pointer = fn.FMTranscriptCreateFromJSONString(json, errorCode, null); + const pointer = fn.FMTranscriptCreateFromJSONString( + json, + errorCode, + null, + ) as NativePointer | null; if (!pointer) { throw statusToError(errorCode[0], "Failed to deserialize transcript"); } diff --git a/tests/unit/compat/responses.test.ts b/tests/unit/compat/responses.test.ts index 75d5d64..868bbaf 100644 --- a/tests/unit/compat/responses.test.ts +++ b/tests/unit/compat/responses.test.ts @@ -1474,6 +1474,47 @@ describe("Responses API compat layer", () => { client.close(); }); + it("reorderJson reorders keys inside array items when items schema has properties", async () => { + simulateStructuredSuccess({ + people: [ + { age: 30, name: "Alice" }, + { age: 25, name: "Bob" }, + ], + }); + + const client = new Client(); + const result = (await client.responses.create({ + input: "Generate", + text: { + format: { + type: "json_schema", + name: "Test", + schema: { + type: "object", + properties: { + people: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer" }, + }, + }, + }, + }, + }, + }, + }, + })) as Response; + + const parsed = JSON.parse(result.output_text); + expect(Object.keys(parsed.people[0])).toEqual(["name", "age"]); + expect(parsed.people[0].name).toBe("Alice"); + expect(parsed.people[1].name).toBe("Bob"); + client.close(); + }); + it("handles assistant messages in multi-turn array input", async () => { simulateRespondSuccess("OK"); diff --git a/tests/unit/schema.test.ts b/tests/unit/schema.test.ts index 63250d9..c3caed3 100644 --- a/tests/unit/schema.test.ts +++ b/tests/unit/schema.test.ts @@ -16,6 +16,7 @@ import { GenerationSchema, GenerationSchemaProperty, afmSchemaFormat, + generable, } from "../../src/schema.js"; import type { NativePointer } from "../../src/bindings.js"; @@ -547,3 +548,171 @@ describe("GeneratedContent", () => { ); }); }); + +describe("generable", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates a schema with scalar properties", () => { + const def = generable("Person", { + name: { type: "string", description: "Full name" }, + age: { type: "integer" }, + active: { type: "boolean" }, + }); + + expect(def.schema).toBeInstanceOf(GenerationSchema); + expect(mockFns.FMGenerationSchemaCreate).toHaveBeenCalledWith("Person", null); + expect(mockFns.FMGenerationSchemaPropertyCreate).toHaveBeenCalledTimes(3); + expect(mockFns.FMGenerationSchemaPropertyCreate).toHaveBeenCalledWith( + "name", + "Full name", + "string", + false, + ); + expect(mockFns.FMGenerationSchemaPropertyCreate).toHaveBeenCalledWith("age", null, "integer", false); + expect(mockFns.FMGenerationSchemaPropertyCreate).toHaveBeenCalledWith( + "active", + null, + "boolean", + false, + ); + }); + + it("passes description to GenerationSchema", () => { + generable("Thing", { x: { type: "string" } }, "A thing"); + expect(mockFns.FMGenerationSchemaCreate).toHaveBeenCalledWith("Thing", "A thing"); + }); + + it("handles optional fields", () => { + generable("Opt", { + required: { type: "string" }, + maybe: { type: "string", optional: true }, + }); + + expect(mockFns.FMGenerationSchemaPropertyCreate).toHaveBeenCalledWith( + "required", + null, + "string", + false, + ); + expect(mockFns.FMGenerationSchemaPropertyCreate).toHaveBeenCalledWith( + "maybe", + null, + "string", + true, + ); + }); + + it("applies guides to properties", () => { + generable("Guided", { + score: { type: "integer", guides: [GenerationGuide.range(1, 10)] }, + tag: { type: "string", guides: [GenerationGuide.anyOf(["a", "b"])] }, + }); + + expect(mockFns.FMGenerationSchemaPropertyAddRangeGuide).toHaveBeenCalledWith( + "mock-prop-pointer", + 1, + 10, + false, + ); + expect(mockFns.FMGenerationSchemaPropertyAddAnyOfGuide).toHaveBeenCalledWith( + "mock-prop-pointer", + ["a", "b"], + 2, + false, + ); + }); + + it("creates reference schemas for nested objects", () => { + generable("Outer", { + inner: { + type: "object", + properties: { + value: { type: "string" }, + }, + }, + }); + + expect(mockFns.FMGenerationSchemaCreate).toHaveBeenCalledTimes(2); + expect(mockFns.FMGenerationSchemaCreate).toHaveBeenCalledWith("Outer", null); + expect(mockFns.FMGenerationSchemaCreate).toHaveBeenCalledWith("inner", null); + expect(mockFns.FMGenerationSchemaAddReferenceSchema).toHaveBeenCalled(); + }); + + it("creates reference schemas for arrays of objects", () => { + generable("List", { + items: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }); + + expect(mockFns.FMGenerationSchemaCreate).toHaveBeenCalledTimes(2); + expect(mockFns.FMGenerationSchemaAddReferenceSchema).toHaveBeenCalled(); + expect(mockFns.FMGenerationSchemaPropertyCreate).toHaveBeenCalledWith( + "items", + null, + "array", + false, + ); + }); + + it("handles scalar array properties without reference schema", () => { + generable("Tags", { + tags: { type: "array", items: { type: "string" } }, + }); + + expect(mockFns.FMGenerationSchemaCreate).toHaveBeenCalledTimes(1); + expect(mockFns.FMGenerationSchemaAddReferenceSchema).not.toHaveBeenCalled(); + expect(mockFns.FMGenerationSchemaPropertyCreate).toHaveBeenCalledWith( + "tags", + null, + "array", + false, + ); + }); + + it("parse delegates to GeneratedContent.toObject()", () => { + const def = generable("Simple", { + name: { type: "string" }, + }); + + const content = new GeneratedContent(mockPointer("mock-content")); + const result = def.parse(content); + expect(result).toEqual({ name: "test" }); + }); + + it("type inference produces correct types", () => { + const def = generable("Movie", { + title: { type: "string" }, + year: { type: "integer" }, + rating: { type: "number" }, + seen: { type: "boolean" }, + note: { type: "string", optional: true }, + }); + + const content = new GeneratedContent(mockPointer("mock-content")); + const movie = def.parse(content); + + // These accesses must type-check (compile-time verification) + const _title: string = movie.title; + const _year: number = movie.year; + const _rating: number = movie.rating; + const _seen: boolean = movie.seen; + const _note: string | undefined = movie.note; + + void _title; + void _year; + void _rating; + void _seen; + void _note; + + expect(movie).toBeDefined(); + }); +}); diff --git a/tests/unit/session.test.ts b/tests/unit/session.test.ts index b5e2796..4c7f6fb 100644 --- a/tests/unit/session.test.ts +++ b/tests/unit/session.test.ts @@ -797,11 +797,52 @@ describe("LanguageModelSession", () => { }); describe("transcript getter guard", () => { + it("returns transcript on initialized session", () => { + const session = new LanguageModelSession(); + const transcript = session.transcript; + expect(transcript).toBeDefined(); + }); + it("throws when transcript is accessed on uninitialized session", () => { - // Create session then null out internal transcript to test guard const session = new LanguageModelSession(); (session as unknown as { _transcript: null })._transcript = null; expect(() => session.transcript).toThrow("Session not initialized"); }); }); + + describe("streaming early break reset", () => { + it("calls FMLanguageModelSessionReset on active session", async () => { + mockFns.FMLanguageModelSessionResponseStreamIterate.mockImplementation( + (_streamRef: unknown, _ui: unknown, _cbPointer: unknown) => { + setTimeout(() => { + lastRegisteredCallback?.(0, "Hello", 5, null); + }, 0); + }, + ); + + const session = new LanguageModelSession(); + for await (const _chunk of session.streamResponse("Hi")) { + break; + } + expect(mockFns.FMLanguageModelSessionReset).toHaveBeenCalledWith("mock-session-pointer"); + }); + + it("skips reset when session is already disposed", async () => { + mockFns.FMLanguageModelSessionResponseStreamIterate.mockImplementation( + (_streamRef: unknown, _ui: unknown, _cbPointer: unknown) => { + setTimeout(() => { + lastRegisteredCallback?.(0, "Hello", 5, null); + }, 0); + }, + ); + + const session = new LanguageModelSession(); + for await (const _chunk of session.streamResponse("Hi")) { + session.dispose(); + break; + } + expect(mockFns.FMLanguageModelSessionReset).not.toHaveBeenCalled(); + }); + }); + }); From be12f28cad55567db25df85b6bacc7d04dd776f7 Mon Sep 17 00:00:00 2001 From: Cody Bromley Date: Fri, 13 Mar 2026 11:08:37 -0500 Subject: [PATCH 03/12] fix: add missing space in licensing information in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9dce70d..b3a5812 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ Issues and PRs welcome. If something doesn't work on your machine or you find a © 2026 Cody Bromley and contributors. -TSDM is licensed under the Apache 2.0 license.For complete licensing information, see this project's [LICENSE file](LICENSE.md). +TSDM is licensed under the Apache 2.0 license. For complete licensing information, see this project's [LICENSE file](LICENSE.md). The `tsfm-sdk` package available from NPM contains precompiled C bindings and libraries for working with macOS 26 Foundation Models adapted from [python-apple-fm-sdk](https://github.com/apple/python-apple-fm-sdk) which is Copyright Apple Inc. and licensed under the Apache 2.0 license. From 9c905ecf740effad863887c12790ca67d14237fa Mon Sep 17 00:00:00 2001 From: Cody Bromley Date: Fri, 13 Mar 2026 11:08:41 -0500 Subject: [PATCH 04/12] feat: add documentation for schema parsing assumption in generable function --- src/schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/schema.ts b/src/schema.ts index 0b75e35..9de2def 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -382,6 +382,7 @@ export function generable>( } return { schema, + /** Assumes model output conforms to the schema (enforced at generation time). */ parse(content: GeneratedContent): InferSchema { return content.toObject() as InferSchema; }, From 31d9ff6fec8a137cd81440d86c32de7b1473ea04 Mon Sep 17 00:00:00 2001 From: Cody Bromley Date: Fri, 13 Mar 2026 13:18:37 -0500 Subject: [PATCH 05/12] fix: receive callback content as void* to avoid koffi null coercion koffi's str type can coerce null C string pointers to the JS string "null" in callback scenarios. Switch ResponseCallbackProto to void* and decode manually via the new decodeString() helper, which reads without freeing (C side owns callback memory). decodeAndFreeString() now delegates to decodeString() + free. --- src/bindings.ts | 15 +++++++++++++-- src/session.ts | 8 +++++--- tests/unit/session.test.ts | 20 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/bindings.ts b/src/bindings.ts index c4724ef..bf1353b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -231,15 +231,26 @@ export function unregisterCallback(callback: KoffiCallback): void { koffi.unregister(callback); } -export function decodeAndFreeString(pointer: NativePointer | null): string | null { +/** + * Decode a null-terminated C string from a raw pointer without freeing it. + * Returns null if the pointer is null. + * + * Use this for callback parameters where the C side owns the memory. + */ +export function decodeString(pointer: NativePointer | null): string | null { if (!pointer) return null; // 'char *' would treat pointer as char** (pointer-to-pointer) and segfault. // 'char' with -1 reads the null-terminated byte sequence at pointer directly. // koffi may return a string or an array of char codes depending on version; // we handle both and re-encode via TextDecoder to preserve UTF-8. const raw = koffi.decode(pointer, "char", -1); - getFunctions().FMFreeString(pointer); if (typeof raw === "string") return raw; const codes: number[] = raw; return new TextDecoder("utf-8").decode(new Uint8Array(codes.map((c) => c & 0xff))); } + +export function decodeAndFreeString(pointer: NativePointer | null): string | null { + const str = decodeString(pointer); + if (pointer) getFunctions().FMFreeString(pointer); + return str; +} diff --git a/src/session.ts b/src/session.ts index 158b97b..849c40c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -56,7 +56,7 @@ function _installExitHandler(): void { } } -type ResponseCbArgs = [status: number, content: string, _length: number, userInfo: unknown]; +type ResponseCbArgs = [status: number, content: string | null, _length: number, userInfo: unknown]; type StructuredCbArgs = [status: number, contentRef: NativePointer, userInfo: unknown]; export class LanguageModelSession { @@ -278,12 +278,14 @@ export class LanguageModelSession { clearInterval(keepAlive); unregisterCallback(callback); } else if (!content) { - // null content = end-of-stream signal + // null/empty content = end-of-stream signal queue.push({ done: true }); streamDone = true; clearInterval(keepAlive); unregisterCallback(callback); - } else { + } else if (content !== "null") { + // Skip "null" string artifacts from koffi coercing null C string + // pointers during intermediate tool-call snapshots. queue.push({ content }); } const notify = notifyConsumer; diff --git a/tests/unit/session.test.ts b/tests/unit/session.test.ts index 4c7f6fb..570c3c0 100644 --- a/tests/unit/session.test.ts +++ b/tests/unit/session.test.ts @@ -827,6 +827,26 @@ describe("LanguageModelSession", () => { expect(mockFns.FMLanguageModelSessionReset).toHaveBeenCalledWith("mock-session-pointer"); }); + it("skips coerced null string chunks from koffi", async () => { + mockFns.FMLanguageModelSessionResponseStreamIterate.mockImplementation( + (_streamRef: unknown, _ui: unknown, _cbPointer: unknown) => { + setTimeout(() => { + // Simulate koffi coercing a null C string to the JS string "null" + lastRegisteredCallback?.(0, "null", 4, null); + lastRegisteredCallback?.(0, "real content", 12, null); + lastRegisteredCallback?.(0, null, 0, null); + }, 0); + }, + ); + + const session = new LanguageModelSession(); + const chunks: string[] = []; + for await (const chunk of session.streamResponse("Hi")) { + chunks.push(chunk); + } + expect(chunks).toEqual(["real content"]); + }); + it("skips reset when session is already disposed", async () => { mockFns.FMLanguageModelSessionResponseStreamIterate.mockImplementation( (_streamRef: unknown, _ui: unknown, _cbPointer: unknown) => { From 1c721566dcee69aaba426f85540151175e3e28b2 Mon Sep 17 00:00:00 2001 From: Cody Bromley Date: Fri, 13 Mar 2026 13:18:48 -0500 Subject: [PATCH 06/12] chore: widen prettier scope and exclude markdown Expand format scripts from src/ to the entire repo, add .prettierignore for dist, coverage, native, and *.md files. Markdown is excluded because Prettier pads tables. --- .prettierignore | 8 ++++++++ .prettierrc | 2 +- package.json | 6 +++--- 3 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 .prettierignore diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f8c55c0 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +dist +coverage +native +docs/node_modules +docs/.vitepress/dist +docs/.vitepress/cache +package-lock.json +*.md diff --git a/.prettierrc b/.prettierrc index e9bd086..4a1222f 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,4 +4,4 @@ "trailingComma": "all", "printWidth": 100, "tabWidth": 2 -} \ No newline at end of file +} diff --git a/package.json b/package.json index cefa32a..c8b53f9 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "dev": "tsc --watch", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", - "format": "prettier --write src/", - "format:check": "prettier --check src/", + "format": "prettier --write .", + "format:check": "prettier --check .", "test": "vitest run", "test:watch": "vitest", "test:unit": "vitest run --project unit", @@ -62,4 +62,4 @@ "cpu": [ "arm64" ] -} \ No newline at end of file +} From 4d8c8c00d98dc272e161b661e757c5cbf1e65519 Mon Sep 17 00:00:00 2001 From: Cody Bromley Date: Fri, 13 Mar 2026 13:18:58 -0500 Subject: [PATCH 07/12] style: apply prettier formatting --- docs/.vitepress/theme/CopyPageButton.vue | 62 +++++++++++++++++----- docs/.vitepress/theme/HomeExplore.vue | 7 +-- docs/.vitepress/theme/SiteFooter.vue | 14 +++-- docs/tsconfig.json | 6 +-- examples/compat/responses-advanced-real.ts | 9 +++- src/tool.ts | 7 ++- tests/unit/helpers/mock-bindings.ts | 4 +- tests/unit/schema.test.ts | 16 ++++-- tests/unit/session.test.ts | 1 - tsconfig.json | 16 ++---- 10 files changed, 92 insertions(+), 50 deletions(-) diff --git a/docs/.vitepress/theme/CopyPageButton.vue b/docs/.vitepress/theme/CopyPageButton.vue index 6b26613..c9305d7 100644 --- a/docs/.vitepress/theme/CopyPageButton.vue +++ b/docs/.vitepress/theme/CopyPageButton.vue @@ -6,7 +6,12 @@ {{ copied ? "Copied!" : "Copy page" }} -

@@ -17,31 +22,63 @@
Copy page as Markdown for LLMs
- +
-
View as Markdown
+
+ View as Markdown +
View this page as plain text
- +
-
Open in Claude
+
+ Open in Claude +
Ask questions about this page
- +
-
Open in ChatGPT
+
+ Open in ChatGPT +
Ask questions about this page
- +
-
View on GitHub
+
+ View on GitHub +
View source on GitHub
@@ -72,9 +109,7 @@ const rawUrl = computed( ); const pageUrl = computed(() => { - const path = page.value.relativePath - .replace(/\.md$/, "") - .replace(/\/index$/, "/"); + const path = page.value.relativePath.replace(/\.md$/, "").replace(/\/index$/, "/"); return `https://tsfm.dev/${path}`; }); @@ -89,8 +124,7 @@ const chatgptUrl = computed(() => { }); const githubUrl = computed( - () => - `https://github.com/codybrom/tsfm/blob/main/docs/${page.value.relativePath}`, + () => `https://github.com/codybrom/tsfm/blob/main/docs/${page.value.relativePath}`, ); async function copyPage() { diff --git a/docs/.vitepress/theme/HomeExplore.vue b/docs/.vitepress/theme/HomeExplore.vue index 0a7c47e..e3c664c 100644 --- a/docs/.vitepress/theme/HomeExplore.vue +++ b/docs/.vitepress/theme/HomeExplore.vue @@ -1,11 +1,6 @@