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/dist/cli.js CHANGED
@@ -1,15 +1,3054 @@
1
1
  #!/usr/bin/env node
2
- import {
3
- AppleLLM,
4
- AppleLLMError,
5
- captureScreenshot,
6
- installCloudShortcut,
7
- parseImageFlag,
8
- probe
9
- } from "./chunk-FQTRQ3KP.js";
10
2
 
11
3
  // src/cli.ts
12
- import { readFile } from "fs/promises";
4
+ import { readFile as readFile3 } from "fs/promises";
5
+
6
+ // src/cloud.ts
7
+ import { execFile as execFile2 } from "child_process";
8
+ import { mkdtemp, readFile, rm, stat, writeFile } from "fs/promises";
9
+ import os from "os";
10
+ import path from "path";
11
+ import { promisify as promisify2 } from "util";
12
+
13
+ // src/errors.ts
14
+ var AppleLLMError = class extends Error {
15
+ constructor(message, tier, options) {
16
+ super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
17
+ this.tier = tier;
18
+ this.name = new.target.name;
19
+ }
20
+ tier;
21
+ code = "APPLE_LLM_ERROR";
22
+ };
23
+ var ModelUnavailableError = class extends AppleLLMError {
24
+ constructor(message, reason, tier, options) {
25
+ super(message, tier, options);
26
+ this.reason = reason;
27
+ }
28
+ reason;
29
+ code = "MODEL_UNAVAILABLE";
30
+ };
31
+ var SchemaRejectedError = class extends AppleLLMError {
32
+ code = "SCHEMA_REJECTED";
33
+ };
34
+ var SchemaValidationError = class extends AppleLLMError {
35
+ constructor(message, issues, text, tier) {
36
+ super(message, tier);
37
+ this.issues = issues;
38
+ this.text = text;
39
+ }
40
+ issues;
41
+ text;
42
+ code = "SCHEMA_VALIDATION";
43
+ };
44
+ var ContextLengthError = class extends AppleLLMError {
45
+ constructor(message, tier, contextSize, tokenCount) {
46
+ super(message, tier);
47
+ this.contextSize = contextSize;
48
+ this.tokenCount = tokenCount;
49
+ }
50
+ contextSize;
51
+ tokenCount;
52
+ code = "CONTEXT_LENGTH";
53
+ };
54
+ var QuotaError = class extends AppleLLMError {
55
+ constructor(message, tier, resetDate) {
56
+ super(message, tier);
57
+ this.resetDate = resetDate;
58
+ }
59
+ resetDate;
60
+ code = "QUOTA";
61
+ };
62
+ var TimeoutError = class extends AppleLLMError {
63
+ code = "TIMEOUT";
64
+ };
65
+ var AbortError = class extends AppleLLMError {
66
+ code = "ABORTED";
67
+ };
68
+ var SetupRequiredError = class extends AppleLLMError {
69
+ constructor(message, step, tier) {
70
+ super(message, tier);
71
+ this.step = step;
72
+ }
73
+ step;
74
+ code = "SETUP_REQUIRED";
75
+ };
76
+ var RefusalError = class extends AppleLLMError {
77
+ code = "REFUSAL";
78
+ };
79
+ var ToolExecutionError = class extends AppleLLMError {
80
+ constructor(message, toolName, options) {
81
+ super(message, "device", options);
82
+ this.toolName = toolName;
83
+ }
84
+ toolName;
85
+ code = "TOOL_EXECUTION";
86
+ };
87
+ var ModelBusyError = class extends AppleLLMError {
88
+ code = "MODEL_BUSY";
89
+ };
90
+ var UnsupportedError = class extends AppleLLMError {
91
+ code = "UNSUPPORTED";
92
+ };
93
+ function unavailableMessage(reason) {
94
+ const base = "Apple\u2019s on-device model is not available";
95
+ if (reason?.includes("appleIntelligenceNotEnabled")) {
96
+ return `${base}: Apple Intelligence is turned off.
97
+ Enable it in System Settings \u203A Apple Intelligence & Siri, then try again.`;
98
+ }
99
+ if (reason?.includes("modelNotReady")) {
100
+ return `${base}: the model is still downloading.
101
+ Leave the Mac online and plugged in for a few minutes, then try again.`;
102
+ }
103
+ if (reason?.includes("deviceNotEligible")) {
104
+ return `${base}: this Mac is not eligible for Apple Intelligence.`;
105
+ }
106
+ if (reason?.includes("unsupportedPlatform")) {
107
+ return `${base}: this platform is not supported (macOS on Apple Silicon only).`;
108
+ }
109
+ if (reason?.includes("unsupportedOSVersion")) {
110
+ return `${base}: needs macOS 26 or later.`;
111
+ }
112
+ if (reason?.includes("noSwiftCompiler")) {
113
+ return `${base}: no Swift compiler found.
114
+ Install the Xcode command line tools with \`xcode-select --install\`, then try again.`;
115
+ }
116
+ return `${base}${reason ? `: ${reason}` : "."}`;
117
+ }
118
+ function toUnavailableReason(reason) {
119
+ if (reason?.includes("appleIntelligenceNotEnabled")) return "appleIntelligenceNotEnabled";
120
+ if (reason?.includes("modelNotReady")) return "modelNotReady";
121
+ if (reason?.includes("deviceNotEligible")) return "deviceNotEligible";
122
+ if (reason?.includes("unsupportedPlatform")) return "unsupportedPlatform";
123
+ if (reason?.includes("unsupportedOSVersion")) return "unsupportedOSVersion";
124
+ if (reason?.includes("noSwiftCompiler")) return "noSwiftCompiler";
125
+ return "unknown";
126
+ }
127
+
128
+ // src/json-recovery.ts
129
+ function stripCodeFences(text) {
130
+ const trimmed = text.trim();
131
+ const fenced = /```(?:json)?\s*\n?([\s\S]*?)```/i.exec(trimmed);
132
+ if (fenced?.[1] !== void 0) return fenced[1].trim();
133
+ if (trimmed.startsWith("```")) {
134
+ return trimmed.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "").trim();
135
+ }
136
+ return trimmed;
137
+ }
138
+ function extractJsonSpan(text) {
139
+ for (let start = 0; start < text.length; start += 1) {
140
+ const open = text[start];
141
+ if (open !== "{" && open !== "[") continue;
142
+ const close = open === "{" ? "}" : "]";
143
+ let depth = 0;
144
+ let inString = false;
145
+ let escaped = false;
146
+ for (let i = start; i < text.length; i += 1) {
147
+ const ch = text[i];
148
+ if (escaped) {
149
+ escaped = false;
150
+ continue;
151
+ }
152
+ if (ch === "\\") {
153
+ if (inString) escaped = true;
154
+ continue;
155
+ }
156
+ if (ch === '"') {
157
+ inString = !inString;
158
+ continue;
159
+ }
160
+ if (inString) continue;
161
+ if (ch === open) depth += 1;
162
+ else if (ch === close) {
163
+ depth -= 1;
164
+ if (depth === 0) return text.slice(start, i + 1);
165
+ }
166
+ }
167
+ }
168
+ return null;
169
+ }
170
+ function parseLlmJson(raw) {
171
+ const text = stripCodeFences(raw);
172
+ try {
173
+ return JSON.parse(text);
174
+ } catch {
175
+ }
176
+ let offset = 0;
177
+ while (offset < text.length) {
178
+ const span = extractJsonSpan(text.slice(offset));
179
+ if (span === null) break;
180
+ try {
181
+ return JSON.parse(span);
182
+ } catch {
183
+ }
184
+ const idx = text.indexOf(span, offset);
185
+ offset = (idx === -1 ? offset : idx) + 1;
186
+ }
187
+ throw new Error(`Model returned invalid JSON: ${raw.slice(0, 200)}`);
188
+ }
189
+
190
+ // src/target.ts
191
+ import { execFile } from "child_process";
192
+ import { promisify } from "util";
193
+ var execFileAsync = promisify(execFile);
194
+ function isAppleSiliconMac() {
195
+ return process.platform === "darwin" && process.arch === "arm64";
196
+ }
197
+ function targetTripleFrom(versionOutput, arch = process.arch) {
198
+ const [major, minor = "0"] = versionOutput.trim().split(".");
199
+ if (!major || !/^\d+$/.test(major)) return null;
200
+ if (minor !== void 0 && !/^\d+$/.test(minor)) return null;
201
+ const cpu = arch === "x64" || arch === "x86_64" ? "x86_64" : "arm64";
202
+ return `${cpu}-apple-macos${major}.${minor}`;
203
+ }
204
+ async function macosMajor() {
205
+ try {
206
+ const { stdout } = await execFileAsync("sw_vers", ["-productVersion"]);
207
+ const major = Number.parseInt(stdout.trim().split(".")[0] ?? "", 10);
208
+ return Number.isFinite(major) ? major : null;
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+ async function hostTarget() {
214
+ for (const [cmd, args] of [
215
+ ["xcrun", ["--show-sdk-version"]],
216
+ ["sw_vers", ["-productVersion"]]
217
+ ]) {
218
+ try {
219
+ const { stdout } = await execFileAsync(cmd, args);
220
+ const triple = targetTripleFrom(stdout);
221
+ if (triple !== null) return triple;
222
+ } catch {
223
+ }
224
+ }
225
+ return null;
226
+ }
227
+ async function findSwiftc() {
228
+ try {
229
+ await execFileAsync("xcrun", ["-f", "swiftc"]);
230
+ return { command: "xcrun", prefixArgs: ["swiftc"] };
231
+ } catch {
232
+ }
233
+ const { stat: stat3 } = await import("fs/promises");
234
+ try {
235
+ await stat3("/usr/bin/swiftc");
236
+ return { command: "/usr/bin/swiftc", prefixArgs: [] };
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+
242
+ // src/cloud.ts
243
+ var execFileAsync2 = promisify2(execFile2);
244
+ var CLOUD_SHORTCUT_NAME = "Apple LLM Cloud";
245
+ var CLOUD_SHORTCUT_NAME_WEB = "Apple LLM Cloud Web";
246
+ var CALL_TIMEOUT_MS = 12e4;
247
+ var CLOUD_CONTEXT_TOKENS = 32768;
248
+ var IMPORT_TIMEOUT_MS = 9e4;
249
+ var IMPORT_POLL_MS = 2e3;
250
+ function shortcutDefinition(webSearch = false) {
251
+ const parameters = {
252
+ // Stable UUID: re-running setup re-imports the same action rather than
253
+ // accumulating variants.
254
+ UUID: webSearch ? "B47F1A90-2D5E-4C83-A6F1-9E0C7B34D215" : "3827CFFE-3A65-4456-BFA0-49EF061ACEE3",
255
+ // Plain text out. "Automatic" reshapes the result to suit whatever action
256
+ // comes next, and nothing comes next here.
257
+ WFGenerativeResultType: "Text",
258
+ // The shortcut's own input, spliced in as a variable. U+FFFC is the
259
+ // object-replacement character marking the attachment's position.
260
+ WFLLMPrompt: {
261
+ Value: {
262
+ string: "\uFFFC",
263
+ attachmentsByRange: { "{0, 1}": { Type: "ExtensionInput" } }
264
+ },
265
+ WFSerializationType: "WFTextTokenString"
266
+ }
267
+ };
268
+ if (webSearch) parameters.WFAllowWebSearch = true;
269
+ return {
270
+ WFWorkflowMinimumClientVersionString: "900",
271
+ WFWorkflowMinimumClientVersion: 900,
272
+ WFWorkflowClientVersion: "5037.0.17",
273
+ WFWorkflowIcon: {
274
+ WFWorkflowIconStartColor: 431817727,
275
+ WFWorkflowIconGlyphNumber: 61440
276
+ },
277
+ WFWorkflowOutputContentItemClasses: [],
278
+ WFWorkflowHasOutputFallback: false,
279
+ WFWorkflowActions: [
280
+ {
281
+ WFWorkflowActionIdentifier: "is.workflow.actions.askllm",
282
+ WFWorkflowActionParameters: parameters
283
+ }
284
+ ],
285
+ WFWorkflowInputContentItemClasses: ["WFStringContentItem", "WFRichTextContentItem"],
286
+ WFWorkflowImportQuestions: [],
287
+ WFQuickActionSurfaces: [],
288
+ WFWorkflowTypes: ["WFWorkflowTypeShowInSearch"],
289
+ WFWorkflowHasShortcutInputVariables: true
290
+ };
291
+ }
292
+ async function listShortcuts() {
293
+ try {
294
+ const { stdout } = await execFileAsync2("shortcuts", ["list"], { timeout: 2e4 });
295
+ return stdout.split("\n").map((l) => l.trim()).filter((l) => l !== "");
296
+ } catch {
297
+ return [];
298
+ }
299
+ }
300
+ async function installCount(name = CLOUD_SHORTCUT_NAME) {
301
+ return (await listShortcuts()).filter((l) => l === name).length;
302
+ }
303
+ function cloudSetupHint() {
304
+ return "The cloud tier needs a one-time shortcut installed.\nRun: apple-llm setup-cloud\nIt generates and signs the shortcut on this machine \u2014 no account, no API key, nothing uploaded.";
305
+ }
306
+ function duplicateMessage(count, name) {
307
+ return `${count} shortcuts are named "${name}"; \`shortcuts run\` cannot tell them apart and fails with "Couldn't find shortcut".
308
+ Delete the duplicates in the Shortcuts app, then try again.`;
309
+ }
310
+ async function installCloudShortcut(onProgress, options = {}) {
311
+ const name = options.webSearch === true ? CLOUD_SHORTCUT_NAME_WEB : CLOUD_SHORTCUT_NAME;
312
+ const existing = await installCount(name);
313
+ if (existing > 1) throw new AppleLLMError(duplicateMessage(existing, name), "cloud");
314
+ if (existing === 1 && options.force !== true) {
315
+ onProgress?.({ status: `"${name}" is already installed` });
316
+ return;
317
+ }
318
+ const dir = await mkdtemp(path.join(os.tmpdir(), "apple-llm-shortcut-"));
319
+ try {
320
+ const jsonPath = path.join(dir, "definition.json");
321
+ const unsignedPath = path.join(dir, "unsigned.shortcut");
322
+ const signedPath = path.join(dir, `${name}.shortcut`);
323
+ await writeFile(jsonPath, JSON.stringify(shortcutDefinition(options.webSearch)), "utf8");
324
+ await execFileAsync2("plutil", ["-convert", "binary1", "-o", unsignedPath, jsonPath]);
325
+ onProgress?.({ status: "signing the shortcut locally" });
326
+ await execFileAsync2("shortcuts", ["sign", "-m", "anyone", "-i", unsignedPath, "-o", signedPath]);
327
+ onProgress?.({ status: `installing "${name}" (Shortcuts imports asynchronously)` });
328
+ await execFileAsync2("open", [signedPath]);
329
+ const deadline = Date.now() + IMPORT_TIMEOUT_MS;
330
+ for (; ; ) {
331
+ if (await installCount(name) >= 1) break;
332
+ if (Date.now() > deadline) {
333
+ throw new SetupRequiredError(
334
+ `Shortcuts did not import "${name}" within ${IMPORT_TIMEOUT_MS / 1e3}s.
335
+ If a confirmation panel is open in the Shortcuts app, accept it and run setup again.`,
336
+ "setup-cloud",
337
+ "cloud"
338
+ );
339
+ }
340
+ await new Promise((resolve) => setTimeout(resolve, IMPORT_POLL_MS));
341
+ }
342
+ onProgress?.({ status: `"${name}" installed` });
343
+ } finally {
344
+ await rm(dir, { recursive: true, force: true });
345
+ }
346
+ }
347
+ async function probeCloud() {
348
+ if (process.platform !== "darwin") {
349
+ return { available: false, installed: false, reason: `unsupportedPlatform: this machine reports "${process.platform}"` };
350
+ }
351
+ if (!isAppleSiliconMac()) {
352
+ return { available: false, installed: false, reason: "deviceNotEligible: Apple Intelligence needs Apple Silicon" };
353
+ }
354
+ try {
355
+ await execFileAsync2("shortcuts", ["list"], { timeout: 2e4 });
356
+ } catch {
357
+ return { available: false, installed: false, reason: "this system does not provide /usr/bin/shortcuts" };
358
+ }
359
+ const count = await installCount();
360
+ if (count === 0) {
361
+ return { available: false, installed: false, reason: cloudSetupHint() };
362
+ }
363
+ if (count > 1) {
364
+ return { available: false, installed: true, reason: duplicateMessage(count, CLOUD_SHORTCUT_NAME) };
365
+ }
366
+ return { available: true, installed: true, contextSize: CLOUD_CONTEXT_TOKENS };
367
+ }
368
+ var CloudClient = class {
369
+ ready = false;
370
+ /** Last known quota, set by `probe()` so a call can fail fast. */
371
+ quota;
372
+ /** What the framework says the server model can do, when it could be read. */
373
+ model;
374
+ /**
375
+ * Tell the client what the framework reported about the quota.
376
+ *
377
+ * Worth doing because a `shortcuts run` against an exhausted quota costs a
378
+ * full round trip to find out; this turns that into an immediate typed error.
379
+ */
380
+ setQuota(quota) {
381
+ this.quota = quota;
382
+ this.model = quota;
383
+ }
384
+ /** Whether the server model is known to read images. False when it could not be read (macOS 26). */
385
+ get supportsImages() {
386
+ return this.model?.capabilities?.vision === true;
387
+ }
388
+ assertQuota() {
389
+ if (this.quota?.status !== "limitReached") return;
390
+ throw new QuotaError(
391
+ "Apple Private Cloud Compute quota is exhausted (reported by the framework before the call was made).\nFall back to the on-device tier, or try again later.",
392
+ "cloud",
393
+ this.quota.resetDate === void 0 ? void 0 : new Date(this.quota.resetDate)
394
+ );
395
+ }
396
+ get label() {
397
+ return "apple private cloud compute";
398
+ }
399
+ get contextSize() {
400
+ return this.model?.contextSize ?? CLOUD_CONTEXT_TOKENS;
401
+ }
402
+ async ensureReady(onProgress) {
403
+ if (this.ready) return;
404
+ const probe2 = await probeCloud();
405
+ if (!probe2.available) {
406
+ if (probe2.installed !== true) {
407
+ throw new SetupRequiredError(probe2.reason ?? cloudSetupHint(), "setup-cloud", "cloud");
408
+ }
409
+ throw new AppleLLMError(probe2.reason ?? "the cloud tier is unavailable", "cloud");
410
+ }
411
+ this.ready = true;
412
+ onProgress?.({ status: `apple private cloud compute ready (${CLOUD_CONTEXT_TOKENS} token context)` });
413
+ }
414
+ async text(request) {
415
+ await this.ensureReady();
416
+ this.assertQuota();
417
+ const name = request.webSearch === true ? CLOUD_SHORTCUT_NAME_WEB : CLOUD_SHORTCUT_NAME;
418
+ if (request.webSearch === true && await installCount(name) !== 1) {
419
+ throw new SetupRequiredError(
420
+ `webSearch needs the "${name}" shortcut.
421
+ Run: apple-llm setup-cloud --web-search`,
422
+ "setup-cloud --web-search",
423
+ "cloud"
424
+ );
425
+ }
426
+ const images = await imageInputs(request.images);
427
+ let prompt = request.system ? `${request.system}
428
+
429
+ ${request.prompt}` : request.prompt;
430
+ const labels = images.map((image, i) => image.label === void 0 ? void 0 : `${i + 1}: ${image.label}`);
431
+ if (labels.some((label) => label !== void 0)) {
432
+ prompt = `${prompt}
433
+
434
+ Attached images, in order \u2014 ${labels.map((l, i) => l ?? `${i + 1}`).join(", ")}.`;
435
+ }
436
+ const dir = await mkdtemp(path.join(os.tmpdir(), "apple-llm-cloud-"));
437
+ try {
438
+ const inPath = path.join(dir, "prompt.txt");
439
+ const outPath = path.join(dir, "reply.txt");
440
+ await writeFile(inPath, prompt, "utf8");
441
+ const inputs = [inPath, ...images.map((image) => image.path)].flatMap((p) => ["-i", p]);
442
+ try {
443
+ await execFileAsync2("shortcuts", ["run", name, ...inputs, "-o", outPath], {
444
+ timeout: CALL_TIMEOUT_MS,
445
+ killSignal: "SIGKILL",
446
+ signal: request.signal
447
+ });
448
+ } catch (error) {
449
+ if (request.signal?.aborted === true) {
450
+ throw new AbortError("The request was aborted.", "cloud", { cause: request.signal.reason });
451
+ }
452
+ throw describeRunFailure(error, name);
453
+ }
454
+ let reply;
455
+ try {
456
+ reply = await readFile(outPath, "utf8");
457
+ } catch {
458
+ throw new AppleLLMError("Apple cloud generation produced no output.", "cloud");
459
+ }
460
+ if (reply.trim() === "") {
461
+ throw new AppleLLMError("Apple cloud generation returned an empty reply.", "cloud");
462
+ }
463
+ return reply;
464
+ } finally {
465
+ await rm(dir, { recursive: true, force: true });
466
+ }
467
+ }
468
+ /**
469
+ * There is no constrained decoding on this tier, so the schema is spelled out
470
+ * in the prompt and the reply is mined for JSON. The shape is a request here,
471
+ * not a guarantee — unlike on device.
472
+ */
473
+ async json(request) {
474
+ const instruction = `Reply with a single JSON value matching this JSON Schema. Output only the JSON, with no commentary and no code fence.
475
+
476
+ ${JSON.stringify(request.schema, null, 2)}`;
477
+ const system = request.system ? `${request.system}
478
+
479
+ ${instruction}` : instruction;
480
+ return parseLlmJson(await this.text({ ...request, system }));
481
+ }
482
+ // eslint-disable-next-line class-methods-use-this
483
+ close() {
484
+ }
485
+ };
486
+ async function imageInputs(images) {
487
+ const out = [];
488
+ for (const image of images ?? []) {
489
+ const entry = typeof image === "string" ? { path: image } : image;
490
+ const absolute = path.resolve(entry.path);
491
+ try {
492
+ await stat(absolute);
493
+ } catch {
494
+ throw new AppleLLMError(`Image not found: ${entry.path}`, "cloud");
495
+ }
496
+ out.push({ path: absolute, ...entry.label === void 0 ? {} : { label: entry.label } });
497
+ }
498
+ return out;
499
+ }
500
+ function describeRunFailure(error, name = CLOUD_SHORTCUT_NAME) {
501
+ const err = error;
502
+ if (err.killed === true) {
503
+ return new TimeoutError(
504
+ `Apple cloud generation got no response within ${CALL_TIMEOUT_MS / 1e3}s. Open "${name}" in the Shortcuts app and check that Request is bound to the Shortcut Input variable \u2014 an unbound Request makes the action wait for a human.`,
505
+ "cloud"
506
+ );
507
+ }
508
+ const stderr = err.stderr?.trim() ?? "";
509
+ if (/maximum allowed length/i.test(stderr)) {
510
+ return new ContextLengthError(
511
+ `The prompt exceeded the cloud model's ~${CLOUD_CONTEXT_TOKENS}-token context window.`,
512
+ "cloud"
513
+ );
514
+ }
515
+ if (/QuotaLimitReached|quota/i.test(stderr)) {
516
+ const match = /(\d{4}-\d{2}-\d{2}[T ][\d:]+)/.exec(stderr);
517
+ const resetDate = match ? new Date(match[1].replace(" ", "T")) : void 0;
518
+ return new QuotaError(
519
+ `Apple Private Cloud Compute quota reached.${resetDate ? ` Resets ${resetDate.toISOString()}.` : ""}
520
+ Fall back to the on-device tier, or try again later.`,
521
+ "cloud",
522
+ resetDate !== void 0 && !Number.isNaN(resetDate.getTime()) ? resetDate : void 0
523
+ );
524
+ }
525
+ if (/Couldn.t find shortcut/i.test(stderr)) {
526
+ return new SetupRequiredError(
527
+ `Shortcuts could not find "${name}".
528
+ ${cloudSetupHint()}`,
529
+ "setup-cloud",
530
+ "cloud"
531
+ );
532
+ }
533
+ return new AppleLLMError(
534
+ `Apple cloud generation failed: ${stderr !== "" ? stderr : err.message ?? String(error)}`,
535
+ "cloud"
536
+ );
537
+ }
538
+
539
+ // src/device.ts
540
+ import { execFile as execFile4 } from "child_process";
541
+ import { randomUUID } from "crypto";
542
+ import { promisify as promisify4 } from "util";
543
+
544
+ // src/compile.ts
545
+ import { execFile as execFile3 } from "child_process";
546
+ import { chmod, mkdir, readFile as readFile2, rename, rm as rm2, stat as stat2 } from "fs/promises";
547
+ import { createHash } from "crypto";
548
+ import os2 from "os";
549
+ import path2 from "path";
550
+ import { fileURLToPath } from "url";
551
+ import { promisify as promisify3 } from "util";
552
+ var execFileAsync3 = promisify3(execFile3);
553
+ function cacheDir() {
554
+ return path2.join(os2.homedir(), "Library", "Caches", "apple-llm", "bin");
555
+ }
556
+ function fingerprint(source, triple) {
557
+ return createHash("sha256").update(`${source}
558
+ ${triple}`, "utf8").digest("hex").slice(0, 12);
559
+ }
560
+ var cachedSource;
561
+ async function helperSource() {
562
+ if (cachedSource !== void 0) return cachedSource;
563
+ const here = path2.dirname(fileURLToPath(import.meta.url));
564
+ const candidates = [
565
+ path2.join(here, "..", "swift", "helper.swift"),
566
+ path2.join(here, "swift", "helper.swift")
567
+ ];
568
+ for (const candidate of candidates) {
569
+ try {
570
+ cachedSource = await readFile2(candidate, "utf8");
571
+ return cachedSource;
572
+ } catch {
573
+ }
574
+ }
575
+ throw new Error(
576
+ `apple-llm could not find its embedded helper.swift (looked in ${candidates.join(", ")}).`
577
+ );
578
+ }
579
+ async function fileExists(p) {
580
+ try {
581
+ await stat2(p);
582
+ return true;
583
+ } catch {
584
+ return false;
585
+ }
586
+ }
587
+ var inFlight;
588
+ var cachedKey;
589
+ async function ensureBinary(onProgress, options = {}) {
590
+ if (options.force === true) {
591
+ inFlight = void 0;
592
+ cachedKey = void 0;
593
+ } else if (inFlight !== void 0 && cachedKey !== void 0) {
594
+ const pending = inFlight;
595
+ try {
596
+ const source = await helperSource();
597
+ const triple = await hostTarget() ?? process.arch;
598
+ if (fingerprint(source, triple) === cachedKey) return pending;
599
+ inFlight = void 0;
600
+ cachedKey = void 0;
601
+ } catch {
602
+ return pending;
603
+ }
604
+ }
605
+ if (inFlight === void 0) {
606
+ try {
607
+ const source = await helperSource();
608
+ const triple = await hostTarget() ?? process.arch;
609
+ cachedKey = fingerprint(source, triple);
610
+ } catch {
611
+ }
612
+ inFlight = build(onProgress).catch((err) => {
613
+ inFlight = void 0;
614
+ cachedKey = void 0;
615
+ throw err;
616
+ });
617
+ }
618
+ return inFlight;
619
+ }
620
+ async function build(onProgress) {
621
+ const source = await helperSource();
622
+ const triple = await hostTarget() ?? process.arch;
623
+ const dir = cacheDir();
624
+ const target = path2.join(dir, `fm-helper-${fingerprint(source, triple)}`);
625
+ if (await fileExists(target)) return target;
626
+ const swiftc = await findSwiftc();
627
+ if (swiftc === null) {
628
+ throw new ModelUnavailableError(
629
+ "apple-llm needs a Swift compiler to build its on-device helper, but none was found.\nInstall the Xcode command line tools with `xcode-select --install`, then try again.",
630
+ "noSwiftCompiler",
631
+ "device"
632
+ );
633
+ }
634
+ onProgress?.({ status: "building the Apple on-device helper (one time, a few seconds)" });
635
+ await mkdir(dir, { recursive: true });
636
+ const unique = `${process.pid}.${Math.random().toString(36).slice(2)}`;
637
+ const sourcePath = `${target}.${unique}.swift`;
638
+ const staging = `${target}.${unique}.tmp`;
639
+ const { writeFile: writeFile3 } = await import("fs/promises");
640
+ await writeFile3(sourcePath, source, "utf8");
641
+ try {
642
+ const targetArgs = triple.includes("-apple-") ? ["-target", triple] : [];
643
+ await execFileAsync3(swiftc.command, [
644
+ ...swiftc.prefixArgs,
645
+ ...targetArgs,
646
+ "-parse-as-library",
647
+ "-O",
648
+ sourcePath,
649
+ "-o",
650
+ staging
651
+ ]);
652
+ await chmod(staging, 493);
653
+ await rename(staging, target);
654
+ } catch (err) {
655
+ await rm2(staging, { force: true });
656
+ if (await fileExists(target)) return target;
657
+ const reason = err instanceof Error ? err.message : String(err);
658
+ throw new ModelUnavailableError(
659
+ `Failed to build the Apple on-device helper with ${swiftc.command}.
660
+ ${reason.slice(0, 400)}
661
+ This usually means the macOS SDK predates the Foundation Models framework (macOS 26+).`,
662
+ "unsupportedOSVersion",
663
+ "device"
664
+ );
665
+ } finally {
666
+ await rm2(sourcePath, { force: true });
667
+ }
668
+ onProgress?.({ status: "helper built" });
669
+ return target;
670
+ }
671
+
672
+ // src/messages.ts
673
+ function splitMessages(messages) {
674
+ const systems = [];
675
+ const rest = [];
676
+ for (const message of messages) {
677
+ if (message.role === "system") systems.push(message.content);
678
+ else rest.push(message);
679
+ }
680
+ let lastUser = -1;
681
+ for (let i = rest.length - 1; i >= 0; i -= 1) {
682
+ if (rest[i].role === "user") {
683
+ lastUser = i;
684
+ break;
685
+ }
686
+ }
687
+ if (lastUser === -1) {
688
+ throw new AppleLLMError("A message list needs at least one user message.");
689
+ }
690
+ const tail = rest.slice(lastUser + 1);
691
+ const final = tail[tail.length - 1];
692
+ if (final !== void 0 && final.role !== "tool") {
693
+ throw new AppleLLMError(
694
+ "The last message must be from the user, or a tool result answering the assistant\u2019s tool call."
695
+ );
696
+ }
697
+ const callNames = /* @__PURE__ */ new Map();
698
+ for (const message of rest) {
699
+ if (message.role === "assistant") for (const call of message.toolCalls ?? []) callNames.set(call.id, call);
700
+ }
701
+ const history = [];
702
+ for (const message of rest.slice(0, lastUser)) {
703
+ const entry = toHistory(message, callNames);
704
+ if (entry !== void 0) history.push(entry);
705
+ }
706
+ const replay = [];
707
+ for (const message of tail) {
708
+ if (message.role !== "tool") continue;
709
+ const call = callNames.get(message.toolCallId);
710
+ replay.push({
711
+ name: message.name ?? call?.name ?? "",
712
+ arguments: call?.arguments ?? {},
713
+ output: message.content
714
+ });
715
+ }
716
+ const last = rest[lastUser];
717
+ return {
718
+ system: systems.length > 0 ? systems.join("\n\n") : void 0,
719
+ history,
720
+ prompt: last.content,
721
+ images: last.images ?? [],
722
+ replay
723
+ };
724
+ }
725
+ function toHistory(message, calls) {
726
+ switch (message.role) {
727
+ case "user": {
728
+ const images = message.images?.length ?? 0;
729
+ const note = images === 0 ? "" : ` [${images === 1 ? "an image was" : `${images} images were`} attached here]`;
730
+ return { role: "user", content: `${message.content}${note}` };
731
+ }
732
+ case "assistant":
733
+ return {
734
+ role: "assistant",
735
+ content: message.content ?? "",
736
+ ...message.toolCalls !== void 0 && message.toolCalls.length > 0 ? { toolCalls: message.toolCalls } : {}
737
+ };
738
+ case "tool":
739
+ return {
740
+ role: "tool",
741
+ toolCallId: message.toolCallId,
742
+ name: message.name ?? calls.get(message.toolCallId)?.name ?? "tool",
743
+ content: message.content
744
+ };
745
+ default:
746
+ return void 0;
747
+ }
748
+ }
749
+ function renderHistory(history) {
750
+ return history.map((entry) => {
751
+ if (entry.role === "user") return `User: ${entry.content}`;
752
+ if (entry.role === "tool") return `Tool ${entry.name} returned: ${entry.content}`;
753
+ const calls = (entry.toolCalls ?? []).map((c) => `[called ${c.name}(${JSON.stringify(c.arguments)})]`);
754
+ return `Assistant: ${[entry.content, ...calls].filter((s) => s !== "").join(" ")}`;
755
+ }).join("\n");
756
+ }
757
+ function canonicalJson(value) {
758
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
759
+ if (value !== null && typeof value === "object") {
760
+ const keys = Object.keys(value).sort();
761
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
762
+ }
763
+ return JSON.stringify(value) ?? "null";
764
+ }
765
+ var ReplayBook = class {
766
+ unused;
767
+ constructor(results) {
768
+ this.unused = [...results];
769
+ }
770
+ take(name, args) {
771
+ const key = canonicalJson(args);
772
+ let index = this.unused.findIndex((r) => r.name === name && canonicalJson(r.arguments) === key);
773
+ if (index < 0) index = this.unused.findIndex((r) => r.name === name);
774
+ if (index < 0) return void 0;
775
+ const [hit] = this.unused.splice(index, 1);
776
+ return hit.output;
777
+ }
778
+ };
779
+
780
+ // src/protocol.ts
781
+ import { spawn } from "child_process";
782
+ var defaultSpawner = (binary, args) => spawn(binary, args, { stdio: ["pipe", "pipe", "pipe"] });
783
+ function abortErrorFrom(signal) {
784
+ const reason = signal.reason;
785
+ if (reason instanceof AppleLLMError) return reason;
786
+ if (reason instanceof Error && reason.name === "TimeoutError") {
787
+ return new TimeoutError(`Apple generation timed out: ${reason.message}`, "device", { cause: reason });
788
+ }
789
+ return new AbortError("The request was aborted.", void 0, { cause: reason });
790
+ }
791
+ var HelperServer = class {
792
+ constructor(binary, options = {}) {
793
+ this.binary = binary;
794
+ this.timeoutMs = options.timeoutMs ?? 12e4;
795
+ this.spawner = options.spawner ?? defaultSpawner;
796
+ }
797
+ binary;
798
+ child;
799
+ buffer = "";
800
+ queue = [];
801
+ /** Serialises callers so one request's reply cannot be handed to another. */
802
+ chain = Promise.resolve();
803
+ inFlight = 0;
804
+ /** The helper's recent stderr, quoted when it dies. Always drained: an unread pipe blocks the writer. */
805
+ stderrTail = "";
806
+ timeoutMs;
807
+ spawner;
808
+ start() {
809
+ if (this.child !== void 0 && this.child.exitCode === null && !this.child.killed) {
810
+ return this.child;
811
+ }
812
+ const child = this.spawner(this.binary, ["--serve"]);
813
+ this.child = child;
814
+ this.buffer = "";
815
+ this.stderrTail = "";
816
+ child.stdout?.on("data", (chunk) => {
817
+ this.buffer += chunk.toString();
818
+ for (; ; ) {
819
+ const newline = this.buffer.indexOf("\n");
820
+ if (newline === -1) break;
821
+ const line = this.buffer.slice(0, newline).trim();
822
+ this.buffer = this.buffer.slice(newline + 1);
823
+ if (line !== "") this.dispatch(line);
824
+ }
825
+ });
826
+ child.stderr?.on("data", (chunk) => {
827
+ this.stderrTail = (this.stderrTail + chunk.toString()).slice(-2e3);
828
+ });
829
+ const fail = (err) => {
830
+ if (this.child === child) this.child = void 0;
831
+ const pending = this.queue;
832
+ this.queue = [];
833
+ for (const p of pending) {
834
+ clearTimeout(p.timer);
835
+ p.finish(void 0, err);
836
+ }
837
+ };
838
+ child.on("error", (err) => fail(err instanceof Error ? err : new Error(String(err))));
839
+ child.on("close", (code) => {
840
+ const tail = this.stderrTail.trim();
841
+ fail(new AppleLLMError(`Apple helper exited with code ${code}${tail ? `: ${tail.slice(-400)}` : ""}`, "device"));
842
+ });
843
+ child.stdin?.on("error", () => {
844
+ });
845
+ const killer = () => {
846
+ if (child.exitCode === null) child.kill();
847
+ };
848
+ process.once("exit", killer);
849
+ child.once("close", () => process.removeListener("exit", killer));
850
+ this.setRef(this.inFlight > 0);
851
+ return child;
852
+ }
853
+ /** Keep the event loop alive only while a request is in flight. */
854
+ setRef(on) {
855
+ const child = this.child;
856
+ if (child === void 0) return;
857
+ const handles = [child, child.stdin, child.stdout, child.stderr];
858
+ for (const handle of handles) {
859
+ if (on) handle?.ref?.();
860
+ else handle?.unref?.();
861
+ }
862
+ }
863
+ /** Route one line: an event to the head's hooks, anything else completes the head. */
864
+ dispatch(line) {
865
+ const head = this.queue[0];
866
+ if (head === void 0) return;
867
+ let parsed;
868
+ try {
869
+ const value = JSON.parse(line);
870
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
871
+ parsed = value;
872
+ }
873
+ } catch {
874
+ }
875
+ if (parsed?.done === false) {
876
+ this.arm(head);
877
+ if (head.abandoned) {
878
+ const call = parsed.toolCall;
879
+ if (typeof call?.id === "string") this.write({ op: "toolResult", callId: call.id, stop: true });
880
+ return;
881
+ }
882
+ try {
883
+ head.hooks.onEvent?.(parsed, (control) => this.write(control));
884
+ } catch {
885
+ }
886
+ return;
887
+ }
888
+ this.queue.shift();
889
+ clearTimeout(head.timer);
890
+ head.finish(line);
891
+ }
892
+ /**
893
+ * (Re)start the wedge timer. It measures silence, not total time: every
894
+ * event resets it, so a long stream is fine while a helper that stops
895
+ * talking is not.
896
+ */
897
+ arm(entry) {
898
+ clearTimeout(entry.timer);
899
+ entry.timer = setTimeout(() => this.timeOut(entry), this.timeoutMs);
900
+ }
901
+ /**
902
+ * Pause the wedge timer while the client itself is busy — a function tool
903
+ * running in JS. The helper is legitimately silent then: it is waiting on us.
904
+ */
905
+ pause() {
906
+ const head = this.queue[0];
907
+ if (head !== void 0) clearTimeout(head.timer);
908
+ }
909
+ /** Resume the wedge timer paused by `pause()`. */
910
+ resume() {
911
+ const head = this.queue[0];
912
+ if (head !== void 0) this.arm(head);
913
+ }
914
+ timeOut(entry) {
915
+ const index = this.queue.indexOf(entry);
916
+ if (index < 0) return;
917
+ this.queue.splice(index, 1);
918
+ this.stop();
919
+ entry.finish(
920
+ void 0,
921
+ new TimeoutError(`Apple on-device generation timed out after ${this.timeoutMs / 1e3}s of silence.`, "device")
922
+ );
923
+ }
924
+ /** Write a control line (cancel, toolResult) for the request in flight. */
925
+ write(control) {
926
+ this.child?.stdin?.write(`${JSON.stringify(control)}
927
+ `);
928
+ }
929
+ /**
930
+ * One request. Resolves with its final line; events go to `hooks.onEvent`.
931
+ * Serialised against every other request through one chain.
932
+ */
933
+ request(payload, hooks = {}) {
934
+ const { signal } = hooks;
935
+ if (signal?.aborted === true) return Promise.reject(abortErrorFrom(signal));
936
+ let entry;
937
+ let rejectCaller = () => void 0;
938
+ const caller = new Promise((resolve, reject) => {
939
+ rejectCaller = reject;
940
+ const run = () => {
941
+ if (signal?.aborted === true) {
942
+ reject(abortErrorFrom(signal));
943
+ return Promise.resolve();
944
+ }
945
+ const child = this.start();
946
+ this.inFlight += 1;
947
+ this.setRef(true);
948
+ return new Promise((settle) => {
949
+ const pending = {
950
+ hooks,
951
+ abandoned: false,
952
+ finish: (line, err) => {
953
+ this.inFlight -= 1;
954
+ if (this.inFlight === 0) this.setRef(false);
955
+ settle();
956
+ if (pending.abandoned) return;
957
+ if (err !== void 0) reject(err);
958
+ else resolve(line ?? "");
959
+ }
960
+ };
961
+ entry = pending;
962
+ this.arm(pending);
963
+ this.queue.push(pending);
964
+ child.stdin?.write(`${payload.replace(/\n/g, " ")}
965
+ `);
966
+ });
967
+ };
968
+ const slot = this.chain.then(run, run);
969
+ this.chain = slot.catch(() => void 0);
970
+ });
971
+ if (signal !== void 0) {
972
+ const onAbort = () => {
973
+ if (entry !== void 0 && this.queue.includes(entry)) {
974
+ entry.abandoned = true;
975
+ if (hooks.id !== void 0) this.write({ op: "cancel", id: hooks.id });
976
+ }
977
+ rejectCaller(abortErrorFrom(signal));
978
+ };
979
+ signal.addEventListener("abort", onAbort, { once: true });
980
+ const detach = () => signal.removeEventListener("abort", onAbort);
981
+ caller.then(detach, detach);
982
+ }
983
+ return caller;
984
+ }
985
+ /** Version-1 shape: one request, one line. */
986
+ send(payload, hooks = {}) {
987
+ return this.request(payload, hooks);
988
+ }
989
+ /**
990
+ * Streaming request, kept for callers of the version-1 API: deltas go to
991
+ * `onDelta` and the promise resolves with the final line.
992
+ */
993
+ stream(payload, onDelta, hooks = {}) {
994
+ return this.request(payload, {
995
+ ...hooks,
996
+ onEvent: (event, write) => {
997
+ if (typeof event.delta === "string") onDelta(event.delta);
998
+ hooks.onEvent?.(event, write);
999
+ }
1000
+ });
1001
+ }
1002
+ stop() {
1003
+ const child = this.child;
1004
+ this.child = void 0;
1005
+ if (child !== void 0 && child.exitCode === null) {
1006
+ child.stdin?.end();
1007
+ child.kill();
1008
+ }
1009
+ }
1010
+ };
1011
+
1012
+ // src/schema.ts
1013
+ var SCHEMA_MAP_KEYS = ["properties", "$defs", "definitions"];
1014
+ var SCHEMA_LIST_KEYS = ["anyOf", "oneOf", "allOf"];
1015
+ var SCHEMA_KEYS = ["items", "not", "additionalItems"];
1016
+ function isPlainObject(value) {
1017
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1018
+ }
1019
+ function sanitize(name, counter) {
1020
+ const cleaned = name.replace(/[^A-Za-z0-9_]/g, "");
1021
+ if (cleaned.length === 0 || /^[0-9]/.test(cleaned)) {
1022
+ counter.n += 1;
1023
+ return `Schema${counter.n}`;
1024
+ }
1025
+ return cleaned;
1026
+ }
1027
+ function titleFrom(name, ctx) {
1028
+ const base = sanitize(name, ctx.counter);
1029
+ if (!ctx.used.has(base)) {
1030
+ ctx.used.add(base);
1031
+ return base;
1032
+ }
1033
+ for (let i = 2; ; i += 1) {
1034
+ const candidate = `${base}${i}`;
1035
+ if (!ctx.used.has(candidate)) {
1036
+ ctx.used.add(candidate);
1037
+ return candidate;
1038
+ }
1039
+ }
1040
+ }
1041
+ function memberType(value) {
1042
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
1043
+ if (typeof value === "boolean") return "boolean";
1044
+ return "string";
1045
+ }
1046
+ function rewriteRef(ref, ctx) {
1047
+ const slash = ref.lastIndexOf("/");
1048
+ if (slash === -1) return ref;
1049
+ const name = ref.slice(slash + 1);
1050
+ const title = ctx.defTitles.get(name);
1051
+ return title === void 0 || title === name ? ref : `${ref.slice(0, slash + 1)}${title}`;
1052
+ }
1053
+ function convertNode(node, name, ctx) {
1054
+ const out = {};
1055
+ let nullable = false;
1056
+ const converted = /* @__PURE__ */ new Map();
1057
+ const properties = node.properties;
1058
+ if (isPlainObject(properties)) {
1059
+ for (const [key, value] of Object.entries(properties)) {
1060
+ converted.set(
1061
+ key,
1062
+ isPlainObject(value) ? convertNode(value, key, ctx) : { schema: value, nullable: false }
1063
+ );
1064
+ }
1065
+ }
1066
+ for (const [key, value] of Object.entries(node)) {
1067
+ if (key === "type" || key === "enum" || key === "required" || key === "properties") continue;
1068
+ if (key === "$ref" && typeof value === "string") {
1069
+ out.$ref = rewriteRef(value, ctx);
1070
+ } else if (SCHEMA_MAP_KEYS.includes(key) && isPlainObject(value)) {
1071
+ const isDefs = key === "$defs" || key === "definitions";
1072
+ const mapped = {};
1073
+ for (const [childName, childValue] of Object.entries(value)) {
1074
+ if (!isPlainObject(childValue)) {
1075
+ mapped[childName] = childValue;
1076
+ continue;
1077
+ }
1078
+ const child = convertNode(childValue, childName, ctx).schema;
1079
+ const reserved = isDefs ? ctx.defTitles.get(childName) : void 0;
1080
+ if (reserved !== void 0) child.title = reserved;
1081
+ mapped[childName] = child;
1082
+ }
1083
+ out[key] = mapped;
1084
+ } else if (SCHEMA_LIST_KEYS.includes(key) && Array.isArray(value)) {
1085
+ if (key === "allOf") {
1086
+ const allOfConverted = value.map(
1087
+ (entry) => isPlainObject(entry) ? convertNode(entry, name, ctx).schema : entry
1088
+ );
1089
+ if (allOfConverted.length === 1 && isPlainObject(allOfConverted[0])) {
1090
+ for (const [k, v] of Object.entries(allOfConverted[0])) {
1091
+ if (out[k] === void 0) out[k] = v;
1092
+ }
1093
+ } else {
1094
+ out.allOf = allOfConverted;
1095
+ }
1096
+ } else {
1097
+ const branches = value.filter((entry) => !(isPlainObject(entry) && entry.type === "null"));
1098
+ if (branches.length !== value.length) nullable = true;
1099
+ const convertedBranches = branches.map(
1100
+ (entry) => isPlainObject(entry) ? convertNode(entry, name, ctx) : { schema: entry, nullable: false }
1101
+ );
1102
+ if (convertedBranches.some((b) => b.nullable)) nullable = true;
1103
+ if (convertedBranches.length === 1 && isPlainObject(convertedBranches[0].schema)) {
1104
+ for (const [k, v] of Object.entries(convertedBranches[0].schema)) {
1105
+ if (out[k] === void 0) out[k] = v;
1106
+ }
1107
+ } else if (convertedBranches.length > 0) {
1108
+ out.anyOf = convertedBranches.map((b) => b.schema);
1109
+ }
1110
+ }
1111
+ } else if (SCHEMA_KEYS.includes(key) && isPlainObject(value)) {
1112
+ out[key] = convertNode(value, name, ctx).schema;
1113
+ } else {
1114
+ out[key] = value;
1115
+ }
1116
+ }
1117
+ const rawType = node.type;
1118
+ if (Array.isArray(rawType)) {
1119
+ const nonNull = rawType.filter((t) => t !== "null");
1120
+ if (nonNull.length !== rawType.length) nullable = true;
1121
+ if (nonNull.length > 1) {
1122
+ out.anyOf = nonNull.map((t) => ({ type: t }));
1123
+ } else {
1124
+ out.type = nonNull[0] ?? "string";
1125
+ }
1126
+ } else if (rawType !== void 0) {
1127
+ out.type = rawType;
1128
+ }
1129
+ const rawEnum = node.enum;
1130
+ if (Array.isArray(rawEnum) && rawEnum.length > 0) {
1131
+ const members = rawEnum.filter((v) => v !== null);
1132
+ if (members.length !== rawEnum.length) nullable = true;
1133
+ if (members.length > 0) {
1134
+ delete out.type;
1135
+ if (members.every((v) => typeof v === "string")) {
1136
+ out.anyOf = members.map((value) => ({ type: "string", const: value }));
1137
+ } else {
1138
+ const types = [...new Set(members.map(memberType))];
1139
+ if (types.length === 1) out.type = types[0];
1140
+ else out.anyOf = types.map((t) => ({ type: t }));
1141
+ }
1142
+ }
1143
+ }
1144
+ const merged = converted.size === 0 && isPlainObject(out.properties);
1145
+ const isObject2 = !merged && (out.type === "object" || converted.size > 0);
1146
+ if (isObject2) {
1147
+ const props = {};
1148
+ for (const [key, child] of converted) props[key] = child.schema;
1149
+ out.properties = props;
1150
+ const originalRequired = Array.isArray(node.required) ? node.required : [];
1151
+ out.required = originalRequired.filter((key) => !converted.get(key)?.nullable);
1152
+ const keys = Object.keys(props);
1153
+ const declaredOrder = Array.isArray(node["x-order"]) ? node["x-order"].filter((k) => typeof k === "string" && keys.includes(k)) : [];
1154
+ out["x-order"] = [...declaredOrder, ...keys.filter((k) => !declaredOrder.includes(k))];
1155
+ if (typeof out.title === "string") ctx.used.add(out.title);
1156
+ else out.title = titleFrom(name, ctx);
1157
+ if (out.additionalProperties === void 0) out.additionalProperties = false;
1158
+ } else if (Array.isArray(node.required)) {
1159
+ out.required = node.required;
1160
+ }
1161
+ if ((Array.isArray(out.anyOf) || Array.isArray(out.allOf)) && typeof out.title !== "string") {
1162
+ out.title = titleFrom(name, ctx);
1163
+ }
1164
+ if (out.type === "string" && out.anyOf === void 0 && out.allOf === void 0)
1165
+ delete out.title;
1166
+ delete out.pattern;
1167
+ return { schema: out, nullable };
1168
+ }
1169
+ function reserveDefTitles(schema, ctx) {
1170
+ for (const key of ["$defs", "definitions"]) {
1171
+ const defs = schema[key];
1172
+ if (!isPlainObject(defs)) continue;
1173
+ for (const [name, value] of Object.entries(defs)) {
1174
+ const declared = isPlainObject(value) && typeof value.title === "string" ? value.title : null;
1175
+ const title = declared ?? sanitize(name, ctx.counter);
1176
+ ctx.defTitles.set(name, title);
1177
+ ctx.used.add(title);
1178
+ }
1179
+ }
1180
+ }
1181
+ function toAppleSchema(schema, rootName = "Response") {
1182
+ const ctx = { counter: { n: 0 }, defTitles: /* @__PURE__ */ new Map(), used: /* @__PURE__ */ new Set() };
1183
+ reserveDefTitles(schema, ctx);
1184
+ const { $schema: _draft, ...rest } = schema;
1185
+ return convertNode(rest, rootName, ctx).schema;
1186
+ }
1187
+
1188
+ // src/standard-schema.ts
1189
+ function isStandardSchema(value) {
1190
+ if (value === null || typeof value !== "object" && typeof value !== "function") return false;
1191
+ const props = value["~standard"];
1192
+ return props !== null && typeof props === "object" && typeof props.validate === "function";
1193
+ }
1194
+ function resolveSchema(schema) {
1195
+ if (!isStandardSchema(schema)) return { json: schema };
1196
+ const props = schema["~standard"];
1197
+ const describe = props.jsonSchema;
1198
+ if (describe === void 0 || typeof describe.input !== "function") {
1199
+ throw new UnsupportedError(
1200
+ `This ${props.vendor} schema cannot describe itself as JSON Schema, which Apple's decoder needs.
1201
+ ` + (props.vendor === "valibot" ? "Wrap it: `toStandardJsonSchema(schema)` from @valibot/to-json-schema." : "Use a version that implements Standard JSON Schema (zod >= 4.2, arktype >= 2.1), or pass a JSON Schema.")
1202
+ );
1203
+ }
1204
+ let json;
1205
+ try {
1206
+ json = describe.input({ target: "draft-2020-12" });
1207
+ } catch {
1208
+ json = describe.input({ target: "draft-07" });
1209
+ }
1210
+ return { json, validate: async (value) => props.validate(value) };
1211
+ }
1212
+ function formatPath(path4) {
1213
+ if (path4 === void 0 || path4.length === 0) return "";
1214
+ return path4.map((segment) => typeof segment === "object" && segment !== null ? segment.key : segment).map((key) => String(key)).join(".");
1215
+ }
1216
+ function describeIssues(issues) {
1217
+ return issues.slice(0, 8).map((issue) => {
1218
+ const at = formatPath(issue.path);
1219
+ return at === "" ? issue.message : `${at}: ${issue.message}`;
1220
+ }).join("; ");
1221
+ }
1222
+ async function validateWith(resolved, value, text, tier) {
1223
+ if (resolved.validate === void 0) {
1224
+ const issues = checkPatterns(value, resolved.json);
1225
+ if (issues.length === 0) return value;
1226
+ throw new SchemaValidationError(
1227
+ `The model's reply did not satisfy the schema: ${describeIssues(issues)}`,
1228
+ issues.map((issue) => ({ message: issue.message, path: issue.path })),
1229
+ text,
1230
+ tier
1231
+ );
1232
+ }
1233
+ const result = await resolved.validate(value);
1234
+ if (result.issues !== void 0) {
1235
+ throw new SchemaValidationError(
1236
+ `The model's reply did not satisfy the schema: ${describeIssues(result.issues)}`,
1237
+ result.issues.map((issue) => ({ message: issue.message, path: issue.path })),
1238
+ text,
1239
+ tier
1240
+ );
1241
+ }
1242
+ return result.value;
1243
+ }
1244
+ function isObject(value) {
1245
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1246
+ }
1247
+ function allowsNull(schema) {
1248
+ if (!isObject(schema)) return false;
1249
+ if (schema.type === "null" || schema.nullable === true) return true;
1250
+ if (Array.isArray(schema.type) && schema.type.includes("null")) return true;
1251
+ if (Array.isArray(schema.enum) && schema.enum.includes(null)) return true;
1252
+ for (const key of ["anyOf", "oneOf"]) {
1253
+ const branches = schema[key];
1254
+ if (Array.isArray(branches) && branches.some(allowsNull)) return true;
1255
+ }
1256
+ return false;
1257
+ }
1258
+ function restoreNulls(value, schema, root = schema, depth = 0) {
1259
+ if (depth > 32 || !isObject(schema)) return value;
1260
+ const ref = schema.$ref;
1261
+ if (typeof ref === "string") {
1262
+ const target = resolveRef(ref, root);
1263
+ return target === void 0 ? value : restoreNulls(value, target, root, depth + 1);
1264
+ }
1265
+ for (const key of ["anyOf", "oneOf"]) {
1266
+ const branches = schema[key];
1267
+ if (Array.isArray(branches)) {
1268
+ const nonNull = branches.filter((b) => !(isObject(b) && b.type === "null"));
1269
+ if (nonNull.length === 1 && isObject(nonNull[0])) {
1270
+ return restoreNulls(value, nonNull[0], root, depth + 1);
1271
+ }
1272
+ return value;
1273
+ }
1274
+ }
1275
+ if (Array.isArray(schema.allOf) && schema.allOf.length === 1 && isObject(schema.allOf[0])) {
1276
+ return restoreNulls(value, schema.allOf[0], root, depth + 1);
1277
+ }
1278
+ if (Array.isArray(value)) {
1279
+ return isObject(schema.items) ? value.map((item) => restoreNulls(item, schema.items, root, depth + 1)) : value;
1280
+ }
1281
+ if (!isObject(value) || !isObject(schema.properties)) return value;
1282
+ const properties = schema.properties;
1283
+ const required = Array.isArray(schema.required) ? schema.required : [];
1284
+ const out = { ...value };
1285
+ for (const [key, child] of Object.entries(properties)) {
1286
+ if (out[key] === void 0) {
1287
+ if (required.includes(key) && allowsNull(child)) out[key] = null;
1288
+ continue;
1289
+ }
1290
+ if (isObject(child)) out[key] = restoreNulls(out[key], child, root, depth + 1);
1291
+ }
1292
+ return out;
1293
+ }
1294
+ function resolveRef(ref, root) {
1295
+ const match = /^#\/(\$defs|definitions)\/(.+)$/.exec(ref);
1296
+ if (match === null) return ref === "#" ? root : void 0;
1297
+ const defs = root[match[1]];
1298
+ const target = isObject(defs) ? defs[decodeURIComponent(match[2])] : void 0;
1299
+ return isObject(target) ? target : void 0;
1300
+ }
1301
+ function checkPatterns(value, schema, root = schema, path4 = [], depth = 0) {
1302
+ if (depth > 32 || !isObject(schema)) return [];
1303
+ if (typeof schema.$ref === "string") {
1304
+ const target = resolveRef(schema.$ref, root);
1305
+ return target === void 0 ? [] : checkPatterns(value, target, root, path4, depth + 1);
1306
+ }
1307
+ if (Array.isArray(schema.allOf) && schema.allOf.length === 1 && isObject(schema.allOf[0])) {
1308
+ return checkPatterns(value, schema.allOf[0], root, path4, depth + 1);
1309
+ }
1310
+ for (const key of ["anyOf", "oneOf"]) {
1311
+ const branches = schema[key];
1312
+ if (!Array.isArray(branches)) continue;
1313
+ const nonNull = branches.filter((b) => !(isObject(b) && b.type === "null"));
1314
+ return nonNull.length === 1 && isObject(nonNull[0]) && value !== null ? checkPatterns(value, nonNull[0], root, path4, depth + 1) : [];
1315
+ }
1316
+ if (typeof value === "string" && typeof schema.pattern === "string") {
1317
+ let regex;
1318
+ try {
1319
+ regex = new RegExp(schema.pattern, "u");
1320
+ } catch {
1321
+ regex = void 0;
1322
+ }
1323
+ return regex === void 0 || regex.test(value) ? [] : [{ message: `must match the pattern ${schema.pattern}`, path: path4 }];
1324
+ }
1325
+ if (Array.isArray(value) && isObject(schema.items)) {
1326
+ return value.flatMap((item, i) => checkPatterns(item, schema.items, root, [...path4, i], depth + 1));
1327
+ }
1328
+ if (isObject(value) && isObject(schema.properties)) {
1329
+ return Object.entries(schema.properties).flatMap(
1330
+ ([key, child]) => value[key] === void 0 || !isObject(child) ? [] : checkPatterns(value[key], child, root, [...path4, key], depth + 1)
1331
+ );
1332
+ }
1333
+ return [];
1334
+ }
1335
+
1336
+ // src/tools.ts
1337
+ var BUILT_IN_TOOLS = /* @__PURE__ */ new Set(["ocr", "barcode", "spotlight"]);
1338
+ function tool(definition) {
1339
+ return definition;
1340
+ }
1341
+ var TOOL_NAME = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
1342
+ var EMPTY_PARAMETERS = { type: "object", properties: {}, required: [] };
1343
+ function normalizeTools(tools) {
1344
+ const out = { builtIn: [], functions: [] };
1345
+ if (tools === void 0) return out;
1346
+ const entries = Array.isArray(tools) ? [...tools] : Object.entries(tools).map(([name, def]) => ({ ...def, name: def.name ?? name }));
1347
+ const seen = /* @__PURE__ */ new Set();
1348
+ for (const entry of entries) {
1349
+ if (typeof entry === "string") {
1350
+ if (!BUILT_IN_TOOLS.has(entry)) {
1351
+ throw new AppleLLMError(`Unknown tool "${entry}" (want ${[...BUILT_IN_TOOLS].join(", ")}).`, "device");
1352
+ }
1353
+ if (!out.builtIn.includes(entry)) out.builtIn.push(entry);
1354
+ continue;
1355
+ }
1356
+ if (entry === null || typeof entry !== "object" || typeof entry.name !== "string") {
1357
+ throw new AppleLLMError("A function tool needs a name.", "device");
1358
+ }
1359
+ if (!TOOL_NAME.test(entry.name)) {
1360
+ throw new AppleLLMError(
1361
+ `Tool name "${entry.name}" is invalid: use letters, digits, "_" or "-", starting with a letter or "_".`,
1362
+ "device"
1363
+ );
1364
+ }
1365
+ if (seen.has(entry.name) || BUILT_IN_TOOLS.has(entry.name)) {
1366
+ throw new AppleLLMError(`Tool name "${entry.name}" is used twice.`, "device");
1367
+ }
1368
+ seen.add(entry.name);
1369
+ const resolved = entry.parameters === void 0 ? void 0 : resolveSchema(entry.parameters);
1370
+ out.functions.push({ ...entry, resolved, json: resolved?.json ?? EMPTY_PARAMETERS });
1371
+ }
1372
+ return out;
1373
+ }
1374
+ function toolOutputText(value) {
1375
+ if (typeof value === "string") return value;
1376
+ if (value === void 0) return "";
1377
+ try {
1378
+ return JSON.stringify(value);
1379
+ } catch {
1380
+ return String(value);
1381
+ }
1382
+ }
1383
+
1384
+ // src/device.ts
1385
+ var execFileAsync4 = promisify4(execFile4);
1386
+ var DEFAULT_TEMPERATURE = 0.4;
1387
+ var DEFAULT_MAX_TOKENS = 2048;
1388
+ var DEFAULT_MAX_TOOL_CALLS = 8;
1389
+ var CALL_TIMEOUT_MS2 = 12e4;
1390
+ var BUSY_RETRY_DELAYS_MS = [250, 1e3, 2500];
1391
+ function assertTools(tools) {
1392
+ if (tools === void 0) return;
1393
+ for (const tool2 of tools) {
1394
+ if (!BUILT_IN_TOOLS.has(tool2)) {
1395
+ throw new AppleLLMError(
1396
+ `Unknown tool "${tool2}" (want ${[...BUILT_IN_TOOLS].join(", ")}).`,
1397
+ "device"
1398
+ );
1399
+ }
1400
+ }
1401
+ }
1402
+ async function probeDevice(onProgress) {
1403
+ if (process.platform !== "darwin") {
1404
+ return { available: false, reason: `unsupportedPlatform: this machine reports "${process.platform}"` };
1405
+ }
1406
+ if (!isAppleSiliconMac()) {
1407
+ return { available: false, reason: "deviceNotEligible: Apple Intelligence needs Apple Silicon" };
1408
+ }
1409
+ const major = await macosMajor();
1410
+ if (major !== null && major < 26) {
1411
+ return { available: false, reason: `unsupportedOSVersion: needs macOS 26 or later, this is ${major}` };
1412
+ }
1413
+ let binary;
1414
+ try {
1415
+ binary = await ensureBinary(onProgress);
1416
+ } catch (err) {
1417
+ return { available: false, reason: err instanceof Error ? err.message : String(err) };
1418
+ }
1419
+ try {
1420
+ const { stdout } = await execFileAsync4(binary, ["--probe"], { timeout: 2e4 });
1421
+ return JSON.parse(stdout.trim());
1422
+ } catch (err) {
1423
+ const reason = err instanceof Error ? err.message : String(err);
1424
+ return { available: false, reason: `the helper could not be run: ${reason.slice(0, 300)}` };
1425
+ }
1426
+ }
1427
+ function raiseFor(response, raw, toolFailure) {
1428
+ const detail = response.error ?? raw.slice(0, 200);
1429
+ switch (response.kind) {
1430
+ case "availability":
1431
+ throw new ModelUnavailableError(
1432
+ unavailableMessage(detail),
1433
+ toUnavailableReason(detail),
1434
+ "device"
1435
+ );
1436
+ case "schema":
1437
+ throw new SchemaRejectedError(
1438
+ `Apple rejected the response schema: ${detail}
1439
+ Every object needs a title, an x-order and additionalProperties; enums must be anyOf/const; unions cannot include null.`,
1440
+ "device"
1441
+ );
1442
+ case "context":
1443
+ throw new ContextLengthError(
1444
+ `The prompt exceeded the on-device context window: ${detail}`,
1445
+ "device",
1446
+ response.contextSize,
1447
+ response.tokenCount
1448
+ );
1449
+ case "quota": {
1450
+ const parsed = response.resetDate === void 0 ? void 0 : new Date(response.resetDate);
1451
+ const resetDate = parsed !== void 0 && !Number.isNaN(parsed.getTime()) ? parsed : void 0;
1452
+ throw new QuotaError(
1453
+ `Apple rate limited the on-device model: ${detail}`,
1454
+ "device",
1455
+ resetDate
1456
+ );
1457
+ }
1458
+ case "guardrail":
1459
+ throw new RefusalError(`The on-device model declined to answer: ${detail}`, "device");
1460
+ case "timeout":
1461
+ throw new TimeoutError(`Apple on-device generation timed out: ${detail}`, "device");
1462
+ case "cancelled":
1463
+ throw new AbortError("The request was cancelled.", "device");
1464
+ case "tool": {
1465
+ const name = toolFailure?.name ?? response.tool ?? "unknown";
1466
+ const cause = toolFailure?.error;
1467
+ const why = cause instanceof Error ? cause.message : cause === void 0 ? detail : String(cause);
1468
+ throw new ToolExecutionError(`Tool "${name}" failed: ${why}`, name, { cause });
1469
+ }
1470
+ case "unsupported":
1471
+ throw new UnsupportedError(`Apple on-device generation failed: ${detail}`, "device");
1472
+ case "busy":
1473
+ throw new ModelBusyError(
1474
+ "Apple\u2019s on-device model is busy serving other processes and turned the request away (ModelManagerError 1042), even after retrying. Try again shortly.",
1475
+ "device"
1476
+ );
1477
+ default:
1478
+ throw new AppleLLMError(`Apple on-device generation failed: ${detail}`, "device");
1479
+ }
1480
+ }
1481
+ function usageFrom(raw) {
1482
+ if (raw === void 0 || typeof raw.inputTokens !== "number" || typeof raw.outputTokens !== "number") {
1483
+ return void 0;
1484
+ }
1485
+ return {
1486
+ inputTokens: raw.inputTokens,
1487
+ outputTokens: raw.outputTokens,
1488
+ totalTokens: raw.inputTokens + raw.outputTokens,
1489
+ ...typeof raw.cachedInputTokens === "number" ? { cachedInputTokens: raw.cachedInputTokens } : {}
1490
+ };
1491
+ }
1492
+ function finishFrom(raw) {
1493
+ if (raw === "length") return "length";
1494
+ if (raw === "toolCalls") return "tool-calls";
1495
+ return "stop";
1496
+ }
1497
+ var DeviceClient = class {
1498
+ constructor(options = {}) {
1499
+ this.options = options;
1500
+ this.server = options.server;
1501
+ }
1502
+ options;
1503
+ binary;
1504
+ probeResult;
1505
+ server;
1506
+ readying;
1507
+ get label() {
1508
+ const variant = this.probeResult?.variant;
1509
+ return variant ? `apple on-device (${variant})` : "apple on-device";
1510
+ }
1511
+ get contextSize() {
1512
+ return this.probeResult?.contextSize;
1513
+ }
1514
+ /** Probe and build once; concurrent first calls share the one attempt. */
1515
+ async ensureReady(onProgress) {
1516
+ if (this.probeResult?.available === true && this.binary !== void 0) return;
1517
+ this.readying ??= (async () => {
1518
+ const probe2 = await probeDevice(onProgress);
1519
+ this.probeResult = probe2;
1520
+ if (!probe2.available) {
1521
+ throw new ModelUnavailableError(
1522
+ unavailableMessage(probe2.reason),
1523
+ toUnavailableReason(probe2.reason),
1524
+ "device"
1525
+ );
1526
+ }
1527
+ this.binary = await ensureBinary(onProgress);
1528
+ onProgress?.({
1529
+ status: `apple on-device model ready${probe2.variant ? ` (${probe2.variant}, ${probe2.contextSize} token context)` : ""}`
1530
+ });
1531
+ })().finally(() => {
1532
+ this.readying = void 0;
1533
+ });
1534
+ return this.readying;
1535
+ }
1536
+ getProbe() {
1537
+ return this.probeResult;
1538
+ }
1539
+ async helper() {
1540
+ if (this.options.server !== void 0) return this.options.server;
1541
+ await this.ensureReady();
1542
+ const binary = this.binary ?? await ensureBinary();
1543
+ this.server ??= new HelperServer(binary, { timeoutMs: CALL_TIMEOUT_MS2 });
1544
+ return this.server;
1545
+ }
1546
+ /** Send one single-line envelope and return the parsed reply, raising a typed error on failure. */
1547
+ async exchange(envelope, signal) {
1548
+ const server = await this.helper();
1549
+ const id = randomUUID();
1550
+ const raw = await server.request(JSON.stringify({ ...envelope, id }), { signal, id });
1551
+ const response = parse(raw);
1552
+ if (response.ok !== true) raiseFor(response, raw);
1553
+ return response;
1554
+ }
1555
+ /** The request envelope, shared by every generation path. */
1556
+ async envelopeFor(request, op) {
1557
+ assertTools(request.tools);
1558
+ return {
1559
+ op,
1560
+ instructions: request.system ?? "",
1561
+ prompt: await withDocuments(request.prompt, request.documents),
1562
+ schema: request.schema ?? null,
1563
+ temperature: request.temperature ?? DEFAULT_TEMPERATURE,
1564
+ maxTokens: request.maxTokens ?? DEFAULT_MAX_TOKENS,
1565
+ includeSchemaInPrompt: request.includeSchemaInPrompt,
1566
+ reuseSession: request.reuseSession ?? false,
1567
+ sessionId: request.sessionId,
1568
+ history: request.history !== void 0 && request.history.length > 0 ? request.history : void 0,
1569
+ trimHistory: request.trimHistory,
1570
+ tools: request.tools !== void 0 && request.tools.length > 0 ? request.tools : void 0,
1571
+ functions: request.functions !== void 0 && request.functions.length > 0 ? request.functions.map((fn) => ({
1572
+ name: fn.name,
1573
+ description: fn.description ?? "",
1574
+ parameters: toAppleSchema(fn.json, `${fn.name}Arguments`)
1575
+ })) : void 0,
1576
+ images: request.images,
1577
+ useCase: request.useCase ?? this.options.useCase,
1578
+ guardrails: request.guardrails ?? this.options.guardrails,
1579
+ sampling: request.sampling
1580
+ };
1581
+ }
1582
+ /**
1583
+ * One generation, with every event handled: text deltas, partial objects,
1584
+ * and function tool calls, which run here and answer the helper.
1585
+ */
1586
+ async run(request, op = "generate", hooks = {}) {
1587
+ const envelope = await this.envelopeFor(request, op);
1588
+ const server = await this.helper();
1589
+ const functions = new Map((request.functions ?? []).map((fn) => [fn.name, fn]));
1590
+ const maxToolCalls = hooks.maxToolCalls ?? DEFAULT_MAX_TOOL_CALLS;
1591
+ const calls = [];
1592
+ let toolFailure;
1593
+ const answerToolCall = async (call, write) => {
1594
+ const reply = (fields) => write({ op: "toolResult", callId: call.id, ...fields });
1595
+ const fn = functions.get(call.name);
1596
+ if (fn === void 0) {
1597
+ reply({ output: `There is no tool named "${call.name}".`, isError: true });
1598
+ return;
1599
+ }
1600
+ if (calls.length > maxToolCalls) {
1601
+ call.output = "Tool call limit reached. Answer with the information you already have.";
1602
+ reply({ output: call.output });
1603
+ return;
1604
+ }
1605
+ const replayed = hooks.replay?.take(call.name, call.arguments);
1606
+ if (replayed !== void 0) {
1607
+ call.output = replayed;
1608
+ reply({ output: replayed });
1609
+ return;
1610
+ }
1611
+ if (fn.execute === void 0) {
1612
+ reply({ stop: true });
1613
+ return;
1614
+ }
1615
+ let args = restoreNulls(call.arguments, fn.json);
1616
+ if (fn.resolved?.validate !== void 0) {
1617
+ const checked2 = await fn.resolved.validate(args);
1618
+ if (checked2.issues !== void 0) {
1619
+ call.output = `Invalid arguments: ${describeIssues(checked2.issues)}`;
1620
+ reply({ output: call.output });
1621
+ return;
1622
+ }
1623
+ args = checked2.value;
1624
+ }
1625
+ server.pause();
1626
+ try {
1627
+ const result = await fn.execute(args, { toolCallId: call.id, signal: hooks.signal });
1628
+ call.output = toolOutputText(result);
1629
+ reply({ output: call.output });
1630
+ } catch (error) {
1631
+ toolFailure = { name: call.name, error };
1632
+ reply({ output: error instanceof Error ? error.message : String(error), isError: true });
1633
+ } finally {
1634
+ server.resume();
1635
+ }
1636
+ };
1637
+ let delivered = false;
1638
+ const attempt = (id = randomUUID()) => server.request(JSON.stringify({ ...envelope, id }), {
1639
+ id,
1640
+ signal: hooks.signal,
1641
+ onEvent: (event, write) => {
1642
+ delivered = true;
1643
+ if (typeof event.delta === "string") {
1644
+ if (event.delta !== "") hooks.onDelta?.(event.delta);
1645
+ } else if (typeof event.partial === "string") {
1646
+ hooks.onPartial?.(event.partial);
1647
+ } else if (event.toolCall !== null && typeof event.toolCall === "object") {
1648
+ const toolCall = event.toolCall;
1649
+ const call = {
1650
+ id: String(toolCall.id ?? randomUUID()),
1651
+ name: String(toolCall.name ?? ""),
1652
+ arguments: toolCall.arguments ?? {}
1653
+ };
1654
+ calls.push(call);
1655
+ try {
1656
+ hooks.onToolCall?.(call);
1657
+ } catch {
1658
+ }
1659
+ void answerToolCall(call, write).catch((error) => {
1660
+ toolFailure ??= { name: call.name, error };
1661
+ write({ op: "toolResult", callId: call.id, output: String(error), isError: true });
1662
+ });
1663
+ }
1664
+ }
1665
+ });
1666
+ let raw = await attempt();
1667
+ let response = parse(raw);
1668
+ for (const delay of BUSY_RETRY_DELAYS_MS) {
1669
+ if (response.ok === true || response.kind !== "busy" || delivered) break;
1670
+ await sleep(delay, hooks.signal);
1671
+ raw = await attempt();
1672
+ response = parse(raw);
1673
+ }
1674
+ if (response.ok !== true) raiseFor(response, raw, toolFailure);
1675
+ if (typeof response.content !== "string") {
1676
+ throw new AppleLLMError("Apple helper returned no content.", "device");
1677
+ }
1678
+ const builtIn = (response.toolCalls ?? []).filter((c) => typeof c.name === "string" && !functions.has(c.name)).map((c) => ({
1679
+ id: String(c.id ?? ""),
1680
+ name: String(c.name),
1681
+ arguments: c.arguments ?? {},
1682
+ ...typeof c.output === "string" ? { output: c.output } : {}
1683
+ }));
1684
+ return {
1685
+ content: response.content,
1686
+ finishReason: finishFrom(response.finishReason),
1687
+ usage: usageFrom(response.usage),
1688
+ toolCalls: [...calls, ...builtIn],
1689
+ trimmedTurns: typeof response.trimmedTurns === "number" ? response.trimmedTurns : 0
1690
+ };
1691
+ }
1692
+ /** One request. Returns the helper's `content` string, unparsed. */
1693
+ async complete(request, hooks = {}) {
1694
+ return (await this.run(request, "generate", hooks)).content;
1695
+ }
1696
+ /**
1697
+ * Streaming text. Deltas arrive via onDelta as the model generates; the
1698
+ * promise resolves with the full text.
1699
+ */
1700
+ async stream(prompt, options = {}) {
1701
+ const { onDelta, signal, ...rest } = options;
1702
+ return (await this.run({ ...rest, prompt, schema: null }, "stream", { onDelta, signal })).content;
1703
+ }
1704
+ /**
1705
+ * Conversation history for a named session: the mirrored turns the helper
1706
+ * persisted, oldest first. Survives helper restarts, and the next call
1707
+ * rebuilds the native transcript from it.
1708
+ */
1709
+ async history(sessionId) {
1710
+ const response = await this.exchange({ op: "history", sessionId, instructions: "" });
1711
+ return {
1712
+ instructions: typeof response.instructions === "string" ? response.instructions : "",
1713
+ history: Array.isArray(response.history) ? response.history : []
1714
+ };
1715
+ }
1716
+ /** Drop a named session (and its persisted history), or all of them. */
1717
+ async resetSession(sessionId) {
1718
+ await this.exchange({ op: "reset", sessionId: sessionId ?? "", instructions: "" });
1719
+ }
1720
+ /**
1721
+ * How many tokens a request costs, before sending it.
1722
+ *
1723
+ * The point is to turn a ContextLengthError into an arithmetic check: compare
1724
+ * against `contextSize` and trim, rather than discovering the ceiling by
1725
+ * hitting it. Counts everything that shares the window: instructions,
1726
+ * history, tools, schema, images and inlined documents.
1727
+ */
1728
+ async countTokens(prompt, options = {}) {
1729
+ const envelope = await this.envelopeFor(
1730
+ {
1731
+ prompt,
1732
+ system: options.system,
1733
+ images: options.images,
1734
+ tools: options.tools,
1735
+ documents: options.documents,
1736
+ history: options.history,
1737
+ schema: options.schema === void 0 ? null : toAppleSchema(options.schema),
1738
+ functions: options.functions
1739
+ },
1740
+ "generate"
1741
+ );
1742
+ const response = await this.exchange({ ...envelope, op: "countTokens" }, options.signal);
1743
+ if (typeof response.tokens !== "number" || typeof response.contextSize !== "number") {
1744
+ throw new AppleLLMError("Apple helper returned an unreadable token count.", "device");
1745
+ }
1746
+ return { tokens: response.tokens, contextSize: response.contextSize };
1747
+ }
1748
+ /**
1749
+ * Load the model assets now so the first real call does not pay for it.
1750
+ *
1751
+ * Cheap and idempotent, but do not expect much on a warm machine: with the
1752
+ * assets already resident this measured 0.31s against 0.36s for an
1753
+ * unprewarmed first call — inside the noise. The win is on a genuinely cold
1754
+ * system, where the very first call to the framework here took 7.8s. Worth
1755
+ * calling at startup when you know a request is coming; not worth building
1756
+ * around.
1757
+ */
1758
+ async prewarm(system) {
1759
+ await this.exchange({ op: "prewarm", instructions: system ?? "" });
1760
+ }
1761
+ async text(prompt, options = {}) {
1762
+ return this.complete({ ...options, prompt, schema: null });
1763
+ }
1764
+ async json(prompt, options) {
1765
+ const content = await this.complete({ ...options, prompt, schema: toAppleSchema(options.schema) });
1766
+ try {
1767
+ return restoreNulls(JSON.parse(content), options.schema);
1768
+ } catch {
1769
+ throw new AppleLLMError(
1770
+ `Apple on-device model returned invalid JSON: ${content.slice(0, 200)}`,
1771
+ "device"
1772
+ );
1773
+ }
1774
+ }
1775
+ /** Shut the helper process down. Safe to call more than once. */
1776
+ close() {
1777
+ this.server?.stop();
1778
+ this.server = void 0;
1779
+ }
1780
+ };
1781
+ function sleep(ms, signal) {
1782
+ return new Promise((resolve, reject) => {
1783
+ if (signal?.aborted === true) {
1784
+ reject(abortErrorFrom(signal));
1785
+ return;
1786
+ }
1787
+ const timer = setTimeout(() => {
1788
+ signal?.removeEventListener("abort", onAbort);
1789
+ resolve();
1790
+ }, ms);
1791
+ const onAbort = () => {
1792
+ clearTimeout(timer);
1793
+ reject(abortErrorFrom(signal));
1794
+ };
1795
+ signal?.addEventListener("abort", onAbort, { once: true });
1796
+ });
1797
+ }
1798
+ function parse(raw) {
1799
+ try {
1800
+ return JSON.parse(raw);
1801
+ } catch {
1802
+ throw new AppleLLMError(`Apple helper returned an unreadable envelope: ${raw.slice(0, 200)}`, "device");
1803
+ }
1804
+ }
1805
+ async function withDocuments(prompt, documents) {
1806
+ if (documents === void 0 || documents.length === 0) return prompt;
1807
+ const { readFile: readFile4, stat: stat3 } = await import("fs/promises");
1808
+ const parts = [prompt];
1809
+ for (const doc of documents) {
1810
+ let size = 0;
1811
+ try {
1812
+ size = (await stat3(doc)).size;
1813
+ } catch {
1814
+ throw new AppleLLMError(`Document not found: ${doc}`);
1815
+ }
1816
+ if (size > 512e3) {
1817
+ throw new AppleLLMError(`Document too large to inline (>${512e3} bytes): ${doc}`);
1818
+ }
1819
+ let text;
1820
+ try {
1821
+ text = await readFile4(doc, "utf8");
1822
+ } catch {
1823
+ throw new AppleLLMError(`Document is not readable text: ${doc}`);
1824
+ }
1825
+ if (text.includes("\uFFFD")) {
1826
+ throw new AppleLLMError(`Document is not readable text: ${doc}`);
1827
+ }
1828
+ parts.push(`
1829
+
1830
+ --- Document: ${doc} ---
1831
+ ${text}`);
1832
+ }
1833
+ return parts.join("");
1834
+ }
1835
+ function parseImageFlag(value) {
1836
+ const sep = value.lastIndexOf("::");
1837
+ if (sep > 0) {
1838
+ const path4 = value.slice(0, sep);
1839
+ const label = value.slice(sep + 2).trim();
1840
+ if (path4 !== "" && label !== "") return { path: path4, label };
1841
+ }
1842
+ return value;
1843
+ }
1844
+
1845
+ // src/stream.ts
1846
+ var Channel = class {
1847
+ items = [];
1848
+ waiter;
1849
+ closed = false;
1850
+ push(value) {
1851
+ this.offer({ value });
1852
+ }
1853
+ end() {
1854
+ this.offer({ done: true });
1855
+ }
1856
+ fail(error) {
1857
+ this.offer({ error });
1858
+ }
1859
+ offer(item) {
1860
+ if (this.closed) return;
1861
+ if (!("value" in item)) this.closed = true;
1862
+ const waiter = this.waiter;
1863
+ if (waiter !== void 0) {
1864
+ this.waiter = void 0;
1865
+ waiter(item);
1866
+ } else {
1867
+ this.items.push(item);
1868
+ }
1869
+ }
1870
+ next() {
1871
+ const item = this.items.shift();
1872
+ if (item !== void 0) return Promise.resolve(item);
1873
+ return new Promise((resolve) => {
1874
+ this.waiter = resolve;
1875
+ });
1876
+ }
1877
+ };
1878
+ var ResultStream = class {
1879
+ /** Everything about the finished generation: text, usage, tool calls, finish reason. */
1880
+ result;
1881
+ channel = new Channel();
1882
+ controller = new AbortController();
1883
+ final;
1884
+ iterated = false;
1885
+ finished = false;
1886
+ constructor(run, pick) {
1887
+ this.result = run((chunk) => this.channel.push(chunk), this.controller.signal).then(
1888
+ (result) => {
1889
+ this.finished = true;
1890
+ this.channel.end();
1891
+ return result;
1892
+ },
1893
+ (error) => {
1894
+ this.finished = true;
1895
+ this.channel.fail(error);
1896
+ throw error;
1897
+ }
1898
+ );
1899
+ this.final = this.result.then(pick);
1900
+ this.result.catch(() => void 0);
1901
+ this.final.catch(() => void 0);
1902
+ }
1903
+ /** Stop generating. The promise and the iterator both reject with an AbortError. */
1904
+ abort(reason) {
1905
+ this.controller.abort(reason);
1906
+ }
1907
+ then(onfulfilled, onrejected) {
1908
+ return this.final.then(onfulfilled, onrejected);
1909
+ }
1910
+ catch(onrejected) {
1911
+ return this.final.catch(onrejected);
1912
+ }
1913
+ finally(onfinally) {
1914
+ return this.final.finally(onfinally);
1915
+ }
1916
+ [Symbol.asyncIterator]() {
1917
+ if (this.iterated) throw new Error("A stream can only be iterated once.");
1918
+ this.iterated = true;
1919
+ return {
1920
+ next: async () => {
1921
+ const item = await this.channel.next();
1922
+ if ("value" in item) return { value: item.value, done: false };
1923
+ if ("error" in item) throw item.error;
1924
+ return { value: void 0, done: true };
1925
+ },
1926
+ return: async () => {
1927
+ if (!this.finished) this.abort();
1928
+ return { value: void 0, done: true };
1929
+ }
1930
+ };
1931
+ }
1932
+ };
1933
+
1934
+ // src/client.ts
1935
+ async function probe(onProgress) {
1936
+ const [device, cloud] = await Promise.all([probeDevice(onProgress), probeCloud()]);
1937
+ if (device.cloud !== void 0) {
1938
+ const { capabilities, contextSize, ...quota } = device.cloud;
1939
+ cloud.quota = quota;
1940
+ if (capabilities !== void 0) cloud.capabilities = capabilities;
1941
+ if (contextSize !== void 0 && cloud.contextSize !== void 0) cloud.contextSize = contextSize;
1942
+ }
1943
+ return { device, cloud };
1944
+ }
1945
+ function anySignal(signals) {
1946
+ const list = signals.filter((s) => s !== void 0);
1947
+ if (list.length <= 1) return list[0];
1948
+ const any = AbortSignal.any;
1949
+ if (typeof any === "function") return any(list);
1950
+ const controller = new AbortController();
1951
+ for (const signal of list) {
1952
+ if (signal.aborted) {
1953
+ controller.abort(signal.reason);
1954
+ break;
1955
+ }
1956
+ signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
1957
+ }
1958
+ return controller.signal;
1959
+ }
1960
+ var AppleLLM = class {
1961
+ device;
1962
+ cloud;
1963
+ /** Which tier `auto` settled on, once resolved. */
1964
+ resolved;
1965
+ readying;
1966
+ options;
1967
+ constructor(options = {}) {
1968
+ this.options = options;
1969
+ }
1970
+ get tier() {
1971
+ return this.options.tier ?? "auto";
1972
+ }
1973
+ /** Human-readable name of the tier in use, for logs. */
1974
+ get label() {
1975
+ if (this.resolved === "cloud") return this.cloud?.label ?? "apple private cloud compute";
1976
+ if (this.resolved === "device") return this.device?.label ?? "apple on-device";
1977
+ return `apple (${this.tier})`;
1978
+ }
1979
+ /** Context window of the resolved tier, in tokens. */
1980
+ get contextSize() {
1981
+ if (this.resolved === "cloud") return this.cloud?.contextSize ?? CLOUD_CONTEXT_TOKENS;
1982
+ return this.device?.contextSize;
1983
+ }
1984
+ /**
1985
+ * Resolve the tier and do any one-time setup. Called automatically, but
1986
+ * exposed so a caller can pay the compile cost up front with a progress bar.
1987
+ * Concurrent first calls share one attempt.
1988
+ */
1989
+ async ensureReady(onProgress) {
1990
+ if (this.resolved !== void 0) return;
1991
+ this.readying ??= this.resolve(onProgress ?? this.options.onProgress).finally(() => {
1992
+ this.readying = void 0;
1993
+ });
1994
+ return this.readying;
1995
+ }
1996
+ newDevice() {
1997
+ return new DeviceClient({ useCase: this.options.useCase, guardrails: this.options.guardrails });
1998
+ }
1999
+ async readyCloud(progress) {
2000
+ this.cloud ??= new CloudClient();
2001
+ await this.cloud.ensureReady(progress);
2002
+ this.cloud.setQuota((await probeDevice().catch(() => void 0))?.cloud);
2003
+ this.resolved = "cloud";
2004
+ }
2005
+ async resolve(progress) {
2006
+ if (this.tier === "device") {
2007
+ this.device ??= this.newDevice();
2008
+ await this.device.ensureReady(progress);
2009
+ this.resolved = "device";
2010
+ return;
2011
+ }
2012
+ if (this.tier === "cloud") {
2013
+ await this.readyCloud(progress);
2014
+ return;
2015
+ }
2016
+ this.device ??= this.newDevice();
2017
+ try {
2018
+ await this.device.ensureReady(progress);
2019
+ this.resolved = "device";
2020
+ } catch (deviceError) {
2021
+ try {
2022
+ await this.readyCloud(progress);
2023
+ } catch (cloudError) {
2024
+ const deviceReason = deviceError instanceof Error ? deviceError.message : String(deviceError);
2025
+ const cloudReason = cloudError instanceof Error ? cloudError.message : String(cloudError);
2026
+ throw new ModelUnavailableError(
2027
+ `No Apple model is available.
2028
+
2029
+ On-device: ${deviceReason}
2030
+
2031
+ Cloud: ${cloudReason}`,
2032
+ deviceError instanceof ModelUnavailableError ? deviceError.reason : "unknown"
2033
+ );
2034
+ }
2035
+ }
2036
+ }
2037
+ /** Free text. */
2038
+ async text(input2, options = {}) {
2039
+ return (await this.execute(input2, options, {})).text;
2040
+ }
2041
+ async generate(input2, options = {}) {
2042
+ const { schema, ...rest } = options;
2043
+ return this.execute(input2, rest, { schema: schema === void 0 ? void 0 : resolveSchema(schema) });
2044
+ }
2045
+ /**
2046
+ * Ask for JSON. On device the *shape* is guaranteed by constrained decoding;
2047
+ * a Standard Schema's own validation (refinements, transforms) then runs on
2048
+ * top, and the result is typed by it. On cloud the shape is requested in the
2049
+ * prompt and recovered from the reply. Either way, a reply that fails
2050
+ * validation is retried once with the problems pointed out, then raised as a
2051
+ * SchemaValidationError.
2052
+ */
2053
+ async json(input2, options) {
2054
+ const { schema, ...rest } = options;
2055
+ const result = await this.execute(input2, rest, { schema: resolveSchema(schema) });
2056
+ return result.object;
2057
+ }
2058
+ /**
2059
+ * Streaming text. Iterate it for deltas, or await it for the whole reply:
2060
+ *
2061
+ * for await (const delta of llm.stream(prompt)) process.stdout.write(delta);
2062
+ * const text = await llm.stream(prompt, { onDelta });
2063
+ *
2064
+ * On the cloud tier the reply arrives as one chunk: Shortcuts is not
2065
+ * incremental.
2066
+ */
2067
+ stream(input2, options = {}) {
2068
+ const { onDelta, ...rest } = options;
2069
+ return new ResultStream(
2070
+ (emit, signal) => this.execute(input2, rest, {
2071
+ streaming: true,
2072
+ signal,
2073
+ onDelta: (delta) => {
2074
+ onDelta?.(delta);
2075
+ emit(delta);
2076
+ }
2077
+ }),
2078
+ (result) => result.text
2079
+ );
2080
+ }
2081
+ /**
2082
+ * Streaming JSON: partial objects as the model fills them in — render a form
2083
+ * or a card while it is still being written — and the final object, parsed
2084
+ * and validated, as the awaited value.
2085
+ *
2086
+ * for await (const partial of llm.streamJson(prompt, { schema })) render(partial);
2087
+ */
2088
+ streamJson(input2, options) {
2089
+ const { schema, ...rest } = options;
2090
+ const resolved = resolveSchema(schema);
2091
+ return new ResultStream(
2092
+ (emit, signal) => this.execute(input2, rest, {
2093
+ schema: resolved,
2094
+ streaming: true,
2095
+ signal,
2096
+ onPartial: (json) => {
2097
+ try {
2098
+ emit(JSON.parse(json));
2099
+ } catch {
2100
+ }
2101
+ }
2102
+ }),
2103
+ (result) => result.object
2104
+ );
2105
+ }
2106
+ /** The one path every generation takes. */
2107
+ async execute(input2, options, mode) {
2108
+ const started = Date.now();
2109
+ await this.ensureReady();
2110
+ let prompt;
2111
+ let history = [];
2112
+ let replay;
2113
+ let system = options.system ?? this.options.system;
2114
+ let trimHistory;
2115
+ let images = options.images;
2116
+ if (typeof input2 === "string") {
2117
+ prompt = input2;
2118
+ } else {
2119
+ if (options.sessionId !== void 0) {
2120
+ throw new AppleLLMError(
2121
+ "Pass either a message list or a sessionId, not both: a named session keeps its own history."
2122
+ );
2123
+ }
2124
+ const split = splitMessages(input2);
2125
+ prompt = split.prompt;
2126
+ history = split.history;
2127
+ replay = split.replay.length > 0 ? new ReplayBook(split.replay) : void 0;
2128
+ if (split.system !== void 0) system = system === void 0 ? split.system : `${system}
2129
+
2130
+ ${split.system}`;
2131
+ trimHistory = options.trimHistory ?? true;
2132
+ if (split.images.length > 0) images = [...images ?? [], ...split.images];
2133
+ }
2134
+ const tools = normalizeTools(options.tools);
2135
+ const timeoutMs = options.timeoutMs ?? this.options.timeoutMs;
2136
+ const signal = anySignal([
2137
+ options.signal,
2138
+ mode.signal,
2139
+ timeoutMs === void 0 ? void 0 : AbortSignal.timeout(timeoutMs)
2140
+ ]);
2141
+ if (signal?.aborted === true) throw abortErrorFrom(signal);
2142
+ const attempt = async (feedback) => {
2143
+ const promptText = feedback === void 0 ? prompt : `${prompt}
2144
+
2145
+ ${feedback}`;
2146
+ let text;
2147
+ let finishReason2 = "stop";
2148
+ let usage;
2149
+ let toolCalls = [];
2150
+ let trimmedTurns = 0;
2151
+ if (this.resolved === "cloud") {
2152
+ this.assertCloudCompatible({ ...options, images }, tools.builtIn.length + tools.functions.length > 0);
2153
+ let cloudPrompt = await withDocuments(promptText, options.documents);
2154
+ if (history.length > 0) {
2155
+ cloudPrompt = `Conversation so far:
2156
+ ${renderHistory(history)}
2157
+
2158
+ New message from the user:
2159
+ ${cloudPrompt}`;
2160
+ }
2161
+ let cloudSystem = system;
2162
+ if (mode.schema !== void 0) {
2163
+ const instruction = "Reply with a single JSON value matching this JSON Schema. Output only the JSON, with no commentary and no code fence.\n\n" + JSON.stringify(mode.schema.json, null, 2);
2164
+ cloudSystem = cloudSystem ? `${cloudSystem}
2165
+
2166
+ ${instruction}` : instruction;
2167
+ }
2168
+ text = await this.cloud.text({
2169
+ system: cloudSystem,
2170
+ prompt: cloudPrompt,
2171
+ webSearch: options.webSearch,
2172
+ signal,
2173
+ images: images !== void 0 && images.length > 0 ? images : void 0
2174
+ });
2175
+ if (mode.schema === void 0) mode.onDelta?.(text);
2176
+ } else {
2177
+ const outcome = await this.device.run(
2178
+ {
2179
+ system,
2180
+ prompt: promptText,
2181
+ schema: mode.schema === void 0 ? null : toAppleSchema(mode.schema.json),
2182
+ temperature: options.temperature ?? this.options.temperature,
2183
+ maxTokens: options.maxTokens ?? this.options.maxTokens,
2184
+ images,
2185
+ documents: options.documents,
2186
+ sessionId: options.sessionId,
2187
+ history: history.length > 0 ? history : void 0,
2188
+ trimHistory,
2189
+ tools: tools.builtIn.length > 0 ? tools.builtIn : void 0,
2190
+ functions: tools.functions.length > 0 ? tools.functions : void 0,
2191
+ useCase: options.useCase ?? this.options.useCase,
2192
+ guardrails: options.guardrails ?? this.options.guardrails,
2193
+ sampling: options.sampling ?? this.options.sampling
2194
+ },
2195
+ mode.streaming === true ? "stream" : "generate",
2196
+ {
2197
+ signal,
2198
+ onDelta: mode.onDelta,
2199
+ onPartial: mode.onPartial,
2200
+ replay,
2201
+ maxToolCalls: options.maxToolCalls
2202
+ }
2203
+ );
2204
+ text = outcome.content;
2205
+ finishReason2 = outcome.finishReason;
2206
+ usage = outcome.usage;
2207
+ toolCalls = outcome.toolCalls;
2208
+ trimmedTurns = outcome.trimmedTurns;
2209
+ }
2210
+ let object;
2211
+ if (mode.schema !== void 0 && finishReason2 !== "tool-calls") {
2212
+ object = await this.parseObject(text, mode.schema);
2213
+ if (this.resolved === "cloud") mode.onPartial?.(JSON.stringify(object));
2214
+ }
2215
+ return {
2216
+ text,
2217
+ object,
2218
+ finishReason: finishReason2,
2219
+ usage,
2220
+ toolCalls,
2221
+ trimmedTurns,
2222
+ tier: this.resolved === "cloud" ? "cloud" : "device",
2223
+ durationMs: Date.now() - started,
2224
+ message: {
2225
+ role: "assistant",
2226
+ content: finishReason2 === "tool-calls" ? null : text,
2227
+ ...toolCalls.length > 0 ? { toolCalls: toolCalls.map(({ id, name, arguments: args }) => ({ id, name, arguments: args })) } : {}
2228
+ }
2229
+ };
2230
+ };
2231
+ try {
2232
+ return await attempt();
2233
+ } catch (error) {
2234
+ if (!(error instanceof SchemaValidationError) || mode.streaming === true) throw error;
2235
+ return attempt(
2236
+ `Your previous answer was rejected: ${error.message.replace(/^The model's reply did not satisfy the schema: /, "")}. Answer again, fixing those problems.`
2237
+ );
2238
+ }
2239
+ }
2240
+ /** Parse, restore nulls, validate. Throws SchemaValidationError for anything the schema rejects. */
2241
+ async parseObject(text, schema) {
2242
+ const tier = this.resolved === "cloud" ? "cloud" : "device";
2243
+ let value;
2244
+ try {
2245
+ value = tier === "cloud" ? parseLlmJson(text) : JSON.parse(text);
2246
+ } catch {
2247
+ throw new SchemaValidationError("The model did not reply with valid JSON.", [{ message: "invalid JSON" }], text, tier);
2248
+ }
2249
+ return validateWith(schema, restoreNulls(value, schema.json), text, tier);
2250
+ }
2251
+ assertCloudCompatible(options, hasTools) {
2252
+ const refuse = (what) => {
2253
+ throw new UnsupportedError(`${what} needs the on-device tier.`, "cloud");
2254
+ };
2255
+ if (options.images !== void 0 && options.images.length > 0 && this.cloud?.supportsImages !== true) {
2256
+ throw new UnsupportedError(
2257
+ "images on the cloud tier need a server model that reads them \u2014 macOS 27 (Golden Gate) or later, where the helper can confirm it. Use the on-device tier for images here.",
2258
+ "cloud"
2259
+ );
2260
+ }
2261
+ if (options.sessionId !== void 0) refuse("sessionId");
2262
+ if (hasTools) refuse("tools");
2263
+ }
2264
+ /** Conversation history for a named session (device tier only). */
2265
+ async history(sessionId) {
2266
+ await this.ensureReady();
2267
+ if (this.resolved !== "device") {
2268
+ throw new UnsupportedError("history needs the on-device tier.", "cloud");
2269
+ }
2270
+ return this.device.history(sessionId);
2271
+ }
2272
+ /** Drop a named session, or all sessions when omitted (device tier only). */
2273
+ async resetSession(sessionId) {
2274
+ await this.ensureReady();
2275
+ if (this.resolved === "device") await this.device.resetSession(sessionId);
2276
+ }
2277
+ /**
2278
+ * A named conversation: calls sharing one native transcript, like one thread
2279
+ * in the Siri app. History persists across helper restarts, and the
2280
+ * transcript is rebuilt from it — trimmed to fit when it outgrows the window.
2281
+ */
2282
+ conversation(sessionId, options = {}) {
2283
+ return new Conversation(this, sessionId, options);
2284
+ }
2285
+ /**
2286
+ * Write with Siri, anywhere you type: drafting, rewriting and feedback
2287
+ * built on the permissive-content-transformation guardrails. Device tier
2288
+ * only — these are transformation tasks the default guardrails refuse.
2289
+ */
2290
+ async rewrite(text, options = {}) {
2291
+ const { instruction, ...rest } = options;
2292
+ return this.text(`Rewrite the following text. ${instruction ?? "Keep the meaning, improve clarity."}
2293
+
2294
+ ---
2295
+ ${text}`, {
2296
+ ...rest,
2297
+ guardrails: rest.guardrails ?? "permissive",
2298
+ system: rest.system ?? "You rewrite text. Reply with only the rewritten text, no commentary."
2299
+ });
2300
+ }
2301
+ async proofread(text, options = {}) {
2302
+ return this.text(`Fix spelling, grammar and punctuation in the following text. Preserve the meaning and tone.
2303
+
2304
+ ---
2305
+ ${text}`, {
2306
+ ...options,
2307
+ guardrails: options.guardrails ?? "permissive",
2308
+ system: options.system ?? "You proofread text. Reply with only the corrected text, no commentary."
2309
+ });
2310
+ }
2311
+ /**
2312
+ * Summarise text of any length. Text that does not fit the context window
2313
+ * is summarised in parts, then the parts are summarised together — so a
2314
+ * long report works on an 8k-token model instead of failing.
2315
+ */
2316
+ async summarize(text, options = {}) {
2317
+ const { length, ...rest } = options;
2318
+ const once = (body, what, size) => this.text(`Summarize ${what} in ${size}.
2319
+
2320
+ ---
2321
+ ${body}`, {
2322
+ ...rest,
2323
+ guardrails: rest.guardrails ?? "permissive",
2324
+ system: rest.system ?? "You summarize text tersely."
2325
+ });
2326
+ let current = text;
2327
+ for (let round = 0; round < 4; round += 1) {
2328
+ const chunks = await this.chunksFor(current, rest.maxTokens);
2329
+ if (chunks.length <= 1) break;
2330
+ const parts = [];
2331
+ for (const chunk of chunks) {
2332
+ parts.push(await once(chunk, "this part of a longer document", "a few sentences, keeping names, numbers and decisions"));
2333
+ }
2334
+ current = parts.join("\n\n");
2335
+ }
2336
+ return once(current, current === text ? "the following text" : "these notes on a longer document", length ?? "one short paragraph");
2337
+ }
2338
+ /** Split text into pieces that each fit one call, on paragraph boundaries where possible. */
2339
+ async chunksFor(text, maxTokens) {
2340
+ await this.ensureReady();
2341
+ const window = this.contextSize ?? 4096;
2342
+ const budget = Math.max(512, window - (maxTokens ?? this.options.maxTokens ?? 1024) - 256);
2343
+ let tokens;
2344
+ if (this.resolved === "device") {
2345
+ try {
2346
+ tokens = (await this.device.countTokens(text)).tokens;
2347
+ } catch {
2348
+ tokens = Math.ceil(text.length / 3);
2349
+ }
2350
+ } else {
2351
+ tokens = Math.ceil(text.length / 3);
2352
+ }
2353
+ if (tokens <= budget) return [text];
2354
+ const maxChars = Math.floor(text.length / tokens * budget * 0.85);
2355
+ const chunks = [];
2356
+ let current = "";
2357
+ for (const paragraph of text.split(/\n\s*\n/)) {
2358
+ const pieces = paragraph.length > maxChars ? paragraph.match(new RegExp(`[\\s\\S]{1,${maxChars}}`, "g")) ?? [] : [paragraph];
2359
+ for (const piece of pieces) {
2360
+ if (current.length + piece.length + 2 > maxChars && current !== "") {
2361
+ chunks.push(current);
2362
+ current = "";
2363
+ }
2364
+ current = current === "" ? piece : `${current}
2365
+
2366
+ ${piece}`;
2367
+ }
2368
+ }
2369
+ if (current !== "") chunks.push(current);
2370
+ return chunks;
2371
+ }
2372
+ async draft(topic, options = {}) {
2373
+ const { kind, ...rest } = options;
2374
+ return this.text(`Write a ${kind ?? "short draft"} about the following topic.
2375
+
2376
+ ---
2377
+ ${topic}`, {
2378
+ ...rest,
2379
+ system: rest.system ?? "You are a helpful writing assistant."
2380
+ });
2381
+ }
2382
+ async tone(text, tone, options = {}) {
2383
+ return this.text(`Rewrite the following text to sound more ${tone}.
2384
+
2385
+ ---
2386
+ ${text}`, {
2387
+ ...options,
2388
+ guardrails: options.guardrails ?? "permissive",
2389
+ system: options.system ?? "You rewrite text. Reply with only the rewritten text, no commentary."
2390
+ });
2391
+ }
2392
+ /**
2393
+ * Ask about what's on screen: captures a screenshot (interactive selection
2394
+ * by default, like Cmd+Shift+Space Visual Intelligence) and asks the model
2395
+ * about it with vision. Device tier, macOS 27+.
2396
+ */
2397
+ async askScreen(question, options = {}) {
2398
+ await this.ensureReady();
2399
+ if (this.resolved !== "device") {
2400
+ throw new UnsupportedError("askScreen needs the on-device tier.", "cloud");
2401
+ }
2402
+ const { mode, ...rest } = options;
2403
+ const shot = await captureScreenshot(mode ?? "interactive");
2404
+ try {
2405
+ return await this.text(question, { ...rest, images: [...rest.images ?? [], shot] });
2406
+ } finally {
2407
+ const { rm: rm4 } = await import("fs/promises");
2408
+ await rm4(shot, { force: true }).catch(() => void 0);
2409
+ }
2410
+ }
2411
+ /**
2412
+ * `json()` in the `(system, user, schema)` shape many LLM clients use, for
2413
+ * dropping this in behind an existing interface.
2414
+ */
2415
+ async completeJson(system, user, schema) {
2416
+ return this.json(user, { system, schema });
2417
+ }
2418
+ /**
2419
+ * How many tokens a request costs, before sending it. Device tier only.
2420
+ *
2421
+ * Turns a ContextLengthError into arithmetic: compare against `contextSize`
2422
+ * and trim, rather than finding the ceiling by hitting it. Counts
2423
+ * everything that shares the window — instructions, message history, tools,
2424
+ * schema, images and documents.
2425
+ */
2426
+ async countTokens(input2, options = {}) {
2427
+ await this.ensureReady();
2428
+ if (this.resolved !== "device") {
2429
+ throw new UnsupportedError("countTokens needs the on-device tier.", "cloud");
2430
+ }
2431
+ let prompt;
2432
+ let history;
2433
+ let system = options.system ?? this.options.system;
2434
+ if (typeof input2 === "string") {
2435
+ prompt = input2;
2436
+ } else {
2437
+ const split = splitMessages(input2);
2438
+ prompt = split.prompt;
2439
+ history = split.history;
2440
+ if (split.system !== void 0) system = system === void 0 ? split.system : `${system}
2441
+
2442
+ ${split.system}`;
2443
+ }
2444
+ const tools = normalizeTools(options.tools);
2445
+ return this.device.countTokens(prompt, {
2446
+ system,
2447
+ images: options.images,
2448
+ documents: options.documents,
2449
+ history,
2450
+ tools: tools.builtIn.length > 0 ? tools.builtIn : void 0,
2451
+ functions: tools.functions.length > 0 ? tools.functions : void 0,
2452
+ schema: options.schema === void 0 ? void 0 : resolveSchema(options.schema).json,
2453
+ signal: options.signal
2454
+ });
2455
+ }
2456
+ /**
2457
+ * Load the model assets now so the first real call does not pay for it.
2458
+ * Device tier only; a no-op elsewhere. See `DeviceClient.prewarm` for what it
2459
+ * is actually worth (little, on a warm machine).
2460
+ */
2461
+ async prewarm(system) {
2462
+ await this.ensureReady();
2463
+ if (this.resolved === "device") await this.device.prewarm(system ?? this.options.system);
2464
+ }
2465
+ /** Release the long-lived helper process. Safe to call more than once. */
2466
+ close() {
2467
+ this.device?.close();
2468
+ this.cloud?.close();
2469
+ }
2470
+ /** `using llm = new AppleLLM()` closes it at the end of the block. */
2471
+ [Symbol.dispose]() {
2472
+ this.close();
2473
+ }
2474
+ /** `await using llm = new AppleLLM()` closes it at the end of the block. */
2475
+ async [Symbol.asyncDispose]() {
2476
+ this.close();
2477
+ }
2478
+ };
2479
+ var Conversation = class {
2480
+ constructor(llm, sessionId, defaults = {}) {
2481
+ this.llm = llm;
2482
+ this.sessionId = sessionId;
2483
+ this.defaults = defaults;
2484
+ }
2485
+ llm;
2486
+ sessionId;
2487
+ defaults;
2488
+ with(options) {
2489
+ return { ...options, system: options.system ?? this.defaults.system, sessionId: this.sessionId };
2490
+ }
2491
+ async text(prompt, options = {}) {
2492
+ return this.llm.text(prompt, this.with(options));
2493
+ }
2494
+ stream(prompt, options = {}) {
2495
+ return this.llm.stream(prompt, this.with(options));
2496
+ }
2497
+ async generate(prompt, options = {}) {
2498
+ return this.llm.generate(prompt, this.with(options));
2499
+ }
2500
+ async json(prompt, options) {
2501
+ return this.llm.json(prompt, this.with(options));
2502
+ }
2503
+ async history() {
2504
+ return this.llm.history(this.sessionId);
2505
+ }
2506
+ async reset() {
2507
+ await this.llm.resetSession(this.sessionId);
2508
+ }
2509
+ };
2510
+ async function captureScreenshot(mode = "interactive") {
2511
+ const { execFile: execFile5 } = await import("child_process");
2512
+ const { promisify: promisify5 } = await import("util");
2513
+ const { mkdtemp: mkdtemp3, stat: stat3 } = await import("fs/promises");
2514
+ const os4 = await import("os");
2515
+ const path4 = await import("path");
2516
+ const execFileAsync5 = promisify5(execFile5);
2517
+ const dir = await mkdtemp3(path4.join(os4.tmpdir(), "apple-llm-screen-"));
2518
+ const out = path4.join(dir, "screen.png");
2519
+ const args = mode === "interactive" ? ["-i", "-x", out] : mode === "window" ? ["-w", "-x", out] : ["-x", out];
2520
+ try {
2521
+ await execFileAsync5("screencapture", args, { timeout: 12e4 });
2522
+ } catch (err) {
2523
+ throw new AppleLLMError(
2524
+ `Could not capture a screenshot (screencapture failed): ${err instanceof Error ? err.message : String(err)}`,
2525
+ "device"
2526
+ );
2527
+ }
2528
+ try {
2529
+ const st = await stat3(out);
2530
+ if (st.size === 0) throw new Error("empty capture");
2531
+ } catch {
2532
+ throw new AppleLLMError("Screenshot capture was cancelled or produced no image.", "device");
2533
+ }
2534
+ return out;
2535
+ }
2536
+
2537
+ // src/repl.ts
2538
+ import { createInterface } from "readline/promises";
2539
+ import { stdin as input, stdout as output } from "process";
2540
+ var HELP = `commands:
2541
+ /reset start over
2542
+ /system <text> replace the instructions (and start over)
2543
+ /image <path> attach an image to your next message
2544
+ /tokens how much of the context window the conversation uses
2545
+ /help this list
2546
+ /exit quit (or Ctrl+D)`;
2547
+ async function runRepl(llm, options = {}) {
2548
+ const tty = output.isTTY === true;
2549
+ const dim = (s) => tty ? `\x1B[2m${s}\x1B[22m` : s;
2550
+ const rl = createInterface({ input, output, terminal: tty });
2551
+ const lines = rl[Symbol.asyncIterator]();
2552
+ rl.setPrompt(tty ? "\x1B[1myou \u203A\x1B[22m " : "you \u203A ");
2553
+ let system = options.system;
2554
+ let messages = [];
2555
+ let pendingImages = [];
2556
+ let generating;
2557
+ rl.on("SIGINT", () => {
2558
+ if (generating !== void 0) generating.abort();
2559
+ else rl.close();
2560
+ });
2561
+ await llm.ensureReady((p) => output.write(dim(` ${p.status}
2562
+ `)));
2563
+ output.write(dim(`${llm.label} \u2014 ${llm.contextSize ?? "?"} token context. /help for commands, Ctrl+D to quit.
2564
+ `));
2565
+ for (; ; ) {
2566
+ if (tty) rl.prompt();
2567
+ const next = await lines.next();
2568
+ if (next.done === true) break;
2569
+ const line = String(next.value).trim();
2570
+ if (!tty) output.write(`you \u203A ${line}
2571
+ `);
2572
+ if (line === "") continue;
2573
+ if (line.startsWith("/")) {
2574
+ const [command, ...args] = line.slice(1).split(/\s+/);
2575
+ const rest = args.join(" ");
2576
+ if (command === "exit" || command === "quit") break;
2577
+ if (command === "help") output.write(`${HELP}
2578
+ `);
2579
+ else if (command === "reset") {
2580
+ messages = [];
2581
+ output.write(dim("conversation cleared\n"));
2582
+ } else if (command === "system") {
2583
+ system = rest === "" ? void 0 : rest;
2584
+ messages = [];
2585
+ output.write(dim(system === void 0 ? "instructions cleared\n" : "instructions set; conversation cleared\n"));
2586
+ } else if (command === "image") {
2587
+ if (rest === "") output.write("usage: /image <path>\n");
2588
+ else {
2589
+ pendingImages.push(rest);
2590
+ output.write(dim(`attached ${rest} to your next message
2591
+ `));
2592
+ }
2593
+ } else if (command === "tokens") {
2594
+ if (messages.length === 0) output.write(dim("nothing yet\n"));
2595
+ else {
2596
+ try {
2597
+ const probe2 = [...messages, { role: "user", content: "" }];
2598
+ const { tokens, contextSize } = await llm.countTokens(probe2, { system });
2599
+ output.write(dim(`${tokens} of ${contextSize} tokens (${Math.round(tokens / contextSize * 100)}%)
2600
+ `));
2601
+ } catch (error) {
2602
+ output.write(`${error instanceof Error ? error.message : String(error)}
2603
+ `);
2604
+ }
2605
+ }
2606
+ } else output.write(`unknown command /${command}
2607
+ ${HELP}
2608
+ `);
2609
+ continue;
2610
+ }
2611
+ const turn = { role: "user", content: line, ...pendingImages.length > 0 ? { images: pendingImages } : {} };
2612
+ pendingImages = [];
2613
+ generating = new AbortController();
2614
+ output.write(tty ? "\x1B[1mmodel \u203A\x1B[22m " : "model \u203A ");
2615
+ try {
2616
+ const stream = llm.stream([...messages, turn], { ...options.call, system, signal: generating.signal });
2617
+ for await (const delta of stream) output.write(delta);
2618
+ const result = await stream.result;
2619
+ output.write("\n");
2620
+ messages.push(turn, result.message);
2621
+ const notes = [];
2622
+ if (result.trimmedTurns > 0) notes.push(`${result.trimmedTurns} oldest turn(s) dropped to fit`);
2623
+ if (result.finishReason === "length") notes.push("cut off at the token limit");
2624
+ if (result.usage !== void 0) notes.push(`${result.usage.outputTokens} tokens`);
2625
+ notes.push(`${(result.durationMs / 1e3).toFixed(1)}s`);
2626
+ output.write(dim(` ${notes.join(" \xB7 ")}
2627
+ `));
2628
+ } catch (error) {
2629
+ output.write("\n");
2630
+ if (error instanceof AbortError) output.write(dim(" (stopped)\n"));
2631
+ else output.write(`${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}
2632
+ `);
2633
+ } finally {
2634
+ generating = void 0;
2635
+ }
2636
+ }
2637
+ rl.close();
2638
+ }
2639
+
2640
+ // src/server.ts
2641
+ import http from "http";
2642
+ import { randomUUID as randomUUID2 } from "crypto";
2643
+
2644
+ // src/attachments.ts
2645
+ import { mkdtemp as mkdtemp2, rm as rm3, writeFile as writeFile2 } from "fs/promises";
2646
+ import os3 from "os";
2647
+ import path3 from "path";
2648
+ var MAX_IMAGE_BYTES = 20 * 1024 * 1024;
2649
+ var EXTENSIONS = {
2650
+ "image/png": ".png",
2651
+ "image/jpeg": ".jpg",
2652
+ "image/jpg": ".jpg",
2653
+ "image/gif": ".gif",
2654
+ "image/webp": ".webp",
2655
+ "image/heic": ".heic",
2656
+ "image/heif": ".heif",
2657
+ "image/tiff": ".tiff",
2658
+ "image/bmp": ".bmp"
2659
+ };
2660
+ var TempImages = class {
2661
+ dir;
2662
+ count = 0;
2663
+ /** Write one image and return its path. */
2664
+ async add(source, mediaType, signal) {
2665
+ const { bytes, type } = await loadImage(source, mediaType, signal);
2666
+ this.dir ??= await mkdtemp2(path3.join(os3.tmpdir(), "apple-llm-img-"));
2667
+ this.count += 1;
2668
+ const file = path3.join(this.dir, `image${this.count}${EXTENSIONS[type] ?? ".img"}`);
2669
+ await writeFile2(file, bytes);
2670
+ return file;
2671
+ }
2672
+ async dispose() {
2673
+ if (this.dir !== void 0) await rm3(this.dir, { recursive: true, force: true }).catch(() => void 0);
2674
+ this.dir = void 0;
2675
+ }
2676
+ };
2677
+ async function loadImage(source, mediaType, signal) {
2678
+ if (source instanceof Uint8Array) return { bytes: checked(source), type: mediaType ?? sniff(source) };
2679
+ if (source instanceof ArrayBuffer) {
2680
+ const bytes2 = new Uint8Array(source);
2681
+ return { bytes: checked(bytes2), type: mediaType ?? sniff(bytes2) };
2682
+ }
2683
+ const text = source instanceof URL ? source.href : source;
2684
+ const dataUrl = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(text);
2685
+ if (dataUrl !== null) {
2686
+ const bytes2 = dataUrl[2] !== void 0 ? Buffer.from(dataUrl[3], "base64") : Buffer.from(decodeURIComponent(dataUrl[3]), "utf8");
2687
+ return { bytes: checked(bytes2), type: dataUrl[1] ?? mediaType ?? sniff(bytes2) };
2688
+ }
2689
+ if (/^https?:\/\//i.test(text)) {
2690
+ const response = await fetch(text, { signal });
2691
+ if (!response.ok) throw new AppleLLMError(`Could not download image ${text}: HTTP ${response.status}`);
2692
+ const declared = Number(response.headers.get("content-length") ?? "0");
2693
+ if (declared > MAX_IMAGE_BYTES) throw tooLarge();
2694
+ const bytes2 = new Uint8Array(await response.arrayBuffer());
2695
+ const type = response.headers.get("content-type")?.split(";")[0]?.trim();
2696
+ return { bytes: checked(bytes2), type: mediaType ?? type ?? sniff(bytes2) };
2697
+ }
2698
+ const bytes = Buffer.from(text, "base64");
2699
+ if (bytes.length === 0) throw new AppleLLMError("Image data is empty or not base64.");
2700
+ return { bytes: checked(bytes), type: mediaType ?? sniff(bytes) };
2701
+ }
2702
+ function checked(bytes) {
2703
+ if (bytes.length > MAX_IMAGE_BYTES) throw tooLarge();
2704
+ return bytes;
2705
+ }
2706
+ function tooLarge() {
2707
+ return new AppleLLMError(`Image is larger than ${MAX_IMAGE_BYTES / 1024 / 1024} MB.`);
2708
+ }
2709
+ function sniff(bytes) {
2710
+ const at = (i) => bytes[i] ?? -1;
2711
+ if (at(0) === 137 && at(1) === 80 && at(2) === 78 && at(3) === 71) return "image/png";
2712
+ if (at(0) === 255 && at(1) === 216) return "image/jpeg";
2713
+ if (at(0) === 71 && at(1) === 73 && at(2) === 70) return "image/gif";
2714
+ if (at(8) === 87 && at(9) === 69 && at(10) === 66 && at(11) === 80) return "image/webp";
2715
+ return "application/octet-stream";
2716
+ }
2717
+
2718
+ // src/server.ts
2719
+ var DEFAULT_PORT = 11436;
2720
+ var MODELS = {
2721
+ "apple-on-device": "device",
2722
+ "apple-private-cloud": "cloud"
2723
+ };
2724
+ function errorResponse(error) {
2725
+ const message = error instanceof Error ? error.message : String(error);
2726
+ const make = (status, type, code) => ({
2727
+ status,
2728
+ body: { error: { message, type, code: code ?? null, param: null } }
2729
+ });
2730
+ if (error instanceof ContextLengthError) return make(400, "invalid_request_error", "context_length_exceeded");
2731
+ if (error instanceof SchemaRejectedError || error instanceof SchemaValidationError) {
2732
+ return make(400, "invalid_request_error", "invalid_schema");
2733
+ }
2734
+ if (error instanceof UnsupportedError) return make(400, "invalid_request_error", "unsupported");
2735
+ if (error instanceof RefusalError) return make(400, "invalid_request_error", "content_filter");
2736
+ if (error instanceof QuotaError) return make(429, "rate_limit_error", "quota_exceeded");
2737
+ if (error instanceof TimeoutError) return make(504, "timeout_error");
2738
+ if (error instanceof AbortError) return make(499, "request_cancelled");
2739
+ if (error instanceof ModelBusyError) return make(503, "service_unavailable", "model_busy");
2740
+ if (error instanceof ModelUnavailableError || error instanceof SetupRequiredError) {
2741
+ return make(503, "service_unavailable", "model_unavailable");
2742
+ }
2743
+ if (error instanceof HttpError) return make(error.status, "invalid_request_error");
2744
+ if (error instanceof AppleLLMError) return make(500, "server_error");
2745
+ return make(500, "server_error");
2746
+ }
2747
+ var HttpError = class extends Error {
2748
+ constructor(status, message) {
2749
+ super(message);
2750
+ this.status = status;
2751
+ }
2752
+ status;
2753
+ };
2754
+ function textOf(content) {
2755
+ if (typeof content === "string") return content;
2756
+ if (content === null || content === void 0) return "";
2757
+ if (Array.isArray(content)) {
2758
+ return content.filter((p) => p.type === "text" || p.type === "input_text").map((p) => p.text ?? "").join("\n");
2759
+ }
2760
+ return String(content);
2761
+ }
2762
+ async function toMessages(raw, images, signal) {
2763
+ if (!Array.isArray(raw) || raw.length === 0) throw new HttpError(400, "`messages` must be a non-empty array.");
2764
+ const out = [];
2765
+ for (const message of raw) {
2766
+ switch (message.role) {
2767
+ case "system":
2768
+ case "developer":
2769
+ out.push({ role: "system", content: textOf(message.content) });
2770
+ break;
2771
+ case "user": {
2772
+ const paths = [];
2773
+ if (Array.isArray(message.content)) {
2774
+ for (const part of message.content) {
2775
+ if (part.type === "image_url" && part.image_url !== void 0) {
2776
+ const url = typeof part.image_url === "string" ? part.image_url : part.image_url.url;
2777
+ paths.push(await images.add(url, void 0, signal));
2778
+ } else if (part.type !== "text" && part.type !== "input_text") {
2779
+ throw new HttpError(400, `Content part "${part.type}" is not supported.`);
2780
+ }
2781
+ }
2782
+ }
2783
+ out.push({ role: "user", content: textOf(message.content), ...paths.length > 0 ? { images: paths } : {} });
2784
+ break;
2785
+ }
2786
+ case "assistant":
2787
+ out.push({
2788
+ role: "assistant",
2789
+ content: textOf(message.content),
2790
+ ...message.tool_calls !== void 0 && message.tool_calls.length > 0 ? {
2791
+ toolCalls: message.tool_calls.map((call) => ({
2792
+ id: call.id,
2793
+ name: call.function.name,
2794
+ arguments: parseArguments(call.function.arguments)
2795
+ }))
2796
+ } : {}
2797
+ });
2798
+ break;
2799
+ case "tool":
2800
+ out.push({ role: "tool", toolCallId: String(message.tool_call_id ?? ""), name: message.name, content: textOf(message.content) });
2801
+ break;
2802
+ default:
2803
+ throw new HttpError(400, `Message role "${message.role}" is not supported.`);
2804
+ }
2805
+ }
2806
+ return out;
2807
+ }
2808
+ function parseArguments(text) {
2809
+ try {
2810
+ return JSON.parse(text);
2811
+ } catch {
2812
+ return text;
2813
+ }
2814
+ }
2815
+ function openAIToolCalls(calls) {
2816
+ return calls.filter((call) => call.output === void 0).map((call, index) => ({
2817
+ index,
2818
+ id: call.id,
2819
+ type: "function",
2820
+ function: { name: call.name, arguments: JSON.stringify(call.arguments ?? {}) }
2821
+ }));
2822
+ }
2823
+ function finishReason(result) {
2824
+ return result.finishReason === "tool-calls" ? "tool_calls" : result.finishReason;
2825
+ }
2826
+ function usageOf(result) {
2827
+ if (result.usage === void 0) return void 0;
2828
+ return {
2829
+ prompt_tokens: result.usage.inputTokens,
2830
+ completion_tokens: result.usage.outputTokens,
2831
+ total_tokens: result.usage.totalTokens
2832
+ };
2833
+ }
2834
+ async function readBody(req, limit = 32 * 1024 * 1024) {
2835
+ const chunks = [];
2836
+ let size = 0;
2837
+ for await (const chunk of req) {
2838
+ size += chunk.length;
2839
+ if (size > limit) throw new HttpError(413, "Request body too large.");
2840
+ chunks.push(chunk);
2841
+ }
2842
+ const text = Buffer.concat(chunks).toString("utf8");
2843
+ if (text === "") return {};
2844
+ try {
2845
+ return JSON.parse(text);
2846
+ } catch {
2847
+ throw new HttpError(400, "Request body is not valid JSON.");
2848
+ }
2849
+ }
2850
+ function createServer(options = {}) {
2851
+ const { port: _port, host: _host, tier: defaultTier = "device", apiKey, cors, log, ...llmOptions } = options;
2852
+ const clients = /* @__PURE__ */ new Map();
2853
+ const clientFor = (tier) => {
2854
+ let client = clients.get(tier);
2855
+ if (client === void 0) {
2856
+ client = new AppleLLM({ ...llmOptions, tier });
2857
+ clients.set(tier, client);
2858
+ }
2859
+ return client;
2860
+ };
2861
+ const server = http.createServer((req, res) => {
2862
+ const started = Date.now();
2863
+ const controller = new AbortController();
2864
+ res.on("close", () => {
2865
+ if (!res.writableFinished) controller.abort();
2866
+ });
2867
+ const send = (status, body) => {
2868
+ if (res.headersSent) return;
2869
+ res.writeHead(status, { "content-type": "application/json", ...corsHeaders() });
2870
+ res.end(JSON.stringify(body));
2871
+ };
2872
+ const corsHeaders = () => cors === void 0 ? {} : {
2873
+ "access-control-allow-origin": cors,
2874
+ "access-control-allow-headers": "authorization, content-type",
2875
+ "access-control-allow-methods": "GET, POST, OPTIONS"
2876
+ };
2877
+ const route = async () => {
2878
+ const url = new URL(req.url ?? "/", "http://localhost");
2879
+ const pathname = url.pathname.replace(/\/+$/, "") || "/";
2880
+ if (req.method === "OPTIONS") {
2881
+ res.writeHead(204, corsHeaders());
2882
+ res.end();
2883
+ return void 0;
2884
+ }
2885
+ if (apiKey !== void 0 && req.headers.authorization !== `Bearer ${apiKey}`) {
2886
+ send(401, { error: { message: "Invalid API key.", type: "invalid_request_error", code: "invalid_api_key", param: null } });
2887
+ return void 0;
2888
+ }
2889
+ if (req.method === "GET" && (pathname === "/health" || pathname === "/v1/health")) {
2890
+ const state = await probe();
2891
+ send(state.device.available || state.cloud.available ? 200 : 503, {
2892
+ status: state.device.available || state.cloud.available ? "ok" : "unavailable",
2893
+ device: { available: state.device.available, variant: state.device.variant, contextSize: state.device.contextSize, reason: state.device.reason },
2894
+ cloud: { available: state.cloud.available, quota: state.cloud.quota?.status, reason: state.cloud.reason }
2895
+ });
2896
+ return void 0;
2897
+ }
2898
+ if (req.method === "GET" && pathname === "/v1/models") {
2899
+ const created = Math.floor(Date.now() / 1e3);
2900
+ send(200, {
2901
+ object: "list",
2902
+ data: Object.keys(MODELS).map((id) => ({ id, object: "model", created, owned_by: "apple" }))
2903
+ });
2904
+ return void 0;
2905
+ }
2906
+ if (req.method === "POST" && pathname === "/v1/chat/completions") {
2907
+ const body = await readBody(req);
2908
+ return chatCompletions(body);
2909
+ }
2910
+ send(404, { error: { message: `No route for ${req.method} ${pathname}.`, type: "invalid_request_error", code: "not_found", param: null } });
2911
+ return void 0;
2912
+ };
2913
+ const chatCompletions = async (body) => {
2914
+ const requested = body.model ?? "";
2915
+ const tier = requested in MODELS ? MODELS[requested] : defaultTier;
2916
+ const model = requested in MODELS ? requested : tier === "cloud" ? "apple-private-cloud" : "apple-on-device";
2917
+ if (body.n !== void 0 && body.n !== 1) throw new HttpError(400, "Only n=1 is supported.");
2918
+ const images = new TempImages();
2919
+ try {
2920
+ const messages = await toMessages(body.messages, images, controller.signal);
2921
+ const functions = [];
2922
+ if (body.tool_choice !== "none") {
2923
+ for (const def of body.tools ?? []) {
2924
+ if (def.type !== "function" || def.function === void 0) continue;
2925
+ functions.push(tool({ name: def.function.name, description: def.function.description, parameters: def.function.parameters }));
2926
+ }
2927
+ }
2928
+ let schema;
2929
+ if (body.response_format?.type === "json_schema" && body.response_format.json_schema?.schema !== void 0) {
2930
+ schema = body.response_format.json_schema.schema;
2931
+ } else if (body.response_format?.type === "json_object") {
2932
+ messages.unshift({ role: "system", content: "Reply with a single JSON object and nothing else." });
2933
+ }
2934
+ let sampling;
2935
+ if (body.top_p !== void 0) sampling = { mode: "threshold", p: body.top_p, seed: body.seed };
2936
+ else if (body.seed !== void 0) sampling = { mode: "topK", k: 50, seed: body.seed };
2937
+ const call = {
2938
+ temperature: body.temperature,
2939
+ maxTokens: body.max_completion_tokens ?? body.max_tokens,
2940
+ sampling,
2941
+ tools: functions.length > 0 ? functions : void 0,
2942
+ signal: controller.signal
2943
+ };
2944
+ const client = clientFor(tier);
2945
+ const id = `chatcmpl-${randomUUID2().replace(/-/g, "").slice(0, 24)}`;
2946
+ const created = Math.floor(Date.now() / 1e3);
2947
+ if (body.stream === true) {
2948
+ res.writeHead(200, {
2949
+ "content-type": "text/event-stream",
2950
+ "cache-control": "no-cache",
2951
+ connection: "keep-alive",
2952
+ ...corsHeaders()
2953
+ });
2954
+ const event = (payload) => {
2955
+ if (!res.destroyed) res.write(`data: ${typeof payload === "string" ? payload : JSON.stringify(payload)}
2956
+
2957
+ `);
2958
+ };
2959
+ const chunk = (delta, finish = null) => {
2960
+ event({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason: finish, logprobs: null }] });
2961
+ };
2962
+ chunk({ role: "assistant", content: "" });
2963
+ try {
2964
+ let result2;
2965
+ if (schema === void 0) {
2966
+ const stream = client.stream(messages, call);
2967
+ for await (const delta of stream) chunk({ content: delta });
2968
+ result2 = await stream.result;
2969
+ } else {
2970
+ result2 = await client.generate(messages, { ...call, schema });
2971
+ if (result2.finishReason !== "tool-calls") chunk({ content: result2.text });
2972
+ }
2973
+ const calls2 = openAIToolCalls(result2.toolCalls);
2974
+ if (calls2.length > 0 && result2.finishReason === "tool-calls") chunk({ tool_calls: calls2 });
2975
+ chunk({}, finishReason(result2));
2976
+ if (body.stream_options?.include_usage === true) {
2977
+ event({ id, object: "chat.completion.chunk", created, model, choices: [], usage: usageOf(result2) ?? null });
2978
+ }
2979
+ } catch (error) {
2980
+ event(errorResponse(error).body);
2981
+ if (!res.destroyed) res.end("data: [DONE]\n\n");
2982
+ return `${model} stream failed: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`;
2983
+ }
2984
+ if (!res.destroyed) res.end("data: [DONE]\n\n");
2985
+ return `${model} stream`;
2986
+ }
2987
+ const result = schema === void 0 ? await client.generate(messages, call) : await client.generate(messages, { ...call, schema });
2988
+ const calls = result.finishReason === "tool-calls" ? openAIToolCalls(result.toolCalls) : [];
2989
+ send(200, {
2990
+ id,
2991
+ object: "chat.completion",
2992
+ created,
2993
+ model,
2994
+ choices: [
2995
+ {
2996
+ index: 0,
2997
+ message: {
2998
+ role: "assistant",
2999
+ content: result.finishReason === "tool-calls" ? null : result.text,
3000
+ ...calls.length > 0 ? { tool_calls: calls.map(({ index: _i, ...rest }) => rest) } : {},
3001
+ refusal: null
3002
+ },
3003
+ finish_reason: finishReason(result),
3004
+ logprobs: null
3005
+ }
3006
+ ],
3007
+ usage: usageOf(result) ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
3008
+ });
3009
+ return model;
3010
+ } finally {
3011
+ await images.dispose();
3012
+ }
3013
+ };
3014
+ route().then((what) => {
3015
+ if (what !== void 0) log?.(`${req.method} ${req.url} ${what} ${res.statusCode} ${Date.now() - started}ms`);
3016
+ }).catch((error) => {
3017
+ const { status, body } = errorResponse(error);
3018
+ if (error instanceof ModelBusyError && !res.headersSent) res.setHeader("retry-after", "2");
3019
+ send(status, body);
3020
+ log?.(`${req.method} ${req.url} ${status} ${Date.now() - started}ms ${error instanceof Error ? error.message.split("\n")[0] : ""}`);
3021
+ });
3022
+ });
3023
+ server.on("close", () => {
3024
+ for (const client of clients.values()) client.close();
3025
+ });
3026
+ return server;
3027
+ }
3028
+ async function serve(options = {}) {
3029
+ const server = createServer(options);
3030
+ const port = options.port ?? DEFAULT_PORT;
3031
+ const host = options.host ?? "127.0.0.1";
3032
+ await new Promise((resolve, reject) => {
3033
+ server.once("error", reject);
3034
+ server.listen(port, host, () => {
3035
+ server.off("error", reject);
3036
+ resolve();
3037
+ });
3038
+ });
3039
+ const address = server.address();
3040
+ const bound = typeof address === "object" && address !== null ? address.port : port;
3041
+ return {
3042
+ server,
3043
+ url: `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${bound}/v1`,
3044
+ close: () => new Promise((resolve) => {
3045
+ server.close(() => resolve());
3046
+ server.closeAllConnections?.();
3047
+ })
3048
+ };
3049
+ }
3050
+
3051
+ // src/cli.ts
13
3052
  function parseArgs(argv) {
14
3053
  const flags = { positional: [], image: [], document: [], tool: [] };
15
3054
  for (let i = 0; i < argv.length; i += 1) {
@@ -78,6 +3117,21 @@ ${USAGE}`);
78
3117
  case "--greedy":
79
3118
  flags.greedy = true;
80
3119
  break;
3120
+ case "--timeout":
3121
+ flags.timeout = take("--timeout");
3122
+ break;
3123
+ case "--port":
3124
+ flags.port = take("--port");
3125
+ break;
3126
+ case "--host":
3127
+ flags.host = take("--host");
3128
+ break;
3129
+ case "--api-key":
3130
+ flags.apiKey = take("--api-key");
3131
+ break;
3132
+ case "--cors":
3133
+ flags.cors = take("--cors");
3134
+ break;
81
3135
  default:
82
3136
  flags.positional.push(arg);
83
3137
  }
@@ -108,6 +3162,8 @@ var USAGE = `apple-llm \u2014 Apple's on-device and Private Cloud Compute models
108
3162
  apple-llm probe what this machine can do
109
3163
  apple-llm setup-cloud [--web-search] install the Shortcut the cloud tier needs
110
3164
  apple-llm run [options] <prompt|-> one completion ("-" reads stdin)
3165
+ apple-llm chat [options] interactive conversation, streamed
3166
+ apple-llm serve [options] OpenAI-compatible API on localhost
111
3167
  apple-llm count [options] <prompt|-> tokens this prompt costs, before sending
112
3168
  apple-llm ask-screen [options] <question|-> screenshot, then ask (device tier)
113
3169
  apple-llm history --session <id> show a conversation's mirrored turns
@@ -130,6 +3186,19 @@ run options:
130
3186
  --seed <n> reproducible sampling (top-k, seeded)
131
3187
  --greedy greedy decoding (deterministic, but degenerates)
132
3188
  --web-search cloud tier only
3189
+ --timeout <seconds> give up after this long
3190
+ --json print the full result: text, usage, tool calls
3191
+ (with --schema --stream: one partial object per line)
3192
+
3193
+ chat options: --tier, --system, --tool, --use-case, --guardrails, --max-tokens.
3194
+ In the chat: /reset, /system <text>, /image <path>, /tokens, /help, /exit.
3195
+
3196
+ serve options:
3197
+ --port <n> default ${DEFAULT_PORT}
3198
+ --host <addr> default 127.0.0.1 (0.0.0.0 exposes it to your network)
3199
+ --api-key <key> require "Authorization: Bearer <key>"
3200
+ --cors <origin> allow browser clients from this origin
3201
+ --tier device|cloud|auto for requests not naming a model; default device
133
3202
 
134
3203
  ask-screen options: --mode interactive|window|fullscreen (default interactive),
135
3204
  plus --system, --session, --max-tokens.
@@ -321,10 +3390,64 @@ ${USAGE}`);
321
3390
  `);
322
3391
  return 0;
323
3392
  } finally {
324
- const { rm } = await import("fs/promises");
325
- await rm(shot, { force: true }).catch(() => void 0);
3393
+ const { rm: rm4 } = await import("fs/promises");
3394
+ await rm4(shot, { force: true }).catch(() => void 0);
3395
+ llm.close();
3396
+ }
3397
+ }
3398
+ if (command === "serve") {
3399
+ const port = flags.port === void 0 ? DEFAULT_PORT : Number(flags.port);
3400
+ if (!Number.isInteger(port) || port < 0 || port > 65535) return usageError(`--port must be a port number (got "${flags.port}").`);
3401
+ if (flags.tier !== void 0 && !["device", "cloud", "auto"].includes(flags.tier)) {
3402
+ return usageError(`--tier must be device, cloud, or auto (got "${flags.tier}").`);
3403
+ }
3404
+ const running = await serve({
3405
+ port,
3406
+ host: flags.host,
3407
+ apiKey: flags.apiKey,
3408
+ cors: flags.cors,
3409
+ tier: flags.tier,
3410
+ log: (line) => process.stderr.write(` ${line}
3411
+ `)
3412
+ });
3413
+ process.stderr.write(
3414
+ `apple-llm serving an OpenAI-compatible API at ${running.url}
3415
+ models: apple-on-device${flags.tier === "cloud" ? " (default: apple-private-cloud)" : ""}, apple-private-cloud
3416
+ try: curl ${running.url}/chat/completions -H 'content-type: application/json' -d '{"model":"apple-on-device","messages":[{"role":"user","content":"Hello"}]}'
3417
+ ` + (flags.host === "0.0.0.0" && flags.apiKey === void 0 ? " warning: listening on every interface with no --api-key; anyone on your network can use it\n" : "")
3418
+ );
3419
+ await new Promise((resolve) => {
3420
+ const stop = () => {
3421
+ void running.close().then(resolve);
3422
+ };
3423
+ process.once("SIGINT", stop);
3424
+ process.once("SIGTERM", stop);
3425
+ });
3426
+ return 0;
3427
+ }
3428
+ if (command === "chat") {
3429
+ if (flags.tier !== void 0 && !["device", "cloud", "auto"].includes(flags.tier)) {
3430
+ return usageError(`--tier must be device, cloud, or auto (got "${flags.tier}").`);
3431
+ }
3432
+ let tools;
3433
+ try {
3434
+ tools = parseTools(flags.tool, usageError);
3435
+ } catch (err) {
3436
+ return usageError(err instanceof Error ? err.message : String(err));
3437
+ }
3438
+ const maxTokens = flags.maxTokens === void 0 ? void 0 : Number(flags.maxTokens);
3439
+ const llm = new AppleLLM({
3440
+ tier: flags.tier ?? "auto",
3441
+ maxTokens,
3442
+ useCase: flags.useCase,
3443
+ guardrails: flags.guardrails
3444
+ });
3445
+ try {
3446
+ await runRepl(llm, { system: flags.system, call: { tools } });
3447
+ } finally {
326
3448
  llm.close();
327
3449
  }
3450
+ return 0;
328
3451
  }
