@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.
- package/THIRD_PARTY_MODELS.md +233 -0
- package/dist/addon.js +7474 -1393
- package/dist/addon.mjs +7486 -1406
- package/native/afm/camstack-afm +0 -0
- package/native/afm/camstack-afm.swift +481 -0
- package/package.json +7 -3
|
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.
|
|
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",
|