apple-local-llm 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 parkerduff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,14 +1,341 @@
1
1
  # apple-local-llm
2
2
 
3
- Call Apple's on-device Foundation Models using the OpenAI Responses API format — no servers, no setup.
3
+ Call Apple's on-device Foundation Models from JavaScript — no servers, no setup.
4
4
 
5
- ## Status
5
+ Works with Node.js, Electron, and VS Code extensions.
6
6
 
7
- 🚧 **Under development** — this package is reserved and not yet functional.
7
+ ## Requirements
8
8
 
9
- ## Coming Soon
9
+ - **macOS 26+** (Tahoe)
10
+ - **Apple Silicon** (M Series)
11
+ - **Apple Intelligence enabled** in System Settings
10
12
 
11
- - Drop-in OpenAI Responses API compatibility
12
- - Works with Node.js, Electron, and VS Code extensions
13
- - No localhost server required
14
- - Automatic fallback when unavailable
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install apple-local-llm
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ### Simple API
22
+
23
+ ```typescript
24
+ import { createClient } from "apple-local-llm";
25
+
26
+ const client = createClient();
27
+
28
+ // Check compatibility first
29
+ const compat = await client.compatibility.check();
30
+ if (!compat.compatible) {
31
+ console.log("Not available:", compat.reasonCode);
32
+ // Handle fallback to cloud API
33
+ }
34
+
35
+ // Generate a response
36
+ const result = await client.responses.create({
37
+ input: "What is the capital of France?",
38
+ });
39
+
40
+ if (result.ok) {
41
+ console.log(result.text); // "The capital of France is Paris."
42
+ }
43
+ ```
44
+
45
+ ### Streaming
46
+
47
+ ```typescript
48
+ for await (const chunk of client.stream({ input: "Count from 1 to 5." })) {
49
+ if ("delta" in chunk) {
50
+ process.stdout.write(chunk.delta);
51
+ }
52
+ }
53
+ ```
54
+
55
+ ## API Reference
56
+
57
+ ### `createClient(options?)`
58
+
59
+ Creates a new client instance.
60
+
61
+ ```typescript
62
+ const client = createClient({
63
+ model: "default", // Optional: model identifier (currently only "default")
64
+ onLog: (msg) => console.log(msg), // Optional: debug logging
65
+ idleTimeoutMs: 5 * 60 * 1000, // Optional: helper idle timeout (default: 5 min)
66
+ });
67
+ ```
68
+
69
+ **Defaults:**
70
+ - Helper auto-shuts down after 5 minutes of inactivity
71
+ - Helper auto-restarts up to 3 times on crash (with exponential backoff)
72
+ - Request timeout: 60 seconds (configurable via `timeoutMs`)
73
+
74
+ You can also import and instantiate the class directly:
75
+ ```typescript
76
+ import { AppleLocalLLMClient } from "apple-local-llm";
77
+ const client = new AppleLocalLLMClient(options);
78
+ ```
79
+
80
+ ### `client.compatibility.check()`
81
+
82
+ Check if the local model is available. Always call this before making requests.
83
+
84
+ ```typescript
85
+ const result = await client.compatibility.check();
86
+ // { compatible: true }
87
+ // or { compatible: false, reasonCode: "AI_DISABLED" }
88
+ ```
89
+
90
+ **Reason codes:**
91
+ | Code | Description |
92
+ |------|-------------|
93
+ | `NOT_DARWIN` | Not running on macOS |
94
+ | `UNSUPPORTED_HARDWARE` | Not Apple Silicon |
95
+ | `AI_DISABLED` | Apple Intelligence not enabled |
96
+ | `MODEL_NOT_READY` | Model still downloading |
97
+ | `SPAWN_FAILED` | Helper binary failed to start |
98
+ | `HELPER_NOT_FOUND` | Helper binary not found |
99
+ | `HELPER_UNHEALTHY` | Helper process not responding correctly |
100
+ | `PROTOCOL_MISMATCH` | Helper version incompatible with client |
101
+
102
+ ### `client.capabilities.get()`
103
+
104
+ Get detailed model capabilities (calls the helper).
105
+
106
+ ```typescript
107
+ const caps = await client.capabilities.get();
108
+ // { available: true, model: "apple-on-device" }
109
+ // or { available: false, reasonCode: "AI_DISABLED" }
110
+ ```
111
+
112
+ ### `client.responses.create(params)`
113
+
114
+ Generate a response.
115
+
116
+ ```typescript
117
+ const result = await client.responses.create({
118
+ input: "Your prompt here",
119
+ model: "default", // Optional: model identifier
120
+ max_output_tokens: 1000, // Optional
121
+ stream: false, // Optional
122
+ signal: abortController.signal, // Optional: AbortSignal
123
+ timeoutMs: 60000, // Optional: request timeout (ms)
124
+ response_format: { // Optional: structured JSON output
125
+ type: "json_schema",
126
+ json_schema: {
127
+ name: "Result",
128
+ schema: { type: "object", properties: { ... } }
129
+ }
130
+ }
131
+ });
132
+ ```
133
+
134
+ **Structured Output Example:**
135
+
136
+ ```typescript
137
+ const result = await client.responses.create({
138
+ input: "List 3 colors",
139
+ response_format: {
140
+ type: "json_schema",
141
+ json_schema: {
142
+ name: "Colors",
143
+ schema: {
144
+ type: "object",
145
+ properties: {
146
+ colors: { type: "array", items: { type: "string" } }
147
+ }
148
+ }
149
+ }
150
+ }
151
+ });
152
+ const data = JSON.parse(result.text); // { colors: ["red", "blue", "green"] }
153
+ ```
154
+
155
+ > `response_format` is not supported with streaming.
156
+
157
+ Returns `ResponseResult` on success, or an error object:
158
+ ```typescript
159
+ // Success:
160
+ { ok: true, text: "...", request_id: "..." }
161
+ // Error:
162
+ { ok: false, error: { code: "...", detail: "..." } }
163
+ ```
164
+
165
+ Note: The return type is a discriminated union, not the exported `ResponseResult` interface.
166
+
167
+ **Error codes:**
168
+ | Code | Description |
169
+ |------|-------------|
170
+ | `UNAVAILABLE` | Model not available (see reason codes above) |
171
+ | `TIMEOUT` | Request timed out (default: 60s) |
172
+ | `CANCELLED` | Request was cancelled via AbortSignal |
173
+ | `RATE_LIMITED` | System rate limit exceeded |
174
+ | `GUARDRAIL` | Content violated Apple's safety guidelines |
175
+ | `INTERNAL` | Unexpected error |
176
+
177
+ ### `client.stream(params)`
178
+
179
+ Async generator for streaming responses.
180
+
181
+ ```typescript
182
+ for await (const chunk of client.stream({ input: "..." })) {
183
+ if ("delta" in chunk) {
184
+ // Partial content
185
+ console.log(chunk.delta);
186
+ } else if ("done" in chunk) {
187
+ // Final complete text
188
+ console.log(chunk.text);
189
+ }
190
+ }
191
+ ```
192
+
193
+ ### `client.responses.cancel(requestId)`
194
+
195
+ Cancel an in-progress request.
196
+
197
+ ```typescript
198
+ const result = await client.responses.cancel("req_123");
199
+ // { ok: true } or { ok: false, error: { code: "NOT_RUNNING", detail: "..." } }
200
+ ```
201
+
202
+ ### `client.shutdown()`
203
+
204
+ Gracefully shut down the helper process.
205
+
206
+ ```typescript
207
+ await client.shutdown();
208
+ ```
209
+
210
+ ## TypeScript Types
211
+
212
+ All types are exported:
213
+
214
+ ```typescript
215
+ import type {
216
+ ClientOptions,
217
+ ReasonCode,
218
+ CompatibilityResult,
219
+ CapabilitiesResult,
220
+ ResponsesCreateParams,
221
+ ResponseResult,
222
+ JSONSchema,
223
+ ResponseFormat,
224
+ } from "apple-local-llm";
225
+ ```
226
+
227
+ ## CLI Usage
228
+
229
+ The `fm-proxy` binary can also be used directly from the command line:
230
+
231
+ ```bash
232
+ # Simple prompt
233
+ fm-proxy "What is the capital of France?"
234
+
235
+ # Streaming output
236
+ fm-proxy --stream "Tell me a story"
237
+ fm-proxy -s "Tell me a story"
238
+
239
+ # Start HTTP server
240
+ fm-proxy --serve
241
+ fm-proxy --serve --port=3000
242
+
243
+ # Other options
244
+ fm-proxy --help # Show usage (or -h)
245
+ fm-proxy --version # Show version (or -v)
246
+ fm-proxy --stdio # stdio mode (used internally by npm package)
247
+ ```
248
+
249
+ ### HTTP Server Mode
250
+
251
+ Run `fm-proxy --serve` to start a local HTTP server:
252
+
253
+ ```bash
254
+ fm-proxy --serve --port=8080
255
+ ```
256
+
257
+ **Endpoints:**
258
+
259
+ | Endpoint | Method | Description |
260
+ |----------|--------|-------------|
261
+ | `/health` | GET | Health check and availability status |
262
+ | `/generate` | POST | Text generation (supports streaming) |
263
+
264
+ **Options:**
265
+
266
+ | Option | Description |
267
+ |--------|-------------|
268
+ | `--port=<PORT>` | Set server port (default: 8080) |
269
+ | `--auth-token=<TOKEN>` | Require Bearer token for `/generate` |
270
+
271
+ You can also set `AUTH_TOKEN` environment variable instead of `--auth-token`.
272
+
273
+ **CORS:** All endpoints support CORS with `Access-Control-Allow-Origin: *`.
274
+
275
+ **Examples:**
276
+
277
+ ```bash
278
+ # Health check
279
+ curl http://127.0.0.1:8080/health
280
+ # Response: {"status":"ok","model":"apple-on-device","available":true}
281
+
282
+ # Simple generation
283
+ curl -X POST http://127.0.0.1:8080/generate \
284
+ -H "Content-Type: application/json" \
285
+ -d '{"prompt": "What is 2+2?"}'
286
+ # Response: {"text":"2+2 equals 4."}
287
+
288
+ # With authentication
289
+ curl -X POST http://127.0.0.1:8080/generate \
290
+ -H "Content-Type: application/json" \
291
+ -H "Authorization: Bearer <token>" \
292
+ -d '{"prompt": "Hello"}'
293
+ ```
294
+
295
+ #### Streaming (SSE)
296
+
297
+ Add `"stream": true` to get Server-Sent Events with OpenAI-compatible chunks:
298
+
299
+ ```bash
300
+ curl -N -X POST http://127.0.0.1:8080/generate \
301
+ -H "Content-Type: application/json" \
302
+ -d '{"prompt": "Write a haiku", "stream": true}'
303
+ ```
304
+
305
+ Response:
306
+ ```
307
+ data: {"id":"...","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"}}]}
308
+ data: {"id":"...","object":"chat.completion.chunk","choices":[{"delta":{"content":"..."}}]}
309
+ data: {"id":"...","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}]}
310
+ data: [DONE]
311
+ ```
312
+
313
+ ## How It Works
314
+
315
+ This package bundles a small native helper (`fm-proxy`) that communicates with Apple's Foundation Models framework over stdio. The helper is spawned on first request and stays alive to keep the model warm.
316
+
317
+ - **No localhost server** — npm package uses stdio, not HTTP
318
+ - **No user setup** — just `npm install`
319
+ - **Fails gracefully** — check `compatibility.check()` and fall back to cloud
320
+
321
+ ## Runtime Support
322
+
323
+ **JS API (`createClient()`):**
324
+ | Environment | Supported |
325
+ |-------------|-----------|
326
+ | Node.js | ✅ |
327
+ | Electron (main process) | ✅ |
328
+ | VS Code extensions | ✅ |
329
+ | Electron (renderer) | ❌ No `child_process` |
330
+ | Browser | ❌ |
331
+
332
+ **HTTP Server (`fm-proxy --serve`):**
333
+ | Environment | Supported |
334
+ |-------------|-----------|
335
+ | Any HTTP client | ✅ |
336
+ | Browser (fetch) | ✅ |
337
+ | Electron (renderer) | ✅ |
338
+
339
+ ## License
340
+
341
+ MIT
package/bin/fm-proxy ADDED
Binary file
@@ -0,0 +1,105 @@
1
+ export type ReasonCode = "NOT_DARWIN" | "UNSUPPORTED_HARDWARE" | "AI_DISABLED" | "MODEL_NOT_READY" | "SPAWN_FAILED" | "HELPER_UNHEALTHY" | "HELPER_NOT_FOUND" | "PROTOCOL_MISMATCH";
2
+ export interface CompatibilityResult {
3
+ compatible: boolean;
4
+ reasonCode?: ReasonCode;
5
+ }
6
+ export interface CapabilitiesResult {
7
+ available: boolean;
8
+ reasonCode?: string;
9
+ model?: string;
10
+ }
11
+ export interface JSONSchema {
12
+ type: "object" | "array" | "string" | "number" | "integer" | "boolean";
13
+ properties?: Record<string, JSONSchema>;
14
+ items?: JSONSchema;
15
+ required?: string[];
16
+ description?: string;
17
+ enum?: string[];
18
+ }
19
+ export interface ResponseFormat {
20
+ type: "json_schema";
21
+ json_schema: {
22
+ name: string;
23
+ description?: string;
24
+ schema: JSONSchema;
25
+ };
26
+ }
27
+ export interface ResponsesCreateParams {
28
+ model?: string;
29
+ input: string;
30
+ max_output_tokens?: number;
31
+ stream?: boolean;
32
+ signal?: AbortSignal;
33
+ timeoutMs?: number;
34
+ response_format?: ResponseFormat;
35
+ }
36
+ export interface ResponseResult {
37
+ request_id: string;
38
+ text: string;
39
+ model?: string;
40
+ }
41
+ export interface StreamEvent {
42
+ request_id: string;
43
+ event: "delta" | "done" | "error";
44
+ delta?: string;
45
+ text?: string;
46
+ model?: string;
47
+ error?: {
48
+ code: string;
49
+ detail: string;
50
+ };
51
+ }
52
+ export declare const DEFAULT_MODEL = "default";
53
+ export interface ClientOptions {
54
+ model?: string;
55
+ onLog?: (message: string) => void;
56
+ idleTimeoutMs?: number;
57
+ }
58
+ export declare class AppleLocalLLMClient {
59
+ private options;
60
+ private resolverResult;
61
+ private processManager;
62
+ private compatibilityCache;
63
+ constructor(options?: ClientOptions);
64
+ private getModel;
65
+ get compatibility(): {
66
+ check: () => Promise<CompatibilityResult>;
67
+ };
68
+ get capabilities(): {
69
+ get: () => Promise<CapabilitiesResult>;
70
+ };
71
+ get responses(): {
72
+ create: (params: ResponsesCreateParams) => Promise<{
73
+ ok: true;
74
+ text: string;
75
+ request_id: string;
76
+ } | {
77
+ ok: false;
78
+ error: {
79
+ code: string;
80
+ detail: string;
81
+ };
82
+ }>;
83
+ cancel: (requestId: string) => Promise<{
84
+ ok: true;
85
+ } | {
86
+ ok: false;
87
+ error: {
88
+ code: string;
89
+ detail: string;
90
+ };
91
+ }>;
92
+ };
93
+ private checkCompatibility;
94
+ private getCapabilities;
95
+ private createResponse;
96
+ stream(params: Omit<ResponsesCreateParams, "stream">): AsyncGenerator<{
97
+ delta: string;
98
+ } | {
99
+ done: true;
100
+ text: string;
101
+ }, void, unknown>;
102
+ private cancelResponse;
103
+ shutdown(): Promise<void>;
104
+ }
105
+ export declare function createClient(options?: ClientOptions): AppleLocalLLMClient;
package/dist/client.js ADDED
@@ -0,0 +1,249 @@
1
+ import { resolveHelper } from "./resolver.js";
2
+ import { ProcessManager } from "./process-manager.js";
3
+ export const DEFAULT_MODEL = "default";
4
+ export class AppleLocalLLMClient {
5
+ options;
6
+ resolverResult = null;
7
+ processManager = null;
8
+ compatibilityCache = null;
9
+ constructor(options = {}) {
10
+ this.options = { model: DEFAULT_MODEL, ...options };
11
+ }
12
+ getModel(override) {
13
+ return override ?? this.options.model ?? DEFAULT_MODEL;
14
+ }
15
+ get compatibility() {
16
+ return {
17
+ check: () => this.checkCompatibility(),
18
+ };
19
+ }
20
+ get capabilities() {
21
+ return {
22
+ get: () => this.getCapabilities(),
23
+ };
24
+ }
25
+ get responses() {
26
+ return {
27
+ create: (params) => this.createResponse(params),
28
+ cancel: (requestId) => this.cancelResponse(requestId),
29
+ };
30
+ }
31
+ async checkCompatibility() {
32
+ if (this.compatibilityCache) {
33
+ return this.compatibilityCache;
34
+ }
35
+ // Fast JS-side checks
36
+ this.resolverResult = resolveHelper();
37
+ if (!this.resolverResult.ok) {
38
+ const result = {
39
+ compatible: false,
40
+ reasonCode: this.resolverResult.reasonCode,
41
+ };
42
+ this.compatibilityCache = result;
43
+ return result;
44
+ }
45
+ // Spawn helper and check capabilities
46
+ try {
47
+ this.processManager = new ProcessManager(this.resolverResult.location, {
48
+ onLog: this.options.onLog,
49
+ idleTimeoutMs: this.options.idleTimeoutMs,
50
+ });
51
+ const transport = await this.processManager.getTransport();
52
+ const response = await transport.send("capabilities.get");
53
+ if (!response.ok) {
54
+ const result = {
55
+ compatible: false,
56
+ reasonCode: "HELPER_UNHEALTHY",
57
+ };
58
+ this.compatibilityCache = result;
59
+ return result;
60
+ }
61
+ const caps = response.result;
62
+ if (caps.available) {
63
+ const result = { compatible: true };
64
+ this.compatibilityCache = result;
65
+ return result;
66
+ }
67
+ else {
68
+ const result = {
69
+ compatible: false,
70
+ reasonCode: caps.reason_code,
71
+ };
72
+ this.compatibilityCache = result;
73
+ return result;
74
+ }
75
+ }
76
+ catch (err) {
77
+ const result = {
78
+ compatible: false,
79
+ reasonCode: "SPAWN_FAILED",
80
+ };
81
+ this.compatibilityCache = result;
82
+ return result;
83
+ }
84
+ }
85
+ async getCapabilities() {
86
+ const compat = await this.checkCompatibility();
87
+ if (!compat.compatible) {
88
+ return {
89
+ available: false,
90
+ reasonCode: compat.reasonCode,
91
+ };
92
+ }
93
+ const transport = await this.processManager.getTransport();
94
+ const response = await transport.send("capabilities.get");
95
+ if (!response.ok) {
96
+ return {
97
+ available: false,
98
+ reasonCode: response.error?.code,
99
+ };
100
+ }
101
+ const raw = response.result;
102
+ return {
103
+ available: raw.available,
104
+ reasonCode: raw.reason_code,
105
+ model: raw.model,
106
+ };
107
+ }
108
+ async createResponse(params) {
109
+ const compat = await this.checkCompatibility();
110
+ if (!compat.compatible) {
111
+ return {
112
+ ok: false,
113
+ error: {
114
+ code: "UNAVAILABLE",
115
+ detail: `Not compatible: ${compat.reasonCode}`,
116
+ },
117
+ };
118
+ }
119
+ const transport = await this.processManager.getTransport();
120
+ if (params.stream) {
121
+ // For streaming, collect all deltas
122
+ let fullText = "";
123
+ const response = await transport.sendStreaming("responses.create", {
124
+ model: this.getModel(params.model),
125
+ input: params.input,
126
+ max_output_tokens: params.max_output_tokens,
127
+ stream: true,
128
+ response_format: params.response_format,
129
+ }, (event) => {
130
+ const result = event.result;
131
+ if (result?.delta) {
132
+ fullText += result.delta;
133
+ }
134
+ }, { signal: params.signal, timeoutMs: params.timeoutMs });
135
+ const result = response.result;
136
+ if (result.event === "error" || !response.ok) {
137
+ return {
138
+ ok: false,
139
+ error: result.error ?? response.error ?? { code: "INTERNAL", detail: "Unknown error" },
140
+ };
141
+ }
142
+ return {
143
+ ok: true,
144
+ text: result.text ?? fullText,
145
+ request_id: result.request_id,
146
+ };
147
+ }
148
+ else {
149
+ const response = await transport.send("responses.create", {
150
+ model: this.getModel(params.model),
151
+ input: params.input,
152
+ max_output_tokens: params.max_output_tokens,
153
+ response_format: params.response_format,
154
+ }, { signal: params.signal, timeoutMs: params.timeoutMs });
155
+ if (!response.ok) {
156
+ return {
157
+ ok: false,
158
+ error: response.error ?? { code: "INTERNAL", detail: "Unknown error" },
159
+ };
160
+ }
161
+ const result = response.result;
162
+ return {
163
+ ok: true,
164
+ text: result.text,
165
+ request_id: result.request_id,
166
+ };
167
+ }
168
+ }
169
+ async *stream(params) {
170
+ const compat = await this.checkCompatibility();
171
+ if (!compat.compatible) {
172
+ throw new Error(`Not compatible: ${compat.reasonCode}`);
173
+ }
174
+ const transport = await this.processManager.getTransport();
175
+ // Create a queue for streaming events
176
+ const queue = [];
177
+ let resolveNext = null;
178
+ let finished = false;
179
+ transport.sendStreaming("responses.create", {
180
+ model: this.getModel(params.model),
181
+ input: params.input,
182
+ max_output_tokens: params.max_output_tokens,
183
+ stream: true,
184
+ }, (event) => {
185
+ queue.push(event);
186
+ resolveNext?.();
187
+ }, { signal: params.signal, timeoutMs: params.timeoutMs }).then(() => {
188
+ finished = true;
189
+ queue.push({ done: true });
190
+ resolveNext?.();
191
+ }).catch((err) => {
192
+ finished = true;
193
+ queue.push({ ok: false, error: { code: "INTERNAL", detail: err instanceof Error ? err.message : "Stream failed" } });
194
+ resolveNext?.();
195
+ });
196
+ while (!finished || queue.length > 0) {
197
+ if (queue.length === 0) {
198
+ await new Promise((r) => { resolveNext = r; });
199
+ continue;
200
+ }
201
+ const event = queue.shift();
202
+ if ("done" in event && event.done === true)
203
+ break;
204
+ // Handle error from .catch()
205
+ if ("ok" in event && event.ok === false) {
206
+ throw new Error(event.error?.detail ?? "Stream failed");
207
+ }
208
+ const result = event.result;
209
+ if (result.event === "delta" && result.delta) {
210
+ yield { delta: result.delta };
211
+ }
212
+ else if (result.event === "done") {
213
+ yield { done: true, text: result.text ?? "" };
214
+ break;
215
+ }
216
+ else if (result.event === "error") {
217
+ throw new Error(result.error?.detail ?? "Stream error");
218
+ }
219
+ }
220
+ }
221
+ async cancelResponse(requestId) {
222
+ if (!this.processManager) {
223
+ return { ok: false, error: { code: "NOT_RUNNING", detail: "No active session" } };
224
+ }
225
+ try {
226
+ const transport = await this.processManager.getTransport();
227
+ const response = await transport.send("responses.cancel", { request_id: requestId });
228
+ if (!response.ok) {
229
+ return {
230
+ ok: false,
231
+ error: response.error ?? { code: "INTERNAL", detail: "Cancel failed" },
232
+ };
233
+ }
234
+ return { ok: true };
235
+ }
236
+ catch (err) {
237
+ return {
238
+ ok: false,
239
+ error: { code: "INTERNAL", detail: err instanceof Error ? err.message : "Cancel failed" },
240
+ };
241
+ }
242
+ }
243
+ async shutdown() {
244
+ await this.processManager?.shutdown();
245
+ }
246
+ }
247
+ export function createClient(options) {
248
+ return new AppleLocalLLMClient(options);
249
+ }
@@ -0,0 +1,2 @@
1
+ export { createClient, AppleLocalLLMClient } from "./client.js";
2
+ export type { ClientOptions, CompatibilityResult, CapabilitiesResult, ResponsesCreateParams, ResponseResult, StreamEvent, ReasonCode, JSONSchema, ResponseFormat, } from "./client.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { createClient, AppleLocalLLMClient } from "./client.js";
@@ -0,0 +1,25 @@
1
+ import { RPCTransport } from "./transport.js";
2
+ import { HelperLocation } from "./resolver.js";
3
+ export interface ProcessManagerOptions {
4
+ idleTimeoutMs?: number;
5
+ maxRestarts?: number;
6
+ onLog?: (message: string) => void;
7
+ }
8
+ export declare class ProcessManager {
9
+ private location;
10
+ private options;
11
+ private process;
12
+ private transport;
13
+ private idleTimer;
14
+ private restartCount;
15
+ private healthy;
16
+ private backoffMs;
17
+ constructor(location: HelperLocation, options?: ProcessManagerOptions);
18
+ getTransport(): Promise<RPCTransport>;
19
+ private sleep;
20
+ private spawn;
21
+ private resetIdleTimer;
22
+ shutdown(): Promise<void>;
23
+ private kill;
24
+ isHealthy(): boolean;
25
+ }
@@ -0,0 +1,122 @@
1
+ import { spawn } from "child_process";
2
+ import { RPCTransport } from "./transport.js";
3
+ import { ensureExecutable } from "./resolver.js";
4
+ const DEFAULT_IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
5
+ const DEFAULT_MAX_RESTARTS = 3;
6
+ const PROTOCOL_VERSION = 1;
7
+ const INITIAL_BACKOFF_MS = 100;
8
+ const MAX_BACKOFF_MS = 5000;
9
+ export class ProcessManager {
10
+ location;
11
+ options;
12
+ process = null;
13
+ transport = null;
14
+ idleTimer = null;
15
+ restartCount = 0;
16
+ healthy = false;
17
+ backoffMs = INITIAL_BACKOFF_MS;
18
+ constructor(location, options = {}) {
19
+ this.location = location;
20
+ this.options = options;
21
+ }
22
+ async getTransport() {
23
+ this.resetIdleTimer();
24
+ if (this.transport && this.healthy) {
25
+ return this.transport;
26
+ }
27
+ // Check if we need to wait (backoff after crash)
28
+ const maxRestarts = this.options.maxRestarts ?? DEFAULT_MAX_RESTARTS;
29
+ if (this.restartCount >= maxRestarts) {
30
+ throw new Error(`Helper crashed ${this.restartCount} times, giving up`);
31
+ }
32
+ if (this.restartCount > 0) {
33
+ this.options.onLog?.(`Restarting helper (attempt ${this.restartCount + 1}/${maxRestarts}) after ${this.backoffMs}ms`);
34
+ await this.sleep(this.backoffMs);
35
+ this.backoffMs = Math.min(this.backoffMs * 2, MAX_BACKOFF_MS);
36
+ }
37
+ return this.spawn();
38
+ }
39
+ sleep(ms) {
40
+ return new Promise((resolve) => setTimeout(resolve, ms));
41
+ }
42
+ async spawn() {
43
+ await ensureExecutable(this.location);
44
+ this.process = spawn(this.location.executablePath, ["--stdio"], {
45
+ stdio: ["pipe", "pipe", "pipe"],
46
+ });
47
+ this.transport = new RPCTransport(this.process);
48
+ this.transport.on("log", (msg) => {
49
+ this.options.onLog?.(msg);
50
+ });
51
+ this.transport.on("exit", (code) => {
52
+ this.healthy = false;
53
+ this.transport = null;
54
+ this.process = null;
55
+ if (code !== 0) {
56
+ this.restartCount++;
57
+ this.options.onLog?.(`Helper exited with code ${code}, crash count: ${this.restartCount}`);
58
+ }
59
+ });
60
+ this.transport.on("error", (err) => {
61
+ this.options.onLog?.(`Transport error: ${err.message}`);
62
+ this.healthy = false;
63
+ });
64
+ // Handshake
65
+ let pingResponse;
66
+ try {
67
+ pingResponse = await this.transport.send("health.ping");
68
+ }
69
+ catch (err) {
70
+ this.kill();
71
+ throw err;
72
+ }
73
+ if (!pingResponse.ok) {
74
+ this.kill();
75
+ throw new Error("Handshake failed: health.ping returned error");
76
+ }
77
+ const result = pingResponse.result;
78
+ if (result.protocol_version !== PROTOCOL_VERSION) {
79
+ this.kill();
80
+ throw new Error(`Protocol mismatch: expected ${PROTOCOL_VERSION}, got ${result.protocol_version}`);
81
+ }
82
+ this.healthy = true;
83
+ this.restartCount = 0;
84
+ this.backoffMs = INITIAL_BACKOFF_MS; // Reset backoff on success
85
+ return this.transport;
86
+ }
87
+ resetIdleTimer() {
88
+ if (this.idleTimer) {
89
+ clearTimeout(this.idleTimer);
90
+ }
91
+ const timeout = this.options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT;
92
+ this.idleTimer = setTimeout(() => {
93
+ this.shutdown();
94
+ }, timeout);
95
+ }
96
+ async shutdown() {
97
+ if (this.idleTimer) {
98
+ clearTimeout(this.idleTimer);
99
+ this.idleTimer = null;
100
+ }
101
+ if (this.transport && this.healthy) {
102
+ try {
103
+ await this.transport.send("process.shutdown");
104
+ }
105
+ catch {
106
+ // Ignore errors during shutdown
107
+ }
108
+ }
109
+ this.kill();
110
+ }
111
+ kill() {
112
+ if (this.process) {
113
+ this.process.kill();
114
+ this.process = null;
115
+ }
116
+ this.transport = null;
117
+ this.healthy = false;
118
+ }
119
+ isHealthy() {
120
+ return this.healthy;
121
+ }
122
+ }
@@ -0,0 +1,13 @@
1
+ export interface HelperLocation {
2
+ type: "cli";
3
+ executablePath: string;
4
+ }
5
+ export type ResolverResult = {
6
+ ok: true;
7
+ location: HelperLocation;
8
+ } | {
9
+ ok: false;
10
+ reasonCode: "NOT_DARWIN" | "UNSUPPORTED_HARDWARE" | "HELPER_NOT_FOUND";
11
+ };
12
+ export declare function resolveHelper(): ResolverResult;
13
+ export declare function ensureExecutable(location: HelperLocation): Promise<void>;
@@ -0,0 +1,49 @@
1
+ import * as path from "path";
2
+ import * as fs from "fs";
3
+ import { fileURLToPath } from "url";
4
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
5
+ export function resolveHelper() {
6
+ if (process.platform !== "darwin") {
7
+ return { ok: false, reasonCode: "NOT_DARWIN" };
8
+ }
9
+ if (process.arch !== "arm64") {
10
+ return { ok: false, reasonCode: "UNSUPPORTED_HARDWARE" };
11
+ }
12
+ const helperPath = findHelperBinary();
13
+ if (!helperPath) {
14
+ return { ok: false, reasonCode: "HELPER_NOT_FOUND" };
15
+ }
16
+ return {
17
+ ok: true,
18
+ location: {
19
+ type: "cli",
20
+ executablePath: helperPath,
21
+ },
22
+ };
23
+ }
24
+ function findHelperBinary() {
25
+ // Try bundled binary in main package (npm distribution)
26
+ const bundledPath = path.join(__dirname, "..", "bin", "fm-proxy");
27
+ if (fs.existsSync(bundledPath)) {
28
+ return bundledPath;
29
+ }
30
+ // Try development paths (for local testing)
31
+ const devPaths = [
32
+ path.join(__dirname, "..", "swift", ".build", "release", "fm-proxy"),
33
+ path.join(__dirname, "..", "swift", ".build", "debug", "fm-proxy"),
34
+ ];
35
+ for (const p of devPaths) {
36
+ if (fs.existsSync(p)) {
37
+ return p;
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+ export async function ensureExecutable(location) {
43
+ try {
44
+ await fs.promises.access(location.executablePath, fs.constants.X_OK);
45
+ }
46
+ catch {
47
+ await fs.promises.chmod(location.executablePath, 0o755);
48
+ }
49
+ }
@@ -0,0 +1,34 @@
1
+ import { ChildProcess } from "child_process";
2
+ import { EventEmitter } from "events";
3
+ export interface RPCRequest {
4
+ id?: string;
5
+ method: string;
6
+ params?: unknown;
7
+ }
8
+ export interface SendOptions {
9
+ timeoutMs?: number;
10
+ signal?: AbortSignal;
11
+ }
12
+ export interface RPCResponse {
13
+ id?: string | null;
14
+ ok: boolean;
15
+ result?: unknown;
16
+ error?: {
17
+ code: string;
18
+ detail: string;
19
+ };
20
+ }
21
+ export declare class RPCTransport extends EventEmitter {
22
+ private process;
23
+ private buffer;
24
+ private contentLength;
25
+ private requestId;
26
+ private pending;
27
+ constructor(proc: ChildProcess);
28
+ private onData;
29
+ private processBuffer;
30
+ private handleResponse;
31
+ send(method: string, params?: unknown, options?: SendOptions): Promise<RPCResponse>;
32
+ sendStreaming(method: string, params: unknown, onEvent: (event: RPCResponse) => void, options?: SendOptions): Promise<RPCResponse>;
33
+ close(): void;
34
+ }
@@ -0,0 +1,204 @@
1
+ import { EventEmitter } from "events";
2
+ const DEFAULT_TIMEOUT_MS = 60_000; // 60 seconds
3
+ export class RPCTransport extends EventEmitter {
4
+ process;
5
+ buffer = Buffer.alloc(0);
6
+ contentLength = null;
7
+ requestId = 0;
8
+ pending = new Map();
9
+ constructor(proc) {
10
+ super();
11
+ this.process = proc;
12
+ proc.stdout?.on("data", (chunk) => {
13
+ this.onData(chunk);
14
+ });
15
+ proc.stderr?.on("data", (chunk) => {
16
+ this.emit("log", chunk.toString());
17
+ });
18
+ proc.on("exit", (code) => {
19
+ this.emit("exit", code);
20
+ for (const [, { reject }] of this.pending) {
21
+ reject(new Error(`Helper process exited with code ${code}`));
22
+ }
23
+ this.pending.clear();
24
+ });
25
+ proc.on("error", (err) => {
26
+ this.emit("error", err);
27
+ });
28
+ }
29
+ onData(data) {
30
+ this.buffer = Buffer.concat([this.buffer, data]);
31
+ this.processBuffer();
32
+ }
33
+ processBuffer() {
34
+ while (true) {
35
+ if (this.contentLength === null) {
36
+ const headerEndMarker = Buffer.from("\r\n\r\n");
37
+ const headerEnd = this.buffer.indexOf(headerEndMarker);
38
+ if (headerEnd === -1)
39
+ return;
40
+ const header = this.buffer.subarray(0, headerEnd).toString("utf8");
41
+ const match = header.match(/Content-Length:\s*(\d+)/i);
42
+ if (!match) {
43
+ this.emit("error", new Error("Invalid LSP header"));
44
+ return;
45
+ }
46
+ this.contentLength = parseInt(match[1], 10);
47
+ this.buffer = this.buffer.subarray(headerEnd + 4);
48
+ }
49
+ if (this.buffer.length < this.contentLength)
50
+ return;
51
+ const body = this.buffer.subarray(0, this.contentLength).toString("utf8");
52
+ this.buffer = this.buffer.subarray(this.contentLength);
53
+ this.contentLength = null;
54
+ try {
55
+ const response = JSON.parse(body);
56
+ this.handleResponse(response);
57
+ }
58
+ catch (err) {
59
+ this.emit("error", new Error(`Invalid JSON response: ${body}`));
60
+ }
61
+ }
62
+ }
63
+ handleResponse(response) {
64
+ // Always emit as event first (for streaming handlers)
65
+ this.emit("event", response);
66
+ // Then resolve pending promise if this is a direct response
67
+ if (response.id && this.pending.has(response.id)) {
68
+ const { resolve } = this.pending.get(response.id);
69
+ this.pending.delete(response.id);
70
+ resolve(response);
71
+ }
72
+ }
73
+ async send(method, params, options = {}) {
74
+ const id = `req_${++this.requestId}`;
75
+ const request = { id, method, params };
76
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
77
+ return new Promise((resolve, reject) => {
78
+ let timer = null;
79
+ let aborted = false;
80
+ const cleanup = () => {
81
+ if (timer)
82
+ clearTimeout(timer);
83
+ this.pending.delete(id);
84
+ };
85
+ // Timeout handling
86
+ timer = setTimeout(() => {
87
+ cleanup();
88
+ reject(new Error(`Request timeout after ${timeoutMs}ms`));
89
+ }, timeoutMs);
90
+ // AbortSignal handling
91
+ if (options.signal) {
92
+ if (options.signal.aborted) {
93
+ cleanup();
94
+ reject(new Error("Request aborted"));
95
+ return;
96
+ }
97
+ options.signal.addEventListener("abort", () => {
98
+ aborted = true;
99
+ cleanup();
100
+ reject(new Error("Request aborted"));
101
+ }, { once: true });
102
+ }
103
+ this.pending.set(id, {
104
+ resolve: (response) => {
105
+ if (!aborted) {
106
+ cleanup();
107
+ resolve(response);
108
+ }
109
+ },
110
+ reject: (err) => {
111
+ cleanup();
112
+ reject(err);
113
+ },
114
+ });
115
+ const body = JSON.stringify(request);
116
+ const message = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
117
+ this.process.stdin?.write(message, (err) => {
118
+ if (err) {
119
+ cleanup();
120
+ reject(err);
121
+ }
122
+ });
123
+ });
124
+ }
125
+ async sendStreaming(method, params, onEvent, options = {}) {
126
+ const id = `req_${++this.requestId}`;
127
+ const request = { id, method, params };
128
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS * 2; // Longer timeout for streaming
129
+ return new Promise((resolve, reject) => {
130
+ let timer = null;
131
+ let aborted = false;
132
+ const cleanup = () => {
133
+ if (timer)
134
+ clearTimeout(timer);
135
+ this.pending.delete(id);
136
+ this.off("event", eventHandler);
137
+ };
138
+ // Timeout handling
139
+ timer = setTimeout(() => {
140
+ cleanup();
141
+ reject(new Error(`Streaming request timeout after ${timeoutMs}ms`));
142
+ }, timeoutMs);
143
+ // Reset timeout on each event (progress)
144
+ const resetTimeout = () => {
145
+ if (timer)
146
+ clearTimeout(timer);
147
+ timer = setTimeout(() => {
148
+ cleanup();
149
+ reject(new Error(`No streaming progress for ${timeoutMs}ms`));
150
+ }, timeoutMs);
151
+ };
152
+ // AbortSignal handling
153
+ if (options.signal) {
154
+ if (options.signal.aborted) {
155
+ cleanup();
156
+ reject(new Error("Request aborted"));
157
+ return;
158
+ }
159
+ options.signal.addEventListener("abort", () => {
160
+ aborted = true;
161
+ cleanup();
162
+ reject(new Error("Request aborted"));
163
+ }, { once: true });
164
+ }
165
+ const eventHandler = (event) => {
166
+ const result = event.result;
167
+ if (result?.request_id === id) {
168
+ resetTimeout(); // Got progress, reset timeout
169
+ if (!aborted) {
170
+ onEvent(event);
171
+ }
172
+ if (result.event === "done" || result.event === "error") {
173
+ cleanup();
174
+ resolve(event);
175
+ }
176
+ }
177
+ };
178
+ this.on("event", eventHandler);
179
+ this.pending.set(id, {
180
+ resolve: (response) => {
181
+ if (!aborted) {
182
+ cleanup();
183
+ resolve(response);
184
+ }
185
+ },
186
+ reject: (err) => {
187
+ cleanup();
188
+ reject(err);
189
+ },
190
+ });
191
+ const body = JSON.stringify(request);
192
+ const message = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
193
+ this.process.stdin?.write(message, (err) => {
194
+ if (err) {
195
+ cleanup();
196
+ reject(err);
197
+ }
198
+ });
199
+ });
200
+ }
201
+ close() {
202
+ this.process.stdin?.end();
203
+ }
204
+ }
package/package.json CHANGED
@@ -1,21 +1,53 @@
1
1
  {
2
2
  "name": "apple-local-llm",
3
- "version": "0.0.1",
4
- "description": "Call Apple's on-device Foundation Models using the OpenAI Responses API format — no servers, no setup.",
5
- "main": "index.js",
3
+ "version": "0.0.2",
4
+ "description": "Call Apple's on-device Foundation Models — no servers, no setup.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "require": "./dist/index.js",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "build:swift": "cd swift && swift build -c release",
18
+ "build:all": "npm run build:swift && npm run copy:bin && npm run build",
19
+ "copy:bin": "mkdir -p bin && cp swift/.build/release/fm-proxy bin/fm-proxy",
20
+ "test": "npx tsx test-all-interactions.ts",
21
+ "prepublishOnly": "npm run build:all"
22
+ },
6
23
  "keywords": [
7
24
  "apple",
8
25
  "llm",
9
26
  "foundation-models",
10
- "openai",
11
27
  "local",
12
28
  "on-device",
13
- "macos"
29
+ "macos",
30
+ "apple-intelligence"
14
31
  ],
15
32
  "author": "parkerduff",
16
33
  "license": "MIT",
17
34
  "repository": {
18
35
  "type": "git",
19
36
  "url": "https://github.com/parkerduff/apple-local-llm"
37
+ },
38
+ "bin": {
39
+ "fm-proxy": "./bin/fm-proxy"
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "bin",
44
+ "README.md"
45
+ ],
46
+ "devDependencies": {
47
+ "@types/node": "^20.0.0",
48
+ "typescript": "^5.0.0"
49
+ },
50
+ "engines": {
51
+ "node": ">=18.0.0"
20
52
  }
21
53
  }
package/index.js DELETED
@@ -1,3 +0,0 @@
1
- // apple-local-llm - placeholder
2
- // Full implementation coming soon
3
- throw new Error("apple-local-llm is not yet implemented. Check back soon!");