329
3452
  if (command === "run") {
330
3453
  const arg = flags.positional[0];
@@ -343,6 +3466,10 @@ ${USAGE}`);
343
3466
  if (maxTokens !== void 0 && Number.isNaN(maxTokens)) {
344
3467
  return usageError(`--max-tokens must be a number (got "${flags.maxTokens}").`);
345
3468
  }
3469
+ const timeoutMs = flags.timeout === void 0 ? void 0 : Number(flags.timeout) * 1e3;
3470
+ if (timeoutMs !== void 0 && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) {
3471
+ return usageError(`--timeout must be a number of seconds (got "${flags.timeout}").`);
3472
+ }
346
3473
  const seed = flags.seed === void 0 ? void 0 : Number(flags.seed);
347
3474
  if (seed !== void 0 && Number.isNaN(seed)) {
348
3475
  return usageError(`--seed must be a number (got "${flags.seed}").`);
@@ -369,6 +3496,7 @@ ${USAGE}`);
369
3496
  useCase: flags.useCase,
370
3497
  guardrails: flags.guardrails,
371
3498
  sampling,
3499
+ timeoutMs,
372
3500
  onProgress: (p) => process.stderr.write(` ${p.status}
373
3501
  `)
374
3502
  });
@@ -376,12 +3504,9 @@ ${USAGE}`);
376
3504
  const documents = flags.document.length > 0 ? flags.document : void 0;
377
3505
  try {
378
3506
  if (flags.schema !== void 0) {
379
- if (flags.stream === true) {
380
- return usageError("--stream is text only; schemas need a complete response.");
381
- }
382
3507
  let schemaText;
383
3508
  try {
384
- schemaText = await readFile(flags.schema, "utf8");
3509
+ schemaText = await readFile3(flags.schema, "utf8");
385
3510
  } catch (err) {
386
3511
  throw new AppleLLMError(
387
3512
  `Could not read schema file "${flags.schema}": ${err instanceof Error ? err.message : String(err)}`
@@ -395,7 +3520,7 @@ ${USAGE}`);
395
3520
  `Schema file "${flags.schema}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
396
3521
  );
397
3522
  }
398
- const out = await llm.json(prompt, {
3523
+ const call = {
399
3524
  schema,
400
3525
  system: flags.system,
401
3526
  webSearch: flags.webSearch,
@@ -403,13 +3528,22 @@ ${USAGE}`);
403
3528
  documents,
