Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ dist
build-native.log
foundation-models-c
.build
native/
native/*
!native/extensions/
coverage
.vscode/settings.json
docs/.vitepress/dist
Expand Down
8 changes: 8 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
dist
coverage
native
docs/node_modules
docs/.vitepress/dist
docs/.vitepress/cache
package-lock.json
*.md
2 changes: 1 addition & 1 deletion .prettierrc
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}
}
40 changes: 39 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `generable()` — declarative typed schema builder for structured output with full TypeScript type inference, the equivalent of the Python SDK's `@generable` decorator
- `SystemLanguageModel.contextSize` — read the model's context window size (back-deployed from macOS 26.4 SDK)
- `SystemLanguageModel.supportedLanguages` — list supported language codes
- `SystemLanguageModel.supportsLocale()` — check if a specific locale is supported
- `LanguageModelSession.prewarm()` — preload model resources and optionally cache a prompt prefix to reduce first-response latency
- `GeneratedContent.dispose()` / `Symbol.dispose` — explicit resource cleanup for structured output results, with `FinalizationRegistry` auto-cleanup as a safety net
- `NativeTypeName` type export — compound array type names (`"array<string>"`, `"array<integer>"`, etc.) for use with `GenerationSchema.property()`
- `decodeString()` — decode a C string pointer without freeing it, for use in callbacks where the C side owns the memory
- `Tool.onCall` now receives parsed arguments as a second parameter: `(toolName, args)` instead of `(toolName)`
- Input validation: `temperature` must be ≥ 0, `maximumResponseTokens` must be a positive integer — both throw immediately on invalid values
- Explicit FFI type casts (`as NativePointer`, `as boolean`, etc.) at all C call sites
- 3 new examples: `contact-card` (nested generable schemas), `email-triage` (JSON Schema + streaming + tools), `journal` (tools + transcript persistence)
- ESLint: `no-floating-promises` and `no-console` for `src/`, `no-eval` and `no-debugger` globally
- Unit tests for `generable()`, streaming edge cases, compat `reorderJson` with array items, disposed session guards, stream queue-stall recovery, and all 3 new examples

### Fixed

- Stream setup failures no longer stall the request queue permanently (native init moved inside try/finally)
- Stream idle timeout (30s) prevents permanent hangs when native callbacks stop firing after tool-call snapshots
- Disposed session methods (`respond`, `respondWithSchema`, `respondWithJsonSchema`, `streamResponse`) now throw `FoundationModelsError` immediately instead of calling into freed native memory
- `FinalizationRegistry` callbacks across all classes now log warnings via `console.warn` instead of silently swallowing errors
- Better error message when `libFoundationModels.dylib` is not found — lists all searched paths and suggests `npm run build`
- Streaming callback now receives content as `void*` instead of `str` to prevent koffi from coercing null C string pointers to the JS string `"null"`
- Streaming iterator now resets the session (`FMLanguageModelSessionReset`) on early `break` to prevent stalled subsequent calls
- Coerced `"null"` string chunks from koffi are filtered out during streaming

### Changed

- `generable()` array properties now use compound type names (`"array<string>"`, `"array<Name>"`) matching the Python SDK's C bridge convention
- `GenerationSchema.property()` rejects bare `"array"` type — use compound form like `"array<string>"` or use `generable()` for automatic type resolution
- Prettier scope widened from `src/` to entire repo (excluding `*.md`); added `.prettierignore`
- Standardized "Apple Foundation Models" terminology (dropped possessive "'s") across docs and config
- README license section rewritten with copyright notice and Apple trademark disclaimer
- `docs/tsconfig.json` added for VitePress theme type checking
- CSS: added `.VPHero .tagline` max-width constraints for responsive layout

## [0.3.1] - 2026-03-12

### Added
Expand Down Expand Up @@ -121,7 +159,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
Expand Down
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

tsfm 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.

<small>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.</small>
6 changes: 3 additions & 3 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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" }],
Expand All @@ -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" }],
Expand Down
4 changes: 2 additions & 2 deletions docs/.vitepress/generate-llms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
"",
];

Expand Down Expand Up @@ -218,7 +218,7 @@ export async function generateLlmsFullTxt(srcDir: string): Promise<string> {
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.",
"",
];

Expand Down
62 changes: 48 additions & 14 deletions docs/.vitepress/theme/CopyPageButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
<CopyIcon v-else class="copy-page-icon" />
<span>{{ copied ? "Copied!" : "Copy page" }}</span>
</button>
<button class="copy-page-toggle" @click.stop="open = !open" :aria-expanded="open" aria-label="More options">
<button
class="copy-page-toggle"
@click.stop="open = !open"
:aria-expanded="open"
aria-label="More options"
>
<ChevronDownIcon class="copy-page-chevron" :class="{ flipped: open }" />
</button>
<div v-if="open" class="copy-page-menu">
Expand All @@ -17,31 +22,63 @@
<div class="copy-page-item-desc">Copy page as Markdown for LLMs</div>
</div>
</button>
<a :href="rawUrl" target="_blank" rel="noopener" class="copy-page-item" @click="open = false">
<a
:href="rawUrl"
target="_blank"
rel="noopener"
class="copy-page-item"
@click="open = false"
>
<MarkdownIcon class="copy-page-item-icon" />
<div>
<div class="copy-page-item-label">View as Markdown <span class="arrow">&#8599;</span></div>
<div class="copy-page-item-label">
View as Markdown <span class="arrow">&#8599;</span>
</div>
<div class="copy-page-item-desc">View this page as plain text</div>
</div>
</a>
<a :href="claudeUrl" target="_blank" rel="noopener" class="copy-page-item" @click="open = false">
<a
:href="claudeUrl"
target="_blank"
rel="noopener"
class="copy-page-item"
@click="open = false"
>
<ClaudeIcon class="copy-page-item-icon" />
<div>
<div class="copy-page-item-label">Open in Claude <span class="arrow">&#8599;</span></div>
<div class="copy-page-item-label">
Open in Claude <span class="arrow">&#8599;</span>
</div>
<div class="copy-page-item-desc">Ask questions about this page</div>
</div>
</a>
<a :href="chatgptUrl" target="_blank" rel="noopener" class="copy-page-item" @click="open = false">
<a
:href="chatgptUrl"
target="_blank"
rel="noopener"
class="copy-page-item"
@click="open = false"
>
<OpenAiIcon class="copy-page-item-icon" />
<div>
<div class="copy-page-item-label">Open in ChatGPT <span class="arrow">&#8599;</span></div>
<div class="copy-page-item-label">
Open in ChatGPT <span class="arrow">&#8599;</span>
</div>
<div class="copy-page-item-desc">Ask questions about this page</div>
</div>
</a>
<a :href="githubUrl" target="_blank" rel="noopener" class="copy-page-item" @click="open = false">
<a
:href="githubUrl"
target="_blank"
rel="noopener"
class="copy-page-item"
@click="open = false"
>
<GitHubIcon class="copy-page-item-icon" />
<div>
<div class="copy-page-item-label">View on GitHub <span class="arrow">&#8599;</span></div>
<div class="copy-page-item-label">
View on GitHub <span class="arrow">&#8599;</span>
</div>
<div class="copy-page-item-desc">View source on GitHub</div>
</div>
</a>
Expand Down Expand Up @@ -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}`;
});

Expand All @@ -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() {
Expand Down
84 changes: 50 additions & 34 deletions docs/.vitepress/theme/HeroScene.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,12 @@ onMounted(async () => {
const glowEl = glowRef.value;
if (!el || !glowEl) return;

const [THREE, { LineSegments2 }, { LineSegmentsGeometry }, { LineMaterial }] =
await Promise.all([
import("three"),
import("three/addons/lines/LineSegments2.js"),
import("three/addons/lines/LineSegmentsGeometry.js"),
import("three/addons/lines/LineMaterial.js"),
]);
const [THREE, { LineSegments2 }, { LineSegmentsGeometry }, { LineMaterial }] = await Promise.all([
import("three"),
import("three/addons/lines/LineSegments2.js"),
import("three/addons/lines/LineSegmentsGeometry.js"),
import("three/addons/lines/LineMaterial.js"),
]);

// --- Scene setup ---
const scene = new THREE.Scene();
Expand All @@ -38,34 +37,51 @@ 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 top = [0, 1, 0],
bot = [0, -1, 0],
mid = [0, 0, 0];
const ulf = [-S, 0.4, S],
ulb = [-S, 0.4, -S];
const urf = [S, 0.4, S],
urb = [S, 0.4, -S];
const llf = [-S, -0.4, S],
llb = [-S, -0.4, -S];
const lrf = [S, -0.4, S],
lrb = [S, -0.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);
Expand Down
7 changes: 1 addition & 6 deletions docs/.vitepress/theme/HomeExplore.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
<template>
<div class="home-explore">
<a
v-for="card in cards"
:key="card.title"
:href="card.link"
class="home-explore-card"
>
<a v-for="card in cards" :key="card.title" :href="card.link" class="home-explore-card">
<span class="home-explore-title">{{ card.title }}</span>
<span class="home-explore-desc">{{ card.desc }}</span>
</a>
Expand Down
14 changes: 11 additions & 3 deletions docs/.vitepress/theme/SiteFooter.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
<template>
<div class="site-footer">
<p>Released under the <a href="https://www.apache.org/licenses/LICENSE-2.0" target="_blank">Apache 2.0 License</a></p>
<p>Built by <a href="https://github.com/codybrom" target="_blank">Cody Bromley</a> • Not affiliated with Apple Inc.</p>
<p>
Released under the
<a href="https://www.apache.org/licenses/LICENSE-2.0" target="_blank">Apache 2.0 License</a>
</p>
<p>
Built by <a href="https://github.com/codybrom" target="_blank">Cody Bromley</a> • Not
affiliated with Apple Inc.
</p>
</div>
</template>

Expand All @@ -26,7 +32,9 @@
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-color: var(--vp-c-divider);
transition: color 0.2s ease, text-decoration-color 0.2s ease;
transition:
color 0.2s ease,
text-decoration-color 0.2s ease;
}

.site-footer a:hover {
Expand Down
Loading
Loading