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 +21 -0
- package/README.md +335 -8
- package/bin/fm-proxy +0 -0
- package/dist/client.d.ts +105 -0
- package/dist/client.js +249 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/process-manager.d.ts +25 -0
- package/dist/process-manager.js +122 -0
- package/dist/resolver.d.ts +13 -0
- package/dist/resolver.js +49 -0
- package/dist/transport.d.ts +34 -0
- package/dist/transport.js +204 -0
- package/package.json +37 -5
- package/index.js +0 -3
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
|
|
3
|
+
Call Apple's on-device Foundation Models from JavaScript — no servers, no setup.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Works with Node.js, Electron, and VS Code extensions.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Requirements
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
- **macOS 26+** (Tahoe)
|
|
10
|
+
- **Apple Silicon** (M Series)
|
|
11
|
+
- **Apple Intelligence enabled** in System Settings
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
package/dist/client.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
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>;
|
package/dist/resolver.js
ADDED
|
@@ -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.
|
|
4
|
-
"description": "Call Apple's on-device Foundation Models
|
|
5
|
-
"
|
|
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