apple-llm 0.1.0 → 0.2.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,94 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ The helper speaks a new protocol, so the first call after upgrading compiles it
6
+ once more (a few seconds). Every 0.1 call still works; the changes in behaviour
7
+ below are the ones worth reading before you upgrade.
8
+
9
+ ### Added
10
+
11
+ - **Function tools.** `tool({ name, description, parameters, execute })` — the
12
+ model calls your JavaScript mid-generation, with arguments guaranteed to match
13
+ the parameter schema. Tools without `execute` stop generation with
14
+ `finishReason: 'tool-calls'` so you can run them yourself and continue with a
15
+ `tool` message. Mixes with the built-in `'ocr'`, `'barcode'`, `'spotlight'`.
16
+ `maxToolCalls` (default 8) stops a model that loops on a tool.
17
+ - **Standard Schema support.** Pass a Zod 4, ArkType or Valibot
18
+ (`toStandardJsonSchema`) schema anywhere a JSON Schema goes. `json()` is typed
19
+ by it and validated with it; a reply that fails validation is retried once
20
+ with the problems pointed out, then raised as `SchemaValidationError`.
21
+ - **Message lists.** Every method takes `ChatMessage[]` as well as a string.
22
+ History is sent as Apple's native transcript, and trimmed from the oldest end
23
+ if it outgrows the window (`trimHistory`, `result.trimmedTurns`).
24
+ - **`generate()`** returns everything about a call: `text`, `object`,
25
+ `finishReason` (`'stop' | 'length' | 'tool-calls'`), `usage` (input, output,
26
+ cached tokens — macOS 27), `toolCalls` with their outputs, `durationMs`, and
27
+ the assistant `message` to append for the next turn.
28
+ - **`for await` streaming.** `stream()` returns a stream you can iterate or
29
+ await; breaking out cancels generation. `streamJson()` streams partial objects
30
+ as the model fills them in.
31
+ - **Cancellation.** `signal` on every call stops the model itself, not just the
32
+ promise. `timeoutMs` per call or per client.
33
+ - **OpenAI-compatible server**: `apple-llm serve`, or `serve()` from
34
+ `apple-llm/server`. Chat completions with streaming, tools, `response_format`
35
+ JSON Schema and images. Localhost-only, no CORS and on-device by default.
36
+ - **Vercel AI SDK provider**: `apple()` from `apple-llm/ai-sdk`, for AI SDK 6
37
+ and 7 — `generateText`, `streamText`, `generateObject`, multi-step tools,
38
+ images.
39
+ - **`apple-llm chat`**, an interactive terminal chat. `run --json` prints the
40
+ full result; `run --schema --stream` prints partial objects, one per line;
41
+ `run --timeout`.
42
+ - **Long text.** `summarize()` handles text longer than the context window by
43
+ summarising it in parts.
44
+ - **Named sessions recover from overflow.** A conversation that outgrows the
45
+ window is rebuilt from its newest turns instead of failing forever, and after
46
+ a helper restart it resumes as a native transcript rather than a text
47
+ preamble.
48
+ - `countTokens()` now counts message history, schema and tools too.
49
+ - `using` / `await using` dispose a client. Error classes `AbortError`,
50
+ `ToolExecutionError` (your error is its `cause`), `SchemaValidationError`,
51
+ `ModelBusyError` and `UnsupportedError`, and a stable `code` on every error.
52
+ - Images on `user` messages, and images as bytes, data URLs or http URLs
53
+ through the server and the AI SDK provider.
54
+ - **The cloud tier on macOS 27 Golden Gate** reaches the next-generation server
55
+ model Siri AI is built on — the Shortcuts route already selected it, and this
56
+ release makes it visible and uses more of it: `probe().cloud.capabilities` and
57
+ `contextSize` come from the framework (reasoning, vision, tool calling, 32k),
58
+ and **the cloud tier now reads images**, passed as extra Shortcuts inputs with
59
+ the existing shortcut — no reinstall. Images are refused, not dropped, where
60
+ the server model's vision cannot be confirmed (macOS 26).
61
+
62
+ ### Changed
63
+
64
+ - **A script no longer hangs without `close()`.** The helper is unref'd between
65
+ calls, so Node exits when your work is done. `close()` still releases it early.
66
+ - **`json()` fills in `null` for required nullable fields** the model left out.
67
+ Apple's dialect cannot express `null`, so such fields become optional on the
68
+ way in; your schema said they are present, so they are put back on the way
69
+ out. Code that checked `'key' in result` for these fields will now see them.
70
+ - **Schemas lose `pattern`** before reaching Apple's decoder, which rejects
71
+ every regex (`UnsupportedGuide`) — this is what made `z.string().email()`
72
+ fail. The pattern is checked on the reply instead.
73
+ - **The cloud tier streams** as a single chunk instead of throwing, accepts
74
+ `documents` and message lists, and now refuses `sessionId` and `tools` on
75
+ `json()` as it already did on `text()`, instead of ignoring them.
76
+ - `stream()` returns a `ResultStream` rather than a `Promise<string>`. Awaiting
77
+ it still gives the text.
78
+ - Helper errors of kind `unsupported` are `UnsupportedError`, a subclass of
79
+ `AppleLLMError`, so existing `instanceof AppleLLMError` checks still match.
80
+ - Concurrent first calls share one probe-and-compile instead of racing.
81
+
82
+ ### Fixed
83
+
84
+ - Types resolve for CommonJS consumers (`require`) under `node16`/`nodenext`
85
+ module resolution, and the package passes publint and are-the-types-wrong.
86
+ - A single-entry `allOf` holding an inline object (rather than a `$ref`) was
87
+ sent to Apple as an object with no properties; it now keeps them. The same
88
+ fix covers nullable object unions, which are new in this release.
89
+ - A `Conversation`'s default `system` is no longer wiped by a call that passes
90
+ `system: undefined`.
91
+ - The helper's stderr is drained, so a chatty helper can no longer block on a
92
+ full pipe, and its last lines are quoted when it exits unexpectedly.
93
+ - A request turned away because other processes are using the model
94
+ (`ModelManagerError 1042`) is retried with backoff instead of failing.
package/README.md CHANGED
@@ -1,176 +1,315 @@
1
1
  # apple-llm
