@camstack/addon-ai 0.4.122 → 0.4.124

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.
Binary file
@@ -0,0 +1,481 @@
1
+ // camstack-afm — CamStack's Apple Foundation Models helper.
2
+ //
3
+ // A long-lived child of the ai addon's `llm-runtime` provider, on darwin only.
4
+ // It speaks ONE JSON object per line: requests on stdin, replies on stdout.
5
+ // Every request carries an `id`; every reply echoes it.
6
+ //
7
+ // {"id":"1","op":"probe"}
8
+ // {"id":"2","op":"generate","prompt":"…","system":"…","jsonSchema":{…},
9
+ // "maxTokens":64,"temperature":0.2,"topP":0.9,"topK":40}
10
+ // {"id":"3","op":"cancel","target":"2"}
11
+ // {"id":"4","op":"checkSchema","jsonSchema":{…}} // map only, no model call
12
+ //
13
+ // Replies: {"id","ok":true,…} or {"id","ok":false,"code","message","appleError"?,
14
+ // "refusalSource"?}. `code` is already an `LlmErrorCode` (plus "cancelled").
15
+ //
16
+ // Rules this file keeps:
17
+ // - A JSON Schema construct that cannot be mapped to a DynamicGenerationSchema
18
+ // is REFUSED (`bad-request`), never dropped: a dropped `minimum` produced
19
+ // `confidence: 85` for a 0..1 field on the first measurement.
20
+ // - A guardrail violation is its own outcome (`refusalSource: "guardrail"`),
21
+ // never an empty answer.
22
+ // - Image input is reported `false`: this binary is built against the macOS
23
+ // 26 SDK, which has no public image input. It is never inferred elsewhere.
24
+ //
25
+ // Build: `node scripts/build-afm.mjs` (darwin, SDK >= 26). Deployment target 26.0.
26
+ import Foundation
27
+ import FoundationModels
28
+
29
+ let protocolVersion = 1
30
+
31
+ // MARK: - Output
32
+
33
+ final class LineWriter: @unchecked Sendable {
34
+ private let lock = NSLock()
35
+
36
+ func send(_ object: [String: Any]) {
37
+ guard JSONSerialization.isValidJSONObject(object),
38
+ let data = try? JSONSerialization.data(withJSONObject: object, options: [])
39
+ else {
40
+ let fallback = "{\"id\":\"\",\"ok\":false,\"code\":\"adapter-error\",\"message\":\"unserialisable reply\"}\n"
41
+ lock.lock()
42
+ FileHandle.standardOutput.write(Data(fallback.utf8))
43
+ lock.unlock()
44
+ return
45
+ }
46
+ lock.lock()
47
+ FileHandle.standardOutput.write(data)
48
+ FileHandle.standardOutput.write(Data("\n".utf8))
49
+ lock.unlock()
50
+ }
51
+ }
52
+
53
+ // MARK: - In-flight generations
54
+
55
+ /// Lock-based rather than an actor so a generation is registered SYNCHRONOUSLY
56
+ /// on the reading thread: an actor hop left a window where EOF saw an empty
57
+ /// registry and exited under a generation that had just been accepted.
58
+ final class TaskRegistry: @unchecked Sendable {
59
+ private let lock = NSLock()
60
+ private var reserved: Set<String> = []
61
+ private var tasks: [String: Task<Void, Never>] = [:]
62
+
63
+ func reserve(_ id: String) {
64
+ lock.lock(); reserved.insert(id); lock.unlock()
65
+ }
66
+ /// Attach only while still reserved — a task that already finished has
67
+ /// removed its reservation and must not leave a stale entry behind.
68
+ func attach(_ id: String, _ task: Task<Void, Never>) {
69
+ lock.lock(); if reserved.contains(id) { tasks[id] = task }; lock.unlock()
70
+ }
71
+ func remove(_ id: String) {
72
+ lock.lock(); reserved.remove(id); tasks[id] = nil; lock.unlock()
73
+ }
74
+ func cancel(_ id: String) -> Bool {
75
+ lock.lock(); let task = tasks[id]; lock.unlock()
76
+ guard let task else { return false }
77
+ task.cancel()
78
+ return true
79
+ }
80
+ var count: Int {
81
+ lock.lock(); defer { lock.unlock() }
82
+ return reserved.count
83
+ }
84
+ }
85
+
86
+ // MARK: - Replies
87
+
88
+ func failure(_ id: String, _ code: String, _ message: String, extra: [String: Any] = [:]) -> [String: Any] {
89
+ var out: [String: Any] = ["id": id, "ok": false, "code": code, "message": message]
90
+ for (key, value) in extra { out[key] = value }
91
+ return out
92
+ }
93
+
94
+ // MARK: - Availability
95
+
96
+ func osVersionString() -> String {
97
+ let v = ProcessInfo.processInfo.operatingSystemVersion
98
+ return "\(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
99
+ }
100
+
101
+ func languageTag(_ language: Locale.Language) -> String {
102
+ var tag = language.languageCode?.identifier ?? "und"
103
+ if let region = language.region?.identifier { tag += "-\(region)" }
104
+ return tag
105
+ }
106
+
107
+ func availabilityPayload() -> [String: Any] {
108
+ let model = SystemLanguageModel.default
109
+ let osVersion = osVersionString()
110
+ switch model.availability {
111
+ case .available:
112
+ let languages = Array(Set(model.supportedLanguages.map(languageTag))).sorted()
113
+ return [
114
+ "state": "available",
115
+ "osVersion": osVersion,
116
+ "contextSize": model.contextSize,
117
+ "languages": languages,
118
+ // Built against the macOS 26 SDK: no public image input exists here.
119
+ "imageInput": false,
120
+ ]
121
+ case .unavailable(let reason):
122
+ switch reason {
123
+ case .deviceNotEligible:
124
+ return ["state": "unavailable", "reason": "deviceNotEligible", "osVersion": osVersion,
125
+ "detail": "this Mac is not eligible for Apple Intelligence"]
126
+ case .appleIntelligenceNotEnabled:
127
+ return ["state": "unavailable", "reason": "appleIntelligenceNotEnabled", "osVersion": osVersion,
128
+ "detail": "Apple Intelligence is turned off in System Settings for the logged-in user"]
129
+ case .modelNotReady:
130
+ return ["state": "unavailable", "reason": "modelNotReady", "osVersion": osVersion,
131
+ "detail": "the on-device model is still downloading or being prepared"]
132
+ @unknown default:
133
+ return ["state": "unknown", "error": "unrecognised unavailability reason: \(reason)"]
134
+ }
135
+ }
136
+ }
137
+
138
+ // MARK: - JSON Schema → DynamicGenerationSchema
139
+
140
+ struct SchemaRefusal: Error {
141
+ let message: String
142
+ }
143
+
144
+ /// Keys that describe a schema without constraining it. Everything else that is
145
+ /// not explicitly mapped below is a constraint we would silently drop, so it is
146
+ /// refused instead.
147
+ let annotationKeys: Set<String> = ["type", "description", "title", "$schema", "default", "examples", "$id"]
148
+
149
+ struct SchemaMapper {
150
+ func mapRoot(_ node: [String: Any]) throws -> GenerationSchema {
151
+ let root = try map(node, path: "root")
152
+ return try GenerationSchema(root: root, dependencies: [])
153
+ }
154
+
155
+ private func rejectUnknown(_ node: [String: Any], allowed: Set<String>, path: String) throws {
156
+ for key in node.keys where !annotationKeys.contains(key) && !allowed.contains(key) {
157
+ throw SchemaRefusal(message: "\(path): JSON Schema keyword \"\(key)\" cannot be enforced by Apple Foundation Models")
158
+ }
159
+ }
160
+
161
+ private func number(_ value: Any?) -> Double? {
162
+ guard let n = value as? NSNumber, CFGetTypeID(n) != CFBooleanGetTypeID() else { return nil }
163
+ let d = n.doubleValue
164
+ return d.isFinite ? d : nil
165
+ }
166
+
167
+ /// Largest magnitude accepted as an integer bound: exactly representable in a
168
+ /// Double, far inside Int. `Int(Double)` TRAPS outside Int's range, and a
169
+ /// trap here would crash the helper on a caller's schema.
170
+ private let maxIntegerBound = 9_007_199_254_740_991.0
171
+
172
+ /// `nil` when absent; refused when present but not a safe integer.
173
+ private func integer(_ node: [String: Any], _ key: String, path: String, minimum: Double? = nil) throws -> Int? {
174
+ guard let raw = node[key] else { return nil }
175
+ guard let value = number(raw), value == value.rounded(), abs(value) <= maxIntegerBound else {
176
+ throw SchemaRefusal(message: "\(path): \(key) must be an integer within ±2^53")
177
+ }
178
+ if let minimum, value < minimum {
179
+ throw SchemaRefusal(message: "\(path): \(key) must be at least \(Int(minimum))")
180
+ }
181
+ return Int(value)
182
+ }
183
+
184
+ func map(_ node: [String: Any], path: String) throws -> DynamicGenerationSchema {
185
+ let description = node["description"] as? String
186
+ if let values = node["enum"] {
187
+ guard let strings = values as? [String], !strings.isEmpty else {
188
+ throw SchemaRefusal(message: "\(path): only a non-empty enum of strings can be enforced")
189
+ }
190
+ if let type = node["type"], (type as? String) != "string" {
191
+ throw SchemaRefusal(message: "\(path): an enum must be of type string")
192
+ }
193
+ try rejectUnknown(node, allowed: ["enum"], path: path)
194
+ return DynamicGenerationSchema(name: path, description: description, anyOf: strings)
195
+ }
196
+ guard let type = node["type"] as? String else {
197
+ throw SchemaRefusal(message: "\(path): a schema needs a single string \"type\" (type unions, $ref and combinators are not supported)")
198
+ }
199
+ switch type {
200
+ case "object":
201
+ try rejectUnknown(node, allowed: ["properties", "required", "additionalProperties"], path: path)
202
+ if let extra = node["additionalProperties"], !(extra is Bool) {
203
+ throw SchemaRefusal(message: "\(path): additionalProperties must be a boolean")
204
+ }
205
+ if let raw = node["properties"], !(raw is [String: Any]) {
206
+ throw SchemaRefusal(message: "\(path): properties must be an object")
207
+ }
208
+ if let raw = node["required"], !(raw is [String]) {
209
+ throw SchemaRefusal(message: "\(path): required must be an array of strings")
210
+ }
211
+ let properties = node["properties"] as? [String: Any] ?? [:]
212
+ let required = Set(node["required"] as? [String] ?? [])
213
+ for name in required where properties[name] == nil {
214
+ throw SchemaRefusal(message: "\(path): required property \"\(name)\" is not declared")
215
+ }
216
+ var mapped: [DynamicGenerationSchema.Property] = []
217
+ for name in properties.keys.sorted() {
218
+ guard let child = properties[name] as? [String: Any] else {
219
+ throw SchemaRefusal(message: "\(path).\(name): property schema must be an object")
220
+ }
221
+ let schema = try map(child, path: "\(path).\(name)")
222
+ mapped.append(DynamicGenerationSchema.Property(
223
+ name: name,
224
+ description: child["description"] as? String,
225
+ schema: schema,
226
+ isOptional: !required.contains(name)
227
+ ))
228
+ }
229
+ return DynamicGenerationSchema(name: path, description: description, properties: mapped)
230
+ case "string":
231
+ try rejectUnknown(node, allowed: ["const"], path: path)
232
+ if let constant = node["const"] {
233
+ guard let value = constant as? String else {
234
+ throw SchemaRefusal(message: "\(path): const must be a string")
235
+ }
236
+ return DynamicGenerationSchema(type: String.self, guides: [.constant(value)])
237
+ }
238
+ return DynamicGenerationSchema(type: String.self)
239
+ case "integer":
240
+ try rejectUnknown(node, allowed: ["minimum", "maximum"], path: path)
241
+ var guides: [GenerationGuide<Int>] = []
242
+ if let low = try integer(node, "minimum", path: path) { guides.append(.minimum(low)) }
243
+ if let high = try integer(node, "maximum", path: path) { guides.append(.maximum(high)) }
244
+ return DynamicGenerationSchema(type: Int.self, guides: guides)
245
+ case "number":
246
+ try rejectUnknown(node, allowed: ["minimum", "maximum"], path: path)
247
+ var guides: [GenerationGuide<Double>] = []
248
+ for key in ["minimum", "maximum"] {
249
+ guard let raw = node[key] else { continue }
250
+ guard let bound = number(raw) else {
251
+ throw SchemaRefusal(message: "\(path): \(key) must be a number")
252
+ }
253
+ guides.append(key == "minimum" ? .minimum(bound) : .maximum(bound))
254
+ }
255
+ return DynamicGenerationSchema(type: Double.self, guides: guides)
256
+ case "boolean":
257
+ try rejectUnknown(node, allowed: [], path: path)
258
+ return DynamicGenerationSchema(type: Bool.self)
259
+ case "array":
260
+ try rejectUnknown(node, allowed: ["items", "minItems", "maxItems"], path: path)
261
+ guard let items = node["items"] as? [String: Any] else {
262
+ throw SchemaRefusal(message: "\(path): an array needs a single \"items\" schema")
263
+ }
264
+ let itemSchema = try map(items, path: "\(path)[]")
265
+ let minItems = try integer(node, "minItems", path: path, minimum: 0)
266
+ let maxItems = try integer(node, "maxItems", path: path, minimum: 0)
267
+ if let minItems, let maxItems, minItems > maxItems {
268
+ throw SchemaRefusal(message: "\(path): minItems is greater than maxItems")
269
+ }
270
+ return DynamicGenerationSchema(arrayOf: itemSchema, minimumElements: minItems, maximumElements: maxItems)
271
+ default:
272
+ throw SchemaRefusal(message: "\(path): type \"\(type)\" is not supported")
273
+ }
274
+ }
275
+ }
276
+
277
+ // MARK: - Generation
278
+
279
+ func errorName(_ error: LanguageModelSession.GenerationError) -> String {
280
+ switch error {
281
+ case .exceededContextWindowSize: return "exceededContextWindowSize"
282
+ case .assetsUnavailable: return "assetsUnavailable"
283
+ case .guardrailViolation: return "guardrailViolation"
284
+ case .unsupportedGuide: return "unsupportedGuide"
285
+ case .unsupportedLanguageOrLocale: return "unsupportedLanguageOrLocale"
286
+ case .decodingFailure: return "decodingFailure"
287
+ case .rateLimited: return "rateLimited"
288
+ case .concurrentRequests: return "concurrentRequests"
289
+ case .refusal: return "refusal"
290
+ @unknown default: return "unknown"
291
+ }
292
+ }
293
+
294
+ func mapGenerationError(_ id: String, _ error: LanguageModelSession.GenerationError) -> [String: Any] {
295
+ let name = errorName(error)
296
+ let message = error.errorDescription ?? error.localizedDescription
297
+ let extra: [String: Any] = ["appleError": name]
298
+ switch error {
299
+ case .guardrailViolation:
300
+ return failure(id, "refusal", "Apple guardrail refused the request: \(message)",
301
+ extra: extra.merging(["refusalSource": "guardrail"]) { a, _ in a })
302
+ case .refusal:
303
+ return failure(id, "refusal", "the model declined: \(message)",
304
+ extra: extra.merging(["refusalSource": "model"]) { a, _ in a })
305
+ case .rateLimited, .concurrentRequests:
306
+ return failure(id, "rate-limited", message, extra: extra)
307
+ case .exceededContextWindowSize, .unsupportedLanguageOrLocale, .unsupportedGuide:
308
+ return failure(id, "bad-request", message, extra: extra)
309
+ case .assetsUnavailable:
310
+ return failure(id, "unavailable", message, extra: extra)
311
+ default:
312
+ return failure(id, "adapter-error", message, extra: extra)
313
+ }
314
+ }
315
+
316
+ func samplingMode(_ request: [String: Any]) -> GenerationOptions.SamplingMode? {
317
+ if let topK = request["topK"] as? Int, topK > 0 { return .random(top: topK) }
318
+ if let topP = request["topP"] as? Double, topP > 0 { return .random(probabilityThreshold: topP) }
319
+ return nil
320
+ }
321
+
322
+ func countTokens(_ model: SystemLanguageModel, _ text: String) async -> Int {
323
+ if #available(macOS 26.4, *) {
324
+ return (try? await model.tokenCount(for: text)) ?? 0
325
+ }
326
+ // 0 = unknown, not free (the usage store reads it that way).
327
+ return 0
328
+ }
329
+
330
+ func generate(_ id: String, _ request: [String: Any]) async -> [String: Any] {
331
+ guard let prompt = request["prompt"] as? String else {
332
+ return failure(id, "bad-request", "generate needs a string prompt")
333
+ }
334
+ let model = SystemLanguageModel.default
335
+ if case .unavailable = model.availability {
336
+ var out = failure(id, "unavailable", "Apple Foundation Models is not available on this Mac")
337
+ out["availability"] = availabilityPayload()
338
+ return out
339
+ }
340
+ var schema: GenerationSchema?
341
+ if let raw = request["jsonSchema"] {
342
+ guard let node = raw as? [String: Any] else {
343
+ return failure(id, "bad-request", "jsonSchema must be an object")
344
+ }
345
+ do {
346
+ schema = try SchemaMapper().mapRoot(node)
347
+ } catch let refusal as SchemaRefusal {
348
+ return failure(id, "bad-request", refusal.message)
349
+ } catch {
350
+ return failure(id, "bad-request", "jsonSchema rejected: \(error.localizedDescription)")
351
+ }
352
+ }
353
+ let system = request["system"] as? String
354
+ let session = LanguageModelSession(model: model, instructions: system)
355
+ let options = GenerationOptions(
356
+ sampling: samplingMode(request),
357
+ temperature: request["temperature"] as? Double,
358
+ maximumResponseTokens: request["maxTokens"] as? Int
359
+ )
360
+ let started = Date()
361
+ do {
362
+ // STREAMED, and the loop checks cancellation per snapshot: `respond(to:)`
363
+ // honoured a cancel only after the whole answer was produced (measured:
364
+ // 16 s for a cancel sent at 0.5 s), so a gate that gave up kept the
365
+ // model busy for nobody. Leaving the loop stops the generation.
366
+ var text = ""
367
+ // A structured answer the stream ended before completing (maxTokens) is
368
+ // partial JSON: it must never come back as `ok`.
369
+ var structuredComplete = true
370
+ if let schema {
371
+ let stream = session.streamResponse(to: prompt, schema: schema, options: options)
372
+ for try await snapshot in stream {
373
+ try Task.checkCancellation()
374
+ text = snapshot.rawContent.jsonString
375
+ structuredComplete = snapshot.rawContent.isComplete
376
+ }
377
+ } else {
378
+ let stream = session.streamResponse(to: prompt, options: options)
379
+ for try await snapshot in stream {
380
+ try Task.checkCancellation()
381
+ text = snapshot.content
382
+ }
383
+ }
384
+ try Task.checkCancellation()
385
+ // Measured before the token counting below, which is bookkeeping.
386
+ let latencyMs = Int(Date().timeIntervalSince(started) * 1000)
387
+ if !structuredComplete {
388
+ return failure(id, "adapter-error",
389
+ "the structured answer was cut off before it was complete (maxTokens too low?)",
390
+ extra: ["truncated": true])
391
+ }
392
+ let inputTokens = await countTokens(model, (system ?? "") + "\n" + prompt)
393
+ let outputTokens = await countTokens(model, text)
394
+ // Known only where tokenCount exists (26.4+): 0 there means unknown, and
395
+ // unknown is reported as not truncated rather than guessed.
396
+ let maxTokens = request["maxTokens"] as? Int
397
+ let truncated = maxTokens.map { outputTokens > 0 && outputTokens >= $0 } ?? false
398
+ return [
399
+ "id": id,
400
+ "ok": true,
401
+ "text": text,
402
+ "usage": ["inputTokens": inputTokens, "outputTokens": outputTokens],
403
+ "truncated": truncated,
404
+ "latencyMs": latencyMs,
405
+ ]
406
+ } catch is CancellationError {
407
+ return failure(id, "cancelled", "cancelled by the caller")
408
+ } catch let error as LanguageModelSession.GenerationError {
409
+ return mapGenerationError(id, error)
410
+ } catch {
411
+ return failure(id, "adapter-error", error.localizedDescription)
412
+ }
413
+ }
414
+
415
+ // MARK: - Dispatch
416
+
417
+ let writer = LineWriter()
418
+ let registry = TaskRegistry()
419
+
420
+ func handle(_ line: String) {
421
+ guard !line.isEmpty else { return }
422
+ guard let data = line.data(using: .utf8),
423
+ let object = try? JSONSerialization.jsonObject(with: data),
424
+ let request = object as? [String: Any]
425
+ else {
426
+ writer.send(failure("", "bad-request", "request is not a JSON object"))
427
+ return
428
+ }
429
+ guard let id = request["id"] as? String, !id.isEmpty else {
430
+ writer.send(failure("", "bad-request", "request has no id"))
431
+ return
432
+ }
433
+ switch request["op"] as? String {
434
+ case "probe":
435
+ writer.send(["id": id, "ok": true, "protocol": protocolVersion, "availability": availabilityPayload()])
436
+ case "checkSchema":
437
+ guard let node = request["jsonSchema"] as? [String: Any] else {
438
+ writer.send(failure(id, "bad-request", "jsonSchema must be an object"))
439
+ return
440
+ }
441
+ do {
442
+ _ = try SchemaMapper().mapRoot(node)
443
+ writer.send(["id": id, "ok": true])
444
+ } catch let refusal as SchemaRefusal {
445
+ writer.send(failure(id, "bad-request", refusal.message))
446
+ } catch {
447
+ writer.send(failure(id, "bad-request", error.localizedDescription))
448
+ }
449
+ case "generate":
450
+ registry.reserve(id)
451
+ let task = Task {
452
+ let reply = await generate(id, request)
453
+ writer.send(reply)
454
+ registry.remove(id)
455
+ }
456
+ registry.attach(id, task)
457
+ case "cancel":
458
+ let target = request["target"] as? String ?? ""
459
+ let found = registry.cancel(target)
460
+ writer.send(["id": id, "ok": true, "found": found])
461
+ default:
462
+ writer.send(failure(id, "bad-request", "unknown op"))
463
+ }
464
+ }
465
+
466
+ /// After stdin closes, accepted generations may still finish and answer — a
467
+ /// piped caller (and the integration test) writes its requests and closes.
468
+ /// Bounded: a parent that is really gone gets its child back within this.
469
+ let drainOnEofSeconds = 60.0
470
+
471
+ Thread.detachNewThread {
472
+ while let line = readLine(strippingNewline: true) {
473
+ handle(line)
474
+ }
475
+ let deadline = Date().addingTimeInterval(drainOnEofSeconds)
476
+ while registry.count > 0 && Date() < deadline {
477
+ Thread.sleep(forTimeInterval: 0.05)
478
+ }
479
+ exit(0)
480
+ }
481
+ dispatchMain()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-ai",
3
- "version": "0.4.122",
3
+ "version": "0.4.124",
4
4
  "description": "AI addon for CamStack — the `llm` collection provider (cloud, LAN, and camstack-managed local llama.cpp profiles) plus the per-node `llm-runtime` managed executor.",
5
5
  "keywords": [
6
6
  "camstack",
@@ -50,10 +50,14 @@
50
50
  },
51
51
  "files": [
52
52
  "dist",
53
- "assets"
53
+ "assets",
54
+ "native/afm/camstack-afm",
55
+ "native/afm/camstack-afm.swift",
56
+ "THIRD_PARTY_MODELS.md"
54
57
  ],
55
58
  "scripts": {
56
- "build": "vite build",
59
+ "build": "node scripts/build-afm.mjs && vite build",
60
+ "build:native": "node scripts/build-afm.mjs --force",
57
61
  "dev": "vite build --watch",
58
62
  "typecheck": "tsc --noEmit",
59
63
  "test": "vitest run",