404
3529
  sessionId: flags.session,
405
3530
  tools
406
- });
407
- process.stdout.write(`${JSON.stringify(out, null, 2)}
3531
+ };
3532
+ if (flags.stream === true) {
3533
+ const stream = llm.streamJson(prompt, call);
3534
+ for await (const partial of stream) process.stdout.write(`${JSON.stringify(partial)}
3535
+ `);
3536
+ const final = await stream;
3537
+ process.stdout.write(`${JSON.stringify(final)}
3538
+ `);
3539
+ } else if (flags.json === true) {
3540
+ process.stdout.write(`${JSON.stringify(await llm.generate(prompt, call), null, 2)}
3541
+ `);
3542
+ } else {
3543
+ process.stdout.write(`${JSON.stringify(await llm.json(prompt, call), null, 2)}
408
3544
  `);
409
- } else if (flags.stream === true) {
410
- if (tier === "cloud") {
411
- return usageError("--stream needs the on-device tier.");
412
3545
  }
3546
+ } else if (flags.stream === true) {
413
3547
  const out = await llm.stream(prompt, {
414
3548
  system: flags.system,
415
3549
  images,
@@ -421,16 +3555,21 @@ ${USAGE}`);
421
3555
  process.stdout.write("\n");
422
3556
  void out;
423
3557
  } else {
424
- const out = await llm.text(prompt, {
3558
+ const call = {
425
3559
  system: flags.system,
426
3560
  webSearch: flags.webSearch,
427
3561
  images,
428
3562
  documents,
429
3563
  sessionId: flags.session,
430
3564
  tools
431
- });
432
- process.stdout.write(`${out}
3565
+ };
3566
+ if (flags.json === true) {
3567
+ process.stdout.write(`${JSON.stringify(await llm.generate(prompt, call), null, 2)}
433
3568
  `);
3569
+ } else {
3570
+ process.stdout.write(`${await llm.text(prompt, call)}
3571
+ `);
3572
+ }
434
3573
  }
435
3574
  } finally {
436
3575
  llm.close();