2
2
 
3
- Apple's on-device and Private Cloud Compute LLMs, from Node. No API key, no
4
- account, no developer program membership.
3
+ Apple's built-in LLMs from Node — the on-device model and Private Cloud
4
+ Compute. No API key, no account, no model download, **zero runtime
5
+ dependencies**.
5
6
 
6
7
  ```bash
7
8
  npm install apple-llm
8
9
  ```
9
10
 
10
11
  ```ts
11
- import { AppleLLM, probe } from 'apple-llm';
12
+ import { AppleLLM } from 'apple-llm';
12
13
 
13
- await probe();
14
- // { device: { available, contextSize, variant }, cloud: { available, installed } }
15
-
16
- const llm = new AppleLLM({ tier: 'device' }); // 'device' | 'cloud' | 'auto'
17
- await llm.text('Summarize this', { system: 'You are terse.' });
18
- await llm.json('Extract the fields', { schema }); // guaranteed to match, on device
19
- llm.close();
14
+ const llm = new AppleLLM();
15
+ await llm.text('Summarize this in one line: …');
20
16
  ```
21
17
 
22
- ESM + CJS, fully typed, **zero runtime dependencies**. A small Swift helper is
23
- compiled on first use and cached; there is no postinstall script, so installing
24
- on Linux or an Intel Mac always succeeds.
18
+ That is the whole setup. A small Swift helper compiles on first use (a few
19
+ seconds, once) and stays warm between calls.
25
20
 
26
- ## Read this before you use it
21
+ - **Everything an LLM SDK should have** — streaming (`for await`), structured
22
+ output typed by Zod, tool calling with your own JavaScript functions,
23
+ multi-turn chat, cancellation, timeouts, token counting, usage.
24
+ - **Three ways in** — this library, an [OpenAI-compatible server](#openai-compatible-server)
25
+ for any tool that speaks OpenAI, and a [Vercel AI SDK provider](#vercel-ai-sdk).
26
+ - **Two tiers** — on-device (private, fast, free) and Apple's big server
27
+ model on Private Cloud Compute — on macOS 27 Golden Gate, the one Siri AI is
28
+ built on: reasoning, images, live web knowledge, 32k context. Free, no key,
29
+ behind the same API.
27
30
 
28
- - **macOS 26+ on Apple Silicon.** No fallback anywhere else. Import always
29
- works; `probe()` returns `available: false` with an actionable reason.
30
- - **The on-device model is small** (~20B sparse, 1–4B active, 8192-token context
31
- on macOS 27). Good at classification, extraction, tagging, rewriting and short
32
- prose. **Bad at code generation and long reasoning.**
33
- - **The cloud tier is not local.** It sends your prompt to Apple's Private Cloud
34
- Compute, off your machine. Free but quota'd, reached through a private
35
- Shortcuts action Apple can change in any OS release, and with no constrained
36
- decoding — so `json()` there is a request, not a guarantee.
37
- - **Streaming is text-only, tools are Apple's built-ins.** `stream()` gives
38
- partials-as-they-arrive; `tools: ['ocr', 'barcode', 'spotlight']` enables
39
- on-device Vision/Spotlight tools. Generic user-supplied function calling is
40
- still a later version.
31
+ ## Read this first
41
32
 
42
- ## Two traps worth knowing
33
+ - **macOS 26+ on Apple Silicon, with Apple Intelligence on.** Nothing works
34
+ elsewhere, but importing always succeeds and `probe()` says exactly why.
35
+ - **The on-device model is small** (~20B sparse, 1–4B active; 8,192-token
36
+ context). Good at extraction, classification, tagging, rewriting, summaries
37
+ and short answers. Bad at code and long reasoning. Use it for the jobs it
38
+ suits.
39
+ - **The cloud tier is not local.** `tier: 'cloud'` sends your prompt to Apple's
40
+ Private Cloud Compute. The default, `auto`, uses the device when it can.
43
41
 
44
- **Never set `temperature: 0`.** Constrained decoding already guarantees the
45
- schema, so greedy decoding buys nothing and reliably degenerates — it padded an
46
- unbounded array forever, then ran away inside a single string (2.7KB of
47
- `"tasks-tasks-tasks-…"`), turning a 2s call into 20s. The default is `0.4`.
48
- Apple honours `maxItems` but ignores `maxLength`: bound your arrays.
42
+ ## Quick tour
49
43
 
50
- **Keep the client alive.** One long-lived helper process holds the model
51
- resident; spawning one per call measured ~17s against ~1.5s. Construct
52
- `AppleLLM` once, `close()` when you are done. Both traps produce *correct*
53
- output, only slower, so neither looks like a bug.
44
+ ### Structured output, typed by your schema
54
45
 
55
- Apple serialises inference regardless — 4 concurrent requests measured 29.19s
56
- against 29.45s sequentially — so the request queue serialises deliberately.
57
-
58
- ## What macOS 27 adds
46
+ On device, the shape is **guaranteed** by constrained decoding — the model
47
+ cannot produce anything else. Pass a Zod 4 (or ArkType, or Valibot) schema and
48
+ the result is typed and validated; plain JSON Schema works too.
59
49
 
60
50
  ```ts
61
- const { device, cloud } = await probe();
62
- device.capabilities; // { vision, guidedGeneration, reasoning, toolCalling }
63
- device.useCases; // ['general', 'contentTagging']
64
- cloud.quota; // { status: 'belowLimit' | 'limitReached', approachingLimit, resetDate }
51
+ import { z } from 'zod';
52
+
53
+ const Contact = z.object({
54
+ name: z.string(),
55
+ email: z.string().email(),
56
+ phone: z.string().nullable(),
57
+ });
58
+
59
+ const contact = await llm.json('Reach Grace Hopper at grace@navy.mil', { schema: Contact });
60
+ // ^? { name: string; email: string; phone: string | null }
65
61
  ```
66
62
 
67
- On this machine the on-device model reports vision, guided generation and tool
68
- calling, but **not** reasoning. `cloud.quota` is real quota state read from
69
- `PrivateCloudComputeLanguageModel.quotaUsage` — the entitlement that blocks PCC
70
- *inference* does not block reading it — so an exhausted quota becomes an
71
- immediate `QuotaError` rather than a wasted Shortcuts round trip.
63
+ Refinements the decoder cannot enforce (`.email()`, `.min()`) are checked
64
+ afterwards; a reply that fails gets one automatic retry with the problems
65
+ pointed out, then a `SchemaValidationError` carrying the issues and the raw
66
+ text.
72
67
 
73
- **Count tokens before sending**, turning a `ContextLengthError` into arithmetic:
68
+ ### Streaming
74
69
 
75
70
  ```ts
76
- const { tokens, contextSize } = await llm.countTokens(prompt, { system });
71
+ for await (const delta of llm.stream('Write a haiku about bridges')) {
72
+ process.stdout.write(delta);
73
+ }
74
+
75
+ const text = await llm.stream('…'); // or just await the whole reply
76
+ const { usage } = await llm.stream('…').result; // or everything about it
77
77
  ```
78
78
 
79
- **Reproducible output without the `temperature: 0` trap.** Greedy decoding is
80
- deterministic *and* degenerates; seeded top-k is deterministic and does not:
79
+ Breaking out of the loop cancels generation. Structured output streams too —
80
+ partial objects as the model fills them in, for rendering a card or form while
81
+ it is still being written:
81
82
 
82
83
  ```ts
83
- await llm.text(prompt, { sampling: { mode: 'topK', k: 50, seed: 42 }, temperature: 0.9 });
84
+ for await (const partial of llm.streamJson(prompt, { schema: Contact })) {
85
+ render(partial); // { name: 'Grace' } → { name: 'Grace', email: '…' } → …
86
+ }
84
87
  ```
85
88
 
86
- It works only because the helper uses a fresh session per request — reusing one
87
- changes the transcript and with it the output.
88
-
89
- **Vision**, by path — optionally labelled for follow-up turns. A missing file
90
- is an error, never a silent drop:
89
+ ### Tools: your JavaScript, called by the model
91
90
 
92
91
  ```ts
93
- await llm.text('What is in this image?', { images: ['./photo.png'] });
94
- await llm.text('What is in the image labelled chart?', {
95
- images: [{ path: './scan.png', label: 'chart' }],
92
+ import { tool } from 'apple-llm';
93
+
94
+ const getWeather = tool({
95
+ name: 'getWeather',
96
+ description: 'Current weather for a city',
97
+ parameters: z.object({ city: z.string() }),
98
+ execute: async ({ city }) => fetchWeather(city), // city: string
96
99
  });
100
+
101
+ const result = await llm.generate('Do I need an umbrella in Paris?', {
102
+ tools: [getWeather],
103
+ });
104
+ result.text; // "No — it's 21°C and sunny in Paris."
105
+ result.toolCalls; // [{ name: 'getWeather', arguments: { city: 'Paris' }, output: '…' }]
97
106
  ```
98
107
 
99
- **Streaming, conversations, tools, documents, and Write-with-Siri presets:**
108
+ `execute` runs in your process, mid-generation, with your credentials and
109
+ database. Arguments are schema-guaranteed like `json()`. A tool that throws
110
+ ends the call with a `ToolExecutionError` whose `cause` is your error. Tools
111
+ mix freely with Apple's built-ins: `tools: ['ocr', getWeather]`.
112
+
113
+ Leave out `execute` and generation stops at the call instead
114
+ (`finishReason: 'tool-calls'`), so you can run it yourself — send the result
115
+ back as a `tool` message to continue.
116
+
117
+ ### Conversations
118
+
119
+ Pass a message list anywhere a prompt goes:
100
120
 
101
121
  ```ts
102
- await llm.stream('Count to three.', { onDelta: (d) => process.stdout.write(d) });
122
+ import type { ChatMessage } from 'apple-llm';
123
+
124
+ const messages: ChatMessage[] = [
125
+ { role: 'system', content: 'You are terse.' },
126
+ { role: 'user', content: 'My cat is called Biscuit.' },
127
+ ];
128
+ const reply = await llm.generate(messages);
129
+ messages.push(reply.message, { role: 'user', content: 'What is my cat called?' });
130
+ await llm.text(messages); // "Biscuit."
131
+ ```
132
+
133
+ History becomes Apple's native transcript, not a text preamble. When a long
134
+ conversation outgrows the context window, the oldest turns are dropped to fit
135
+ and `result.trimmedTurns` says how many (`trimHistory: false` to get a
136
+ `ContextLengthError` instead).
103
137
 
104
- const chat = llm.conversation('trip-planning');
138
+ Or let the helper keep the thread — it survives restarts:
139
+
140
+ ```ts
141
+ const chat = llm.conversation('support-ticket-42', { system: 'You are terse.' });
105
142
  await chat.text('My cat is called Biscuit.');
106
- await chat.text('What is my cat called?'); // Biscuit.
107
- await chat.history(); // mirrored turns, oldest first
143
+ await chat.text('What is my cat called?'); // "Biscuit."
144
+ ```
145
+
146
+ ### Cancellation and timeouts
147
+
148
+ ```ts
149
+ await llm.text(prompt, { signal: controller.signal }); // AbortError
150
+ await llm.text(prompt, { timeoutMs: 5_000 }); // TimeoutError
151
+ new AppleLLM({ timeoutMs: 30_000 }); // default for every call
152
+ ```
153
+
154
+ Cancelling stops the model itself, not just the promise: the next call does not
155
+ wait behind a reply nobody wants.
156
+
157
+ ### Images, documents, long text
108
158
 
159
+ ```ts
160
+ await llm.text('What is in this photo?', { images: ['./photo.jpg'] });
109
161
  await llm.text('Read the total.', { images: ['./receipt.png'], tools: ['ocr'] });
110
- await llm.text('Which notes mention the bridge?', { tools: ['spotlight'] });
111
162
  await llm.text('Compare these quotes.', { documents: ['./a.md', './b.md'] });
163
+ await llm.summarize(fiftyPageReport); // longer than the window? summarised in parts
164
+ ```
112
165
 
113
- await llm.rewrite('gonna grab a bite', { instruction: 'Make it formal.' });
114
- await llm.proofread('Their going to the store...');
115
- await llm.summarize(longText);
116
- await llm.askScreen('What is on this schedule?');
166
+ ### Knowing what a call costs
167
+
168
+ ```ts
169
+ const { tokens, contextSize } = await llm.countTokens(messages, { schema, tools });
170
+ const { usage, finishReason } = await llm.generate(prompt);
171
+ // usage: { inputTokens, outputTokens, totalTokens, cachedInputTokens }
172
+ // finishReason: 'stop' | 'length' | 'tool-calls' — 'length' means maxTokens cut it off
117
173
  ```
118
174
 
119
- **A tagging-specialised model**, `permissive` guardrails for rewriting, and
120
- `prewarm()` to load model assets up front (worth little once they are resident —
121
- 0.31s against 0.36s here — but real on a cold system):
175
+ ### The big model: Private Cloud Compute
122
176
 
123
177
  ```ts
124
- const llm = new AppleLLM({ tier: 'device', useCase: 'contentTagging' });
125
- await new AppleLLM({ guardrails: 'permissive' }).text('Rewrite more formally: …');
126
- await llm.prewarm();
178
+ const cloud = new AppleLLM({ tier: 'cloud' }); // after `npx apple-llm setup-cloud`, once
179
+
180
+ await cloud.text(puzzle); // reasons it through
181
+ await cloud.text('What is in this photo?', { images: ['./photo.jpg'] });
182
+ await cloud.text('Who won the last World Cup?', { webSearch: true }); // live web knowledge
183
+ await cloud.json(prompt, { schema: Contact }); // typed, validated
127
184
  ```
128
185
 
129
- ## Errors
186
+ On macOS 27 Golden Gate this reaches the next-generation server model Apple
187
+ built Siri AI on. `probe()` reads what it can do straight from the framework —
188
+ reasoning, vision, tool calling and a 32,768-token window — without calling it.
189
+ Measured against the on-device model: it solves multi-step logic puzzles the
190
+ small model loses track of, finds a fact buried in 25,000 tokens, reads images,
191
+ and with `webSearch` answers questions about last month's news correctly. It
192
+ costs no API key and no money, only Apple's per-device quota
193
+ (`probe().cloud.quota`), and your prompt leaves the Mac.
194
+
195
+ ## OpenAI-compatible server
196
+
197
+ ```bash
198
+ npx apple-llm serve
199
+ # apple-llm serving an OpenAI-compatible API at http://127.0.0.1:11436/v1
200
+ ```
201
+
202
+ Point any OpenAI client at it — the official SDKs, LangChain, LlamaIndex,
203
+ editor plugins, Open WebUI — with any API key:
130
204
 
131
205
  ```ts
132
- import { QuotaError, ModelUnavailableError } from 'apple-llm';
206
+ import OpenAI from 'openai';
207
+ const openai = new OpenAI({ baseURL: 'http://127.0.0.1:11436/v1', apiKey: 'unused' });
208
+ await openai.chat.completions.create({ model: 'apple-on-device', messages });
133
209
  ```
134
210
 
135
- `ModelUnavailableError` (`.reason` is `appleIntelligenceNotEnabled` /
136
- `modelNotReady` / `deviceNotEligible` / …), `SchemaRejectedError`,
137
- `ContextLengthError`, `QuotaError` (`.resetDate`), `TimeoutError`,
138
- `SetupRequiredError`, `RefusalError`.
211
+ `/v1/chat/completions` supports streaming, tool calls, `response_format` with
212
+ a JSON Schema (guaranteed on device), images as data or http URLs, `seed` and
213
+ `top_p`. It binds to localhost, sends no CORS headers unless you pass
214
+ `--cors <origin>`, can require `--api-key`, and serves the on-device model
215
+ unless a client asks for `apple-private-cloud` by name. Or from code:
216
+ `import { serve } from 'apple-llm/server'`.
217
+
218
+ ## Vercel AI SDK
219
+
220
+ ```ts
221
+ import { generateText, streamText, stepCountIs, tool } from 'ai';
222
+ import { apple } from 'apple-llm/ai-sdk';
223
+
224
+ const { text } = await generateText({
225
+ model: apple(), // on device; apple('cloud') for Private Cloud Compute
226
+ prompt: 'What is the weather in Paris?',
227
+ tools: { getWeather: tool({ inputSchema: z.object({ city: z.string() }), execute: … }) },
228
+ stopWhen: stepCountIs(3),
229
+ });
230
+ ```
231
+
232
+ AI SDK 6 and 7. `generateText`, `streamText`, `generateObject` / `Output.object`
233
+ (constrained on device), multi-step tools, images and abort signals. Apple-only
234
+ settings go in `providerOptions: { apple: { useCase, guardrails, builtInTools } }`.
139
235
 
140
236
  ## CLI
141
237
 
142
238
  ```bash
143
- apple-llm probe
144
- apple-llm setup-cloud [--web-search] # one-time, generates + signs locally
145
- apple-llm run --tier device --system "You are terse." -
146
- apple-llm run --tier cloud --schema schema.json "Extract the fields"
147
- apple-llm run --image photo.png "What is in this image?"
148
- apple-llm run --image scan.png::chart "What is in the image labelled chart?"
239
+ apple-llm chat # interactive, streamed; Ctrl+C stops a reply
240
+ apple-llm run "Summarize: …" # one completion; "-" reads stdin
241
+ apple-llm run --json "…" # text + usage + tool calls as JSON
242
+ apple-llm run --schema person.json "Ada, 1815" # guaranteed JSON
243
+ apple-llm run --schema person.json --stream "…" # partial objects, one per line
244
+ apple-llm run --image photo.png "What is this?"
149
245
  apple-llm run --tool ocr --image receipt.png "Read the total."
150
- apple-llm run --document a.md --document b.md "Compare these."
151
- apple-llm run --session trip --stream "What is my cat called?"
152
- apple-llm history --session trip
153
- apple-llm reset --session trip
154
- apple-llm ask-screen "What is on this schedule?"
155
- apple-llm rewrite "gonna grab a bite"
156
- apple-llm run --use-case contentTagging --schema tags.json "A recipe for sourdough…"
157
- apple-llm run --seed 42 "Reproducible output"
158
- apple-llm count --system "You are terse." - # tokens before sending
246
+ apple-llm serve [--port 11436] [--api-key k] # OpenAI-compatible API
247
+ apple-llm count --system "…" - # tokens before sending
248
+ apple-llm probe # what this machine can do
249
+ apple-llm setup-cloud # one-time, for the cloud tier
159
250
  ```
160
251
 
161
- ## Schemas
252
+ `apple-llm --help` for everything, including the Write-with-Siri presets
253
+ (`rewrite`, `proofread`, `summarize`, `draft`) and `ask-screen`.
254
+
255
+ ## Two traps worth knowing
256
+
257
+ **Never set `temperature: 0`.** Constrained decoding already guarantees the
258
+ schema, so greedy decoding buys nothing and reliably degenerates — it padded an
259
+ unbounded array forever, then ran away inside a single string, turning a 2s
260
+ call into 20s. The default is `0.4`. For reproducible output use a seed
261
+ instead: `sampling: { mode: 'topK', k: 50, seed: 42 }` returns byte-identical
262
+ text across runs, without the degeneration.
263
+
264
+ **Bound your arrays.** Apple honours `maxItems` (`.max(5)` in Zod) but ignores
265
+ `maxLength` on strings.
266
+
267
+ ## The two tiers
268
+
269
+ | | on-device | cloud (Private Cloud Compute) |
270
+ |---|---|---|
271
+ | Model | AFM 3 Core (~1–4B active) | Apple's next-gen server model (Siri AI's, on macOS 27) |
272
+ | Privacy | nothing leaves the Mac | **your prompt leaves the Mac** |
273
+ | Context | 8,192 tokens (4,096 on macOS 26) | 32,768 tokens |
274
+ | Reasoning | no | yes |
275
+ | Latency | ~0.3–2s warm | ~2s, ~10s at 25k tokens |
276
+ | JSON | guaranteed by constrained decoding | requested, recovered, validated |
277
+ | Streaming | yes, text and objects | one chunk (Shortcuts is not incremental) |
278
+ | Images | yes (macOS 27) | yes (macOS 27 Golden Gate) |
279
+ | Web knowledge | no | yes, with `webSearch` |
280
+ | Your tools, sessions | yes | no |
281
+ | Setup | none | `apple-llm setup-cloud`, once |
282
+ | Limits | none | Apple's quota (read before calling: `probe().cloud.quota`) |
283
+
284
+ ## Errors
285
+
286
+ Every error is an `AppleLLMError` with a stable `code`, and a class to branch on:
287
+
288
+ | class | when |
289
+ |---|---|
290
+ | `ModelUnavailableError` | Apple Intelligence off, model downloading, unsupported Mac/OS (`.reason` says which) |
291
+ | `ContextLengthError` | the request does not fit (`.contextSize`, `.tokenCount`) |
292
+ | `SchemaRejectedError` | Apple's decoder refused a schema |
293
+ | `SchemaValidationError` | the reply failed your schema's validation (`.issues`, `.text`) |
294
+ | `ToolExecutionError` | your tool threw (`.toolName`, `.cause`) |
295
+ | `AbortError` / `TimeoutError` | your signal fired / the deadline passed |
296
+ | `QuotaError` | rate limited (`.resetDate` when Apple gives one) |
297
+ | `RefusalError` | the model's guardrails declined |
298
+ | `ModelBusyError` | other processes are using the model; already retried with backoff |
299
+ | `SetupRequiredError`, `UnsupportedError` | a setup step is missing / the tier cannot do that |
300
+
301
+ ## Resource use
162
302
 
163
- `json()` runs your JSON Schema through `toAppleSchema()`, which rewrites it into
164
- the restricted dialect Apple's `GenerationSchema` decoder accepts — eight rules
165
- covering unions, `x-order`, enums, titles, `$ref`-by-title, `additionalProperties`,
166
- string-typed `const`, and empty objects. See the
167
- [full notes in the repo](https://github.com/jagdish/apple-llm#the-generationschema-dialect).
303
+ One `AppleLLM` keeps one helper process warm — construct it once. The helper
304
+ does not keep your process alive, so a script exits without `close()`; call
305
+ `close()` (or use `await using llm = new AppleLLM()`) to release it early in a
306
+ long-running program. Apple serialises inference, so requests queue rather than
307
+ run in parallel — concurrency buys nothing here.
168
308
 
169
- The details matter more than they look: constrained decoding makes a schema
170
- mistake invisible but total. A union collapsed to the wrong branch does not warn
171
- — it makes the right answer unreachable.
309
+ ## More
172
310
 
173
- ## Credit
311
+ The [repository README](https://github.com/jagdishpal02000/apple-llm#readme)
312
+ has the measurements behind every default, the eight rules of Apple's schema
313
+ dialect, and why the cloud tier goes through Shortcuts.
174
314
 
175
- Extracted from [api-scribe](https://github.com/jagdish/api-scribe) (MIT), where
176
- both routes were discovered and shipped. MIT.
315
+ MIT.