@dvai-bridge/ios-foundation-core 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,358 @@
1
+ DVAI BRIDGE COMMUNITY LICENCE
2
+ Version 1.0 — "DVAI-BCL v1.0"
3
+ Copyright (c) 2026 Deep Voice AI Limited (Company No. 16743132)
4
+
5
+ ================================================================================
6
+ NOTICE TO USER
7
+ ================================================================================
8
+
9
+ This is the legal text of the DVAI Bridge Community Licence ("DVAI-BCL v1.0",
10
+ or "this Licence"). DVAI Bridge is source-available software developed by Deep
11
+ Voice AI Limited, a company incorporated in England and Wales with registered
12
+ number 16743132 ("Licensor", "we", "us"). Anyone may read, modify, build, and
13
+ redistribute this software under the terms set out below.
14
+
15
+ You may use DVAI Bridge free of charge for Permitted Uses (defined below).
16
+ Commercial Use of DVAI Bridge requires a separate Commercial Licence from us.
17
+ On the Change Date specified below, each Released Version of DVAI Bridge
18
+ automatically converts to the Apache License, Version 2.0 ("Apache 2.0"). The
19
+ conversion is irrevocable and is the structural commitment by which we
20
+ guarantee the eventual full open-source availability of DVAI Bridge.
21
+
22
+ This Licence is loosely modelled on the Business Source License 1.1 (BSL 1.1)
23
+ published by MariaDB Corporation Ab, with significant customisations to
24
+ support DVAI's dual-licensing commercial model, defined Permitted Uses, and
25
+ patent grant. It is not a Business Source License and the Licensor License
26
+ Grant in BSL 1.1 has been replaced.
27
+
28
+ A plain-language summary of this Licence is published at
29
+ https://deepvoiceai.co/licensing. The summary is informational only; this
30
+ file is the legally binding instrument.
31
+
32
+ ================================================================================
33
+ DEFINITIONS
34
+ ================================================================================
35
+
36
+ In this Licence the following definitions apply:
37
+
38
+ "Apache 2.0" means the Apache License, Version 2.0 published by the Apache
39
+ Software Foundation and available at https://www.apache.org/licenses/LICENSE-2.0,
40
+ together with the corresponding NOTICE-file conventions.
41
+
42
+ "Change Date" means, in respect of a Released Version, the date that is three
43
+ (3) years after the Public Release Date of that Released Version, as recorded
44
+ in the Change Date Register published in the source repository (file
45
+ CHANGE_DATE.md). On and after the Change Date for a Released Version, that
46
+ Released Version automatically and irrevocably converts to Apache 2.0.
47
+
48
+ "Commercial Licence" means a separate written commercial licence agreement
49
+ issued by the Licensor authorising Commercial Use, on the commercial terms
50
+ published from time to time at https://deepvoiceai.co/licensing or
51
+ individually negotiated with the Licensor.
52
+
53
+ "Commercial Use" means any use of DVAI Bridge in or in connection with a
54
+ product, service, or activity that generates revenue, including without
55
+ limitation:
56
+ (a) integration of DVAI Bridge into a product or service that you sell,
57
+ license, host, or otherwise make available to third parties for value;
58
+ (b) provision of inference services, AI services, or hosted offerings to
59
+ third parties using DVAI Bridge;
60
+ (c) use of DVAI Bridge by, or for the benefit of, any organisation that
61
+ generates revenue from any commercial offering in which DVAI Bridge is
62
+ a component (whether or not DVAI Bridge itself is monetised
63
+ separately).
64
+ Commercial Use does not include any of the Permitted Uses defined below.
65
+
66
+ "DVAI Bridge" means the software in the source repository at
67
+ https://github.com/dvai-global/dvai-bridge ("the Repository"), including the
68
+ source code, build outputs, language-binding packages, and any documentation
69
+ distributed alongside it, in each case as published by the Licensor.
70
+
71
+ "Licence" means this DVAI Bridge Community Licence v1.0 in its entirety,
72
+ together with the NOTICE file in the Repository.
73
+
74
+ "Permitted Use" means each of the following uses of DVAI Bridge, in each case
75
+ free of charge and free of any obligation to enter into a Commercial Licence:
76
+ (a) Personal use — use of DVAI Bridge by an individual for personal,
77
+ non-commercial purposes, including hobby projects, personal-learning
78
+ builds, and experimentation outside the scope of any commercial
79
+ activity;
80
+ (b) Educational use — use of DVAI Bridge in teaching, coursework, training
81
+ materials, student projects, and other educational activities of an
82
+ educational institution or a teacher acting in that capacity;
83
+ (c) Academic research — use of DVAI Bridge in academic research the outputs
84
+ of which are intended for publication, dissemination, or peer review,
85
+ where DVAI Bridge is referenced or cited in the resulting outputs;
86
+ (d) Evaluation — use of DVAI Bridge by an organisation for a period not
87
+ exceeding ninety (90) consecutive days for the purpose of evaluating
88
+ whether to enter into a Commercial Licence;
89
+ (e) Internal-only use — use of DVAI Bridge by an organisation for purely
90
+ internal purposes, where the capability provided by DVAI Bridge is not
91
+ distributed to, used by, or made available to any third party outside
92
+ the organisation, and is not used to generate revenue from any
93
+ external party;
94
+ (f) Contribution — use of DVAI Bridge in connection with the preparation,
95
+ testing, and submission of contributions to the Repository, subject to
96
+ the Licensor's then-current Contributor Licence Agreement.
97
+
98
+ "Public Release Date" means, in respect of a Released Version, the date on
99
+ which the Licensor first made that Released Version generally available to the
100
+ public through the Repository (typically the date of the corresponding Git
101
+ tag), as recorded in the Change Date Register (file CHANGE_DATE.md).
102
+
103
+ "Released Version" means a specific version of DVAI Bridge made publicly
104
+ available by the Licensor through the Repository, identified by a Git tag
105
+ matching the pattern "v<MAJOR>.<MINOR>.<PATCH>" or any successor versioning
106
+ scheme published by the Licensor.
107
+
108
+ "You" (or "Your") means the natural person or legal entity exercising the
109
+ rights granted by this Licence. For legal entities, "You" includes each
110
+ entity that controls, is controlled by, or is under common control with that
111
+ entity.
112
+
113
+ ================================================================================
114
+ 1. GRANT OF LICENCE FOR PERMITTED USE
115
+ ================================================================================
116
+
117
+ 1.1 Subject to Your compliance with the terms of this Licence, the Licensor
118
+ hereby grants You a worldwide, non-exclusive, royalty-free, non-transferable,
119
+ non-sublicensable, revocable (only as expressly stated in Sections 7 and 8)
120
+ copyright licence to reproduce, prepare derivative works of, publicly
121
+ display, publicly perform, and distribute DVAI Bridge for Permitted Use.
122
+
123
+ 1.2 The Licence in Section 1.1 includes the right to modify DVAI Bridge and
124
+ to redistribute DVAI Bridge (in original or modified form) provided that:
125
+ (a) every copy and every derivative work redistributed by You is distributed
126
+ under, and accompanied by, the unmodified text of this Licence;
127
+ (b) every copy and every derivative work retains all copyright, attribution,
128
+ and other proprietary notices appearing in the original;
129
+ (c) any redistribution is accompanied by the unmodified text of the NOTICE
130
+ file as published in the Repository at the time of redistribution; and
131
+ (d) the redistribution itself is for a Permitted Use, or is performed in a
132
+ manner that does not involve Commercial Use by You.
133
+
134
+ 1.3 The Licence in Section 1.1 does not authorise Commercial Use. If You wish
135
+ to use DVAI Bridge for any Commercial Use, You must first obtain a Commercial
136
+ Licence from the Licensor. Information about how to obtain a Commercial
137
+ Licence is published at https://deepvoiceai.co/licensing and may be requested
138
+ by email to info@deepvoiceai.co.
139
+
140
+ ================================================================================
141
+ 2. AUTOMATIC CONVERSION ON THE CHANGE DATE
142
+ ================================================================================
143
+
144
+ 2.1 On and after the Change Date for a Released Version, that Released
145
+ Version (including all source code, build outputs, and documentation included
146
+ in that Released Version) automatically and irrevocably converts to Apache 2.0.
147
+
148
+ 2.2 The conversion in Section 2.1 means that, on and after the Change Date,
149
+ the Released Version may be used, copied, modified, distributed, and used for
150
+ any Commercial Use without a Commercial Licence and without obligation to the
151
+ Licensor, subject only to the terms of Apache 2.0.
152
+
153
+ 2.3 The conversion is per Released Version. Released Versions that have not
154
+ yet reached their Change Date remain subject to this Licence. Each subsequent
155
+ Released Version is subject to this Licence for its own three-year period
156
+ before independently converting to Apache 2.0.
157
+
158
+ 2.4 The Change Date for each Released Version is recorded in the Change Date
159
+ Register in the Repository (file CHANGE_DATE.md). The Licensor commits that
160
+ once a Change Date has been recorded in the Change Date Register, the Change
161
+ Date will not be moved backwards (made later) by any subsequent revision of
162
+ this Licence or any other instrument.
163
+
164
+ 2.5 The Bridge Attribution requirements in Section 4 cease to apply to a
165
+ Released Version on and after its Change Date. The standard NOTICE-file
166
+ conventions of Apache 2.0 continue to apply.
167
+
168
+ ================================================================================
169
+ 3. GRANT OF PATENT LICENCE
170
+ ================================================================================
171
+
172
+ 3.1 Subject to the terms and conditions of this Licence, the Licensor hereby
173
+ grants You a worldwide, non-exclusive, royalty-free, non-transferable,
174
+ non-sublicensable patent licence under the Licensor's Patent Rights to make,
175
+ have made, use, import, sell, offer to sell, and otherwise dispose of DVAI
176
+ Bridge solely for Permitted Use.
177
+
178
+ 3.2 "Patent Rights" means the patent claims owned or controlled by the
179
+ Licensor (including, without limitation, the claims of UK Patent Application
180
+ GB2611312.6 once granted) that, but for this grant, would be infringed by use
181
+ of DVAI Bridge as originally distributed by the Licensor.
182
+
183
+ 3.3 The patent licence in Section 3.1 does not extend to Commercial Use.
184
+ Patent rights for Commercial Use are granted only under a separate Commercial
185
+ Licence.
186
+
187
+ 3.4 If You institute patent litigation (including a cross-claim or
188
+ counterclaim in a lawsuit) against any entity alleging that DVAI Bridge
189
+ constitutes direct or contributory patent infringement, then any patent
190
+ licences granted to You under this Section 3 shall terminate as of the date
191
+ such litigation is filed, without prejudice to any other remedy the Licensor
192
+ may have.
193
+
194
+ ================================================================================
195
+ 4. BRIDGE ATTRIBUTION MARK
196
+ ================================================================================
197
+
198
+ 4.1 When You exercise the licence in Section 1, You shall:
199
+ (a) retain in the source code of DVAI Bridge the copyright notice, this
200
+ Licence text, and the NOTICE file unmodified;
201
+ (b) where DVAI Bridge is incorporated into a build artefact (including a
202
+ published package, binary, container image, or installable application),
203
+ reproduce the substance of the copyright notice and a reference to this
204
+ Licence in a place reasonably accessible to recipients of that artefact
205
+ (for example, an About box, a credits file, a licensing page, or
206
+ documentation distributed with the artefact); and
207
+ (c) not remove or obscure the "Powered by DVAI Bridge" attribution mark, or
208
+ its equivalent in any localised form, in any user-visible or developer-
209
+ visible interface in which it is present in DVAI Bridge as distributed
210
+ by the Licensor.
211
+
212
+ 4.2 The Bridge Attribution Mark may be removed from a build artefact only by
213
+ a Commercial Licensee under the terms of their Commercial Licence with the
214
+ Licensor. Removal of the Bridge Attribution Mark in the absence of a
215
+ Commercial Licence is a material breach of this Licence.
216
+
217
+ 4.3 The Bridge Attribution Mark requirements cease to apply to a Released
218
+ Version on and after its Change Date.
219
+
220
+ ================================================================================
221
+ 5. TRADEMARKS
222
+ ================================================================================
223
+
224
+ 5.1 Nothing in this Licence grants You any right to use the trademarks of
225
+ the Licensor or its affiliates, including without limitation "Deep Voice AI",
226
+ "DVAI", "DVAI Bridge", or the corresponding logos and stylisations
227
+ (collectively, "Licensor Marks"), other than:
228
+ (a) reproduction of the Licensor Marks as part of the unmodified copyright
229
+ notices, NOTICE file, and Bridge Attribution Mark as required by
230
+ Section 4;
231
+ (b) descriptive use of the Licensor Marks in factual references such as
232
+ "based on DVAI Bridge" or "compatible with DVAI Bridge", provided that
233
+ such use is accurate, does not suggest endorsement by the Licensor of
234
+ any third-party product, and does not use the Licensor Marks in a
235
+ stylised, dominant, or distinctive manner that would create a
236
+ likelihood of confusion as to source.
237
+
238
+ 5.2 Trademark rights of third parties referenced in DVAI Bridge or in the
239
+ NOTICE file (including without limitation "Apple", "Android", "Google",
240
+ "Microsoft", "Capacitor", "React Native", "Flutter", ".NET", "iOS",
241
+ "macOS") are the property of their respective owners and are reserved.
242
+
243
+ ================================================================================
244
+ 6. NO WARRANTY
245
+ ================================================================================
246
+
247
+ DVAI BRIDGE IS PROVIDED "AS IS" AND "AS AVAILABLE", WITHOUT WARRANTY OF ANY
248
+ KIND, EXPRESS, IMPLIED, OR STATUTORY, INCLUDING WITHOUT LIMITATION ANY
249
+ WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR
250
+ FITNESS FOR A PARTICULAR PURPOSE. THE LICENSOR DOES NOT WARRANT THAT DVAI
251
+ BRIDGE WILL BE ERROR-FREE, UNINTERRUPTED, SECURE, OR THAT IT WILL MEET YOUR
252
+ REQUIREMENTS. ANY USE BY YOU OF DVAI BRIDGE IS AT YOUR OWN RISK.
253
+
254
+ ================================================================================
255
+ 7. LIMITATION OF LIABILITY
256
+ ================================================================================
257
+
258
+ 7.1 To the maximum extent permitted by applicable law, the Licensor shall
259
+ not be liable for any indirect, incidental, special, consequential, or
260
+ exemplary damages, including without limitation loss of profits, reputation, revenue,
261
+ business, data, or goodwill, arising out of or in connection with this
262
+ Licence or Your use of DVAI Bridge, even if the Licensor has been advised of
263
+ the possibility of such damages.
264
+
265
+ 7.2 To the maximum extent permitted by applicable law, the aggregate
266
+ liability of the Licensor arising out of or in connection with this Licence
267
+ and Your use of DVAI Bridge shall not exceed one hundred pounds sterling
268
+ (GBP 100). For Permitted Use, this Licence is provided to You free of
269
+ charge; this limitation reflects the absence of any consideration paid by
270
+ You to the Licensor for the licence granted here.
271
+
272
+ 7.3 Nothing in this Licence excludes or limits liability for death or
273
+ personal injury caused by negligence, for fraud or fraudulent
274
+ misrepresentation, or for any other liability that cannot be excluded or
275
+ limited as a matter of applicable law.
276
+
277
+ ================================================================================
278
+ 8. TERMINATION
279
+ ================================================================================
280
+
281
+ 8.1 This Licence terminates automatically and without notice upon any
282
+ material breach by You of its terms, including without limitation:
283
+ (a) Commercial Use of DVAI Bridge without a Commercial Licence;
284
+ (b) removal or modification of the copyright notice, this Licence text, the
285
+ NOTICE file, or the Bridge Attribution Mark contrary to Section 4;
286
+ (c) institution by You of patent litigation in the circumstances described
287
+ in Section 3.4.
288
+
289
+ 8.2 On termination under Section 8.1, You shall immediately cease all use of
290
+ DVAI Bridge, delete all copies of DVAI Bridge in Your possession, and procure
291
+ that any third party to whom You have redistributed DVAI Bridge does the
292
+ same.
293
+
294
+ 8.3 If Your breach is curable and is in fact cured within thirty (30) days of
295
+ written notice from the Licensor, the Licensor may at its discretion
296
+ reinstate the Licence as if the breach had not occurred. Reinstatement is at
297
+ the Licensor's sole discretion.
298
+
299
+ 8.4 Sections 3.4, 5, 6, 7, 9, and 10 survive termination of this Licence.
300
+
301
+ ================================================================================
302
+ 9. RELATIONSHIP WITH OTHER AGREEMENTS
303
+ ================================================================================
304
+
305
+ 9.1 This Licence applies only to Released Versions for the period from the
306
+ Public Release Date to the Change Date. On and after the Change Date for a
307
+ Released Version, that Released Version is governed exclusively by Apache 2.0
308
+ and this Licence has no further application to that Released Version, save
309
+ that the patent termination provisions of Section 3.4 continue to apply to
310
+ the conduct of any party that institutes patent litigation as described in
311
+ that Section.
312
+
313
+ 9.2 A Commercial Licence supersedes this Licence as between the Licensor and
314
+ the Commercial Licensee in respect of the uses authorised by the Commercial
315
+ Licence. This Licence continues to apply to all other uses of DVAI Bridge by
316
+ the Commercial Licensee not authorised by the Commercial Licence.
317
+
318
+ 9.3 Contributors to DVAI Bridge are subject to the Licensor's Contributor
319
+ Licence Agreement ("CLA") in addition to this Licence. The CLA is published
320
+ at https://deepvoiceai.co/cla.
321
+
322
+ ================================================================================
323
+ 10. GOVERNING LAW
324
+ ================================================================================
325
+
326
+ 10.1 This Licence is governed by and construed in accordance with the law of
327
+ England and Wales.
328
+
329
+ 10.2 The courts of England and Wales have non-exclusive jurisdiction to
330
+ settle any dispute arising out of or in connection with this Licence.
331
+
332
+ ================================================================================
333
+ 11. CONTACT
334
+ ================================================================================
335
+
336
+ For all enquiries about this Licence, including Commercial Licence enquiries,
337
+ contact info@deepvoiceai.co.
338
+
339
+ Deep Voice AI Limited
340
+ 71-75 Shelton Street
341
+ Covent Garden
342
+ London WC2H 9JQ
343
+ United Kingdom
344
+
345
+ Company No. 16743132
346
+
347
+ ================================================================================
348
+ END OF DVAI BRIDGE COMMUNITY LICENCE v1.0
349
+ ================================================================================
350
+
351
+ NOTE ON LEGAL REVIEW
352
+ --------------------
353
+
354
+ This Licence text has been prepared by the Licensor and is the legally
355
+ binding instrument under which DVAI Bridge is distributed. The Licensor recommends
356
+ that counsel competent in the law of England and Wales be consulted prior to
357
+ substantive variation of this text or before entering into any Commercial
358
+ Licence based on this Licence's framework.
package/Package.swift ADDED
@@ -0,0 +1,45 @@
1
+ // swift-tools-version: 5.9
2
+ import PackageDescription
3
+
4
+ // FoundationModels deployment-target rationale: link-time floor is iOS 18.1
5
+ // (the SDK-emitted symbols are weak-linked and resolve at runtime); the
6
+ // FoundationModels public API itself is `@available(iOS 26.0, *)` in the
7
+ // Xcode 26.4 SDK, so calling it requires runtime guarding. SwiftPM 5.9's
8
+ // `.iOS` enum maxes out at `.v17`, hence the string-based `.iOS(_:)`
9
+ // initializer which accepts arbitrary version strings like "18.1".
10
+ let package = Package(
11
+ name: "DVAIFoundationCore",
12
+ platforms: [.iOS("18.1"), .macOS(.v14)],
13
+ products: [
14
+ .library(name: "DVAIFoundationCore", targets: ["DVAIFoundationCore"]),
15
+ ],
16
+ dependencies: [
17
+ // Shared HTTP-server / handler-dispatch types (formerly duplicated
18
+ // inline; now extracted into dvai-bridge-ios-shared-core for reuse
19
+ // across all backend cores). Path-dep identity =
20
+ // "dvai-bridge-ios-shared-core". DVAISharedCore brings in
21
+ // Hummingbird transitively as of v3.2.0 — the iOS HTTP server
22
+ // backbone is no longer Telegraph.
23
+ .package(path: "../dvai-bridge-ios-shared-core"),
24
+ ],
25
+ targets: [
26
+ // Package.swift sits at the package ROOT (not under `ios/`) so SPM derives
27
+ // identity "dvai-bridge-ios-foundation-core" — unique among siblings.
28
+ // Target paths are relative to Package.swift, hence the `ios/` prefix.
29
+ .target(
30
+ name: "DVAIFoundationCore",
31
+ dependencies: [
32
+ .product(name: "DVAISharedCore", package: "dvai-bridge-ios-shared-core"),
33
+ ],
34
+ path: "ios/Sources/DVAIFoundationCore"
35
+ ),
36
+ .testTarget(
37
+ name: "DVAIFoundationCoreTests",
38
+ dependencies: [
39
+ "DVAIFoundationCore",
40
+ .product(name: "DVAISharedCore", package: "dvai-bridge-ios-shared-core"),
41
+ ],
42
+ path: "ios/Tests/DVAIFoundationCoreTests"
43
+ ),
44
+ ]
45
+ )
@@ -0,0 +1,62 @@
1
+ // Internal/AsyncSemaphore.swift
2
+ //
3
+ // A simple counting semaphore that's safe to await across suspension points.
4
+ // `value: 1` makes it a mutex; multiple `wait()`s queue up and resume in FIFO.
5
+ //
6
+ // Used by `FoundationHandlers` to serialize concurrent `LanguageModelSession`
7
+ // inference calls (`respond(to:)` / `streamResponse(to:)`). An NSLock around
8
+ // an `await` would deadlock because it isn't async-aware; we need a
9
+ // continuation-based async semaphore instead.
10
+
11
+ import Foundation
12
+
13
+ final class AsyncSemaphore: @unchecked Sendable {
14
+ private let lock = NSLock()
15
+ private var value: Int
16
+ private var waiters: [CheckedContinuation<Void, Never>] = []
17
+
18
+ init(value: Int = 1) { self.value = value }
19
+
20
+ /// Try to take a permit synchronously without crossing a suspension
21
+ /// point. Returns `nil` if a permit was acquired and the caller can
22
+ /// proceed; returns a non-nil "register continuation" closure when the
23
+ /// caller must instead `await` to be resumed by a future `signal()`.
24
+ /// Splitting the NSLock manipulation into this sync helper keeps the
25
+ /// lock from being touched inside an `async` context — NSLock is not
26
+ /// async-safe (warning becomes an error in Swift 6).
27
+ private func tryAcquireOrRegister() -> ((CheckedContinuation<Void, Never>) -> Void)? {
28
+ lock.lock()
29
+ if value > 0 {
30
+ value -= 1
31
+ lock.unlock()
32
+ return nil
33
+ }
34
+ // Returning the locked-state register hook: caller stashes the
35
+ // continuation, then we drop the lock. This preserves FIFO ordering
36
+ // because a concurrent `signal()` blocks on the same NSLock until
37
+ // the continuation is appended.
38
+ return { [self] cont in
39
+ waiters.append(cont)
40
+ lock.unlock()
41
+ }
42
+ }
43
+
44
+ func wait() async {
45
+ guard let register = tryAcquireOrRegister() else { return }
46
+ await withCheckedContinuation { cont in
47
+ register(cont)
48
+ }
49
+ }
50
+
51
+ func signal() {
52
+ lock.lock()
53
+ if !waiters.isEmpty {
54
+ let cont = waiters.removeFirst()
55
+ lock.unlock()
56
+ cont.resume()
57
+ } else {
58
+ value += 1
59
+ lock.unlock()
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,391 @@
1
+ // Internal/FoundationHandlers.swift
2
+ //
3
+ // OpenAI-compatible handler set backed by Apple's `FoundationModels`
4
+ // framework (`LanguageModelSession`).
5
+ //
6
+ // Availability — DEVIATION FROM PLAN: the plan documented `iOS 18.1+`,
7
+ // but on the current Xcode 26.4 / iPhoneSimulator26.4 SDK, every public
8
+ // `LanguageModelSession` symbol carries `@available(iOS 26.0, *)`
9
+ // (likewise macOS 26 / macCatalyst 26 / visionOS 26). The Apple
10
+ // FoundationModels framework's runtime requirement was raised between
11
+ // the early plan draft and Xcode 26's release. We therefore guard the
12
+ // whole class with `@available(iOS 26.0, macOS 26.0, *)`. The package's
13
+ // SwiftPM `iOS("18.1")` floor stays — the framework is a strict
14
+ // availability annotation, not a hard link, so older iOS 18.1+ apps
15
+ // still build; they just cannot call into FoundationHandlers without
16
+ // their own `if #available(iOS 26, *)` check.
17
+ //
18
+ // Phase 1 scope (per spec §8.4 modality matrix): text only.
19
+ // - Image content parts → 400 (spec §8.5 wording).
20
+ // - Audio content parts → 400 (spec §8.5 wording).
21
+ // - /v1/embeddings → 400 (Apple FM has no public embedding API).
22
+ //
23
+ // Streaming: 4-frame SSE envelope (role / content* / finish / [DONE]),
24
+ // mirroring `LlamaHandlers`. Multiple content frames are emitted, one per
25
+ // partial response yielded by `LanguageModelSession.streamResponse(to:)`.
26
+ // Telegraph 0.40 buffers the SSE body server-side so per-frame flushing
27
+ // is identical to single-frame to clients today.
28
+ //
29
+ // Concurrency: `LanguageModelSession` is a stateful conversation object;
30
+ // concurrent requests would interleave turns and corrupt Apple FM state.
31
+ // Concurrent inference calls are serialized via `inferenceLock` (an async
32
+ // semaphore) — an NSLock around an `await` would deadlock because it isn't
33
+ // async-aware. The pre-existing `sessionLock` (NSLock) keeps guarding lazy
34
+ // session creation in `ensureSession()` only.
35
+ //
36
+ // Host-build guard: `#if canImport(FoundationModels)` keeps the file
37
+ // compilable on macOS hosts whose Xcode SDK predates FoundationModels.
38
+ // On those hosts the entire class compiles out and the test target
39
+ // guards itself the same way.
40
+
41
+ #if canImport(FoundationModels)
42
+ import FoundationModels
43
+ #endif
44
+ import Foundation
45
+ #if !COCOAPODS
46
+ import DVAISharedCore
47
+ #endif
48
+
49
+ #if canImport(FoundationModels)
50
+ @available(iOS 26.0, macOS 26.0, *)
51
+ public final class FoundationHandlers: DVAIHandlers, @unchecked Sendable {
52
+ private let modelId: String
53
+ private var session: LanguageModelSession?
54
+ private let sessionLock = NSLock()
55
+ /// Serializes `session.respond(to:)` / `session.streamResponse(to:)`
56
+ /// calls so concurrent /v1/chat/completions requests can't interleave
57
+ /// turns on the same conversational `LanguageModelSession` and corrupt
58
+ /// Apple FM state. `sessionLock` only guards the lazy-creation path in
59
+ /// `ensureSession()`; it cannot be held across an `await`.
60
+ private let inferenceLock = AsyncSemaphore(value: 1)
61
+
62
+ public init(modelId: String = "apple-foundation-3b") {
63
+ self.modelId = modelId
64
+ }
65
+
66
+ private func ensureSession() -> LanguageModelSession {
67
+ sessionLock.lock()
68
+ defer { sessionLock.unlock() }
69
+ if let s = session { return s }
70
+ let s = LanguageModelSession()
71
+ session = s
72
+ return s
73
+ }
74
+
75
+ /// Null out the cached session under `sessionLock` so the next request
76
+ /// lazy-creates a fresh one. Called from inference error paths to avoid
77
+ /// reusing a (potentially poisoned) conversational session. This does
78
+ /// NOT touch `inferenceLock` — the in-flight semaphore acquisition is
79
+ /// independent of which session reference is cached.
80
+ private func resetSession() {
81
+ sessionLock.lock()
82
+ self.session = nil
83
+ sessionLock.unlock()
84
+ }
85
+
86
+ // MARK: - /v1/chat/completions
87
+
88
+ public func handleChatCompletion(body: [String: Any], ctx: HandlerContext) async throws -> HandlerResponse {
89
+ // Validate messages field shape up-front. Matches LlamaHandlers' pattern.
90
+ guard let messages = body["messages"] as? [[String: Any]] else {
91
+ return .error(400, "Missing 'messages' field")
92
+ }
93
+ if messages.isEmpty {
94
+ return .error(400, "Empty messages array")
95
+ }
96
+
97
+ // Reject image / audio content parts up-front (spec §8.5 wording).
98
+ for msg in messages {
99
+ if let parts = msg["content"] as? [[String: Any]] {
100
+ for part in parts {
101
+ if let type = part["type"] as? String {
102
+ if type == "image_url" {
103
+ return .error(400, "Image input not supported by Apple Foundation Models in this version.")
104
+ }
105
+ if type == "input_audio" {
106
+ return .error(400, "Audio input not supported by Apple Foundation Models in this version.")
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ let prompt = openAIMessagesToPrompt(messages)
114
+ let session = ensureSession()
115
+ let id = "chatcmpl-fm-\(UUID().uuidString.prefix(20).lowercased())"
116
+ let created = Int(Date().timeIntervalSince1970)
117
+
118
+ if (body["stream"] as? Bool) == true {
119
+ // Acquire the inference semaphore BEFORE building the stream so the
120
+ // entire `for try await partial in ...` loop holds it. The Task
121
+ // releases it in a defer, which is fine because `signal()` is sync.
122
+ await inferenceLock.wait()
123
+
124
+ let modelId = self.modelId
125
+ // `[modelId]` capture: explicit so the Sendable closure can refer
126
+ // to the immutable string without retaining `self`.
127
+ let stream = AsyncStream<String> { [inferenceLock] continuation in
128
+ let task = Task { [modelId, weak self] in
129
+ defer { inferenceLock.signal() }
130
+ do {
131
+ // Frame 1: role delta
132
+ let roleChunk: [String: Any] = [
133
+ "id": id,
134
+ "object": "chat.completion.chunk",
135
+ "created": created,
136
+ "model": modelId,
137
+ "choices": [[
138
+ "index": 0,
139
+ "delta": ["role": "assistant"],
140
+ ] as [String: Any]],
141
+ ]
142
+ if let s = Self.serialize(roleChunk) {
143
+ continuation.yield("data: \(s)\n\n")
144
+ }
145
+
146
+ // Frames 2..N: content deltas, one per partial response.
147
+ // DEVIATION FROM PLAN: plan used `responseStream(to:)` —
148
+ // actual API is `streamResponse(to:)` (returns
149
+ // `ResponseStream`, conforms to `AsyncSequence`,
150
+ // partials expose `.content`).
151
+ for try await partial in session.streamResponse(to: prompt) {
152
+ let chunk: [String: Any] = [
153
+ "id": id,
154
+ "object": "chat.completion.chunk",
155
+ "created": created,
156
+ "model": modelId,
157
+ "choices": [[
158
+ "index": 0,
159
+ "delta": ["content": partial.content],
160
+ ] as [String: Any]],
161
+ ]
162
+ if let s = Self.serialize(chunk) {
163
+ continuation.yield("data: \(s)\n\n")
164
+ }
165
+ }
166
+
167
+ // Frame N+1: finish
168
+ let finishChunk: [String: Any] = [
169
+ "id": id,
170
+ "object": "chat.completion.chunk",
171
+ "created": created,
172
+ "model": modelId,
173
+ "choices": [[
174
+ "index": 0,
175
+ "delta": [:] as [String: Any],
176
+ "finish_reason": "stop",
177
+ ] as [String: Any]],
178
+ ]
179
+ if let s = Self.serialize(finishChunk) {
180
+ continuation.yield("data: \(s)\n\n")
181
+ }
182
+
183
+ continuation.yield("data: [DONE]\n\n")
184
+ } catch {
185
+ // Stream errored mid-flight. Emit a finish chunk with
186
+ // finish_reason: "error" plus an error message so the
187
+ // client can distinguish truncation from completion,
188
+ // then forward [DONE]. Reset the session afterwards so
189
+ // a poisoned conversational instance isn't reused on
190
+ // the next request.
191
+ let errorChunk: [String: Any] = [
192
+ "id": id,
193
+ "object": "chat.completion.chunk",
194
+ "created": created,
195
+ "model": modelId,
196
+ "choices": [[
197
+ "index": 0,
198
+ "delta": [:] as [String: Any],
199
+ "finish_reason": "error",
200
+ ] as [String: Any]],
201
+ "error": ["message": error.localizedDescription],
202
+ ]
203
+ if let s = Self.serialize(errorChunk) {
204
+ continuation.yield("data: \(s)\n\n")
205
+ }
206
+ continuation.yield("data: [DONE]\n\n")
207
+ self?.resetSession()
208
+ }
209
+ continuation.finish()
210
+ }
211
+
212
+ // If the HTTP client disconnects (or the stream is otherwise
213
+ // released) cancel the upstream Task so Apple FM stops
214
+ // generating tokens nobody will read. `streamResponse(to:)`
215
+ // is an AsyncSequence and respects Swift Concurrency
216
+ // cancellation, propagating to Apple FM.
217
+ continuation.onTermination = { @Sendable _ in
218
+ task.cancel()
219
+ }
220
+ }
221
+ return .sse(stream)
222
+ }
223
+
224
+ // Non-streaming
225
+ await inferenceLock.wait()
226
+ do {
227
+ let response = try await session.respond(to: prompt)
228
+ inferenceLock.signal()
229
+ let json: [String: Any] = [
230
+ "id": id,
231
+ "object": "chat.completion",
232
+ "created": created,
233
+ "model": modelId,
234
+ "choices": [[
235
+ "index": 0,
236
+ "message": ["role": "assistant", "content": response.content],
237
+ "finish_reason": "stop",
238
+ ] as [String: Any]],
239
+ "usage": ["prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0],
240
+ ]
241
+ return .json(200, json)
242
+ } catch {
243
+ inferenceLock.signal()
244
+ resetSession()
245
+ return .error(500, error.localizedDescription)
246
+ }
247
+ }
248
+
249
+ // MARK: - /v1/completions (legacy)
250
+
251
+ public func handleCompletion(body: [String: Any], ctx: HandlerContext) async throws -> HandlerResponse {
252
+ let promptField = body["prompt"]
253
+ let prompt: String
254
+ if let s = promptField as? String {
255
+ prompt = s
256
+ } else if let arr = promptField as? [String] {
257
+ prompt = arr.joined(separator: "\n")
258
+ } else {
259
+ prompt = ""
260
+ }
261
+
262
+ var chatBody = body
263
+ chatBody["messages"] = [["role": "user", "content": prompt]]
264
+ chatBody.removeValue(forKey: "prompt")
265
+
266
+ let chatResp = try await handleChatCompletion(body: chatBody, ctx: ctx)
267
+ switch chatResp {
268
+ case .json(let status, let chatBodyAny):
269
+ guard status == 200, let chat = chatBodyAny as? [String: Any] else {
270
+ return chatResp
271
+ }
272
+ return .json(200, chatToLegacyCompletion(chat))
273
+ case .sse(let chatStream):
274
+ let legacyStream = AsyncStream<String> { continuation in
275
+ Task {
276
+ for await chunk in chatStream {
277
+ continuation.yield(self.adaptChunkToLegacy(chunk))
278
+ }
279
+ continuation.finish()
280
+ }
281
+ }
282
+ return .sse(legacyStream)
283
+ case .error:
284
+ return chatResp
285
+ }
286
+ }
287
+
288
+ // MARK: - /v1/embeddings
289
+
290
+ public func handleEmbeddings(body: [String: Any], ctx: HandlerContext) async throws -> HandlerResponse {
291
+ return .error(400, "Embeddings not supported on Apple Foundation Models in this version.")
292
+ }
293
+
294
+ // MARK: - /v1/models
295
+
296
+ public func handleModels(ctx: HandlerContext) async throws -> HandlerResponse {
297
+ return .json(200, [
298
+ "object": "list",
299
+ "data": [["id": modelId, "object": "model", "owned_by": "apple"] as [String: Any]],
300
+ ])
301
+ }
302
+
303
+ // MARK: - Helpers
304
+
305
+ private func openAIMessagesToPrompt(_ messages: [[String: Any]]) -> String {
306
+ var lines: [String] = []
307
+ for msg in messages {
308
+ let role = (msg["role"] as? String) ?? "user"
309
+ if let text = msg["content"] as? String {
310
+ lines.append("\(role): \(text)")
311
+ } else if let parts = msg["content"] as? [[String: Any]] {
312
+ for part in parts {
313
+ if (part["type"] as? String) == "text" {
314
+ lines.append("\(role): \(part["text"] as? String ?? "")")
315
+ }
316
+ // image_url / input_audio rejected before we get here
317
+ // (handleChatCompletion early-returns 400).
318
+ }
319
+ }
320
+ }
321
+ return lines.joined(separator: "\n")
322
+ }
323
+
324
+ private static func serialize(_ obj: Any) -> String? {
325
+ guard let data = try? JSONSerialization.data(withJSONObject: obj, options: []),
326
+ let s = String(data: data, encoding: .utf8) else {
327
+ return nil
328
+ }
329
+ return s
330
+ }
331
+
332
+ /// Convert a chat.completion JSON body to the legacy text_completion shape.
333
+ /// Mirrors the same helper in `LlamaHandlers` and `packages/dvai-bridge-core`.
334
+ private func chatToLegacyCompletion(_ chat: [String: Any]) -> [String: Any] {
335
+ var legacy: [String: Any] = [:]
336
+ let chatId = chat["id"] as? String ?? ""
337
+ legacy["id"] = chatId.isEmpty
338
+ ? "cmpl-\(Int(Date().timeIntervalSince1970))"
339
+ : chatId.replacingOccurrences(of: "chatcmpl-", with: "cmpl-")
340
+ legacy["object"] = "text_completion"
341
+ legacy["created"] = chat["created"] ?? Int(Date().timeIntervalSince1970)
342
+ legacy["model"] = chat["model"] ?? modelId
343
+ let choices = (chat["choices"] as? [[String: Any]]) ?? []
344
+ legacy["choices"] = choices.map { c -> [String: Any] in
345
+ let msg = c["message"] as? [String: Any]
346
+ return [
347
+ "text": (msg?["content"] as? String) ?? "",
348
+ "index": c["index"] ?? 0,
349
+ "finish_reason": c["finish_reason"] ?? "stop",
350
+ "logprobs": NSNull(),
351
+ ] as [String: Any]
352
+ }
353
+ legacy["usage"] = chat["usage"] ?? ["prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0]
354
+ return legacy
355
+ }
356
+
357
+ /// Adapt one SSE frame from chat.completion.chunk → text_completion.chunk.
358
+ /// `[DONE]` is forwarded unchanged. Frames that don't parse fall through.
359
+ private func adaptChunkToLegacy(_ chunk: String) -> String {
360
+ let trimmed = chunk.trimmingCharacters(in: .whitespacesAndNewlines)
361
+ guard trimmed.hasPrefix("data:") else { return chunk }
362
+ let payload = String(trimmed.dropFirst("data:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
363
+ if payload == "[DONE]" { return "data: [DONE]\n\n" }
364
+ guard let data = payload.data(using: .utf8),
365
+ let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
366
+ return chunk
367
+ }
368
+ let chatId = parsed["id"] as? String ?? ""
369
+ let id = chatId.replacingOccurrences(of: "chatcmpl-", with: "cmpl-")
370
+ var legacyChoices: [[String: Any]] = []
371
+ for c in (parsed["choices"] as? [[String: Any]]) ?? [] {
372
+ let delta = c["delta"] as? [String: Any]
373
+ legacyChoices.append([
374
+ "text": (delta?["content"] as? String) ?? "",
375
+ "index": c["index"] ?? 0,
376
+ "finish_reason": c["finish_reason"] ?? NSNull(),
377
+ "logprobs": NSNull(),
378
+ ] as [String: Any])
379
+ }
380
+ let legacy: [String: Any] = [
381
+ "id": id,
382
+ "object": "text_completion.chunk",
383
+ "created": parsed["created"] ?? Int(Date().timeIntervalSince1970),
384
+ "model": parsed["model"] ?? modelId,
385
+ "choices": legacyChoices,
386
+ ]
387
+ if let s = Self.serialize(legacy) { return "data: \(s)\n\n" }
388
+ return chunk
389
+ }
390
+ }
391
+ #endif
@@ -0,0 +1,136 @@
1
+ // Internal/FoundationPluginState.swift
2
+ //
3
+ // Owns the running state of the capacitor-foundation plugin: the embedded
4
+ // HTTP server and the FoundationHandlers instance. All access is serialised
5
+ // through actor isolation.
6
+ //
7
+ // Differences from capacitor-llama's FoundationPluginState:
8
+ // - No model bridge: Apple FM owns the model inside `LanguageModelSession`,
9
+ // so there is no `LlamaCppBridge` analogue.
10
+ // - No `modelPath` opt: the system model is implicit.
11
+ // - iOS 26.0+ runtime gate: FoundationHandlers is `@available(iOS 26.0,
12
+ // macOS 26.0, *)`. We must check at runtime AND at the compiler level
13
+ // (the inner `#available` is required so the compiler permits us to
14
+ // instantiate FoundationHandlers).
15
+ // - No embeddingMode/mmprojPath/gpuLayers/contextSize/threads opts:
16
+ // Apple FM does not expose any of those knobs.
17
+
18
+ import Foundation
19
+ #if !COCOAPODS
20
+ import DVAISharedCore
21
+ #endif
22
+
23
+ public actor FoundationPluginState {
24
+ private var server: HttpServer?
25
+ /// Type-erased reference to the live `FoundationHandlers` instance.
26
+ /// Stored as `AnyObject?` rather than `FoundationHandlers?` so we
27
+ /// don't have to mark the entire actor `@available(iOS 26.0, *)` —
28
+ /// the concrete type is only ever materialised inside an `#available`
29
+ /// block in `start()`. We never need to call methods on it from here
30
+ /// (Telegraph holds the only callable reference, via `installRoutes`),
31
+ /// so a strong-but-opaque retain is sufficient.
32
+ private var handlers: AnyObject?
33
+ private(set) var modelId: String = "apple-foundation-3b"
34
+ private(set) var isRunning: Bool = false
35
+ private(set) var baseUrl: String?
36
+ private(set) var port: Int?
37
+
38
+ public init() {}
39
+
40
+ /// Start the plugin: gate on iOS 26.0+, bind server, install routes.
41
+ /// - Returns dictionary suitable for Capacitor's `call.resolve(...)`.
42
+ public func start(opts: [String: Any]) async throws -> [String: Any] {
43
+ if isRunning { try await stopInternal() }
44
+
45
+ // Compile-time guard: on hosts where the FoundationModels framework
46
+ // isn't available at all (older Xcode), we can't run.
47
+ #if !canImport(FoundationModels)
48
+ throw NSError(
49
+ domain: "DVAIBridgeFoundation",
50
+ code: 500,
51
+ userInfo: [NSLocalizedDescriptionKey: "FoundationModels framework not available on this build."]
52
+ )
53
+ #else
54
+ // Runtime gate: even if the framework is linkable, the iOS device
55
+ // must be on iOS 26.0+ for the `LanguageModelSession` symbols to
56
+ // exist. This is the user-facing error path for older devices.
57
+ guard #available(iOS 26.0, macOS 26.0, *) else {
58
+ throw NSError(
59
+ domain: "DVAIBridgeFoundation",
60
+ code: 400,
61
+ userInfo: [NSLocalizedDescriptionKey: "Apple Foundation Models requires iOS 26.0 or later. The capacitor-foundation backend cannot start on this device."]
62
+ )
63
+ }
64
+
65
+ let httpBasePort = opts["httpBasePort"] as? Int ?? 38883
66
+ let httpMaxPortAttempts = opts["httpMaxPortAttempts"] as? Int ?? 16
67
+ let corsRaw = opts["corsOrigin"]
68
+ let corsConfig = parseCors(corsRaw)
69
+ let modelIdOverride = opts["modelId"] as? String
70
+ let modelId = modelIdOverride ?? "apple-foundation-3b"
71
+
72
+ // Build handlers first; Hummingbird requires routes to be
73
+ // installed at Application construction time, so installRoutes
74
+ // → tryBind is the mandatory order.
75
+ let handlers = FoundationHandlers(modelId: modelId)
76
+ let ctx = HandlerContext(modelId: modelId, backendName: "foundation")
77
+ let server = HttpServer()
78
+ await server.installRoutes(handlers: handlers, ctx: ctx, corsConfig: corsConfig)
79
+
80
+ // Bind server with port-fallback (mirrors capacitor-llama).
81
+ let port = try await server.tryBind(
82
+ basePort: httpBasePort,
83
+ maxAttempts: httpMaxPortAttempts,
84
+ host: "127.0.0.1"
85
+ )
86
+
87
+ self.handlers = handlers
88
+ self.modelId = modelId
89
+ self.server = server
90
+ self.port = port
91
+ self.baseUrl = "http://127.0.0.1:\(port)/v1"
92
+ self.isRunning = true
93
+
94
+ return [
95
+ "baseUrl": self.baseUrl!,
96
+ "port": port,
97
+ "backend": "foundation",
98
+ "modelId": modelId,
99
+ ]
100
+ #endif
101
+ }
102
+
103
+ /// Stop the plugin: drop handlers, stop server. Idempotent.
104
+ public func stop() async throws {
105
+ try await stopInternal()
106
+ }
107
+
108
+ private func stopInternal() async throws {
109
+ await server?.stop()
110
+ server = nil
111
+ handlers = nil
112
+ modelId = "apple-foundation-3b"
113
+ baseUrl = nil
114
+ port = nil
115
+ isRunning = false
116
+ }
117
+
118
+ /// Snapshot of the current running state, suitable for `call.resolve(...)`.
119
+ public func statusInfo() -> [String: Any] {
120
+ var dict: [String: Any] = ["running": isRunning]
121
+ if let baseUrl = baseUrl { dict["baseUrl"] = baseUrl }
122
+ if isRunning { dict["backend"] = "foundation" }
123
+ return dict
124
+ }
125
+
126
+ /// Parse the CORS option from the start opts dict.
127
+ private func parseCors(_ raw: Any?) -> CORSConfig {
128
+ if let s = raw as? String {
129
+ return s == "*" ? .wildcard : .exact(s)
130
+ }
131
+ if let arr = raw as? [String] {
132
+ return .allowlist(arr)
133
+ }
134
+ return .wildcard
135
+ }
136
+ }
@@ -0,0 +1,152 @@
1
+ // Tests/DVAICapacitorFoundationTests/FoundationHandlersTest.swift
2
+ //
3
+ // Unit tests for the `FoundationHandlers` paths that don't require a real
4
+ // `LanguageModelSession`:
5
+ // 1. handleEmbeddings → 400 with spec §8.5 wording.
6
+ // 2. handleModels → 200 with the canned single-entry list.
7
+ // 3. handleChatCompletion (image content part) → 400 with spec §8.5 wording.
8
+ // 4. handleChatCompletion (audio content part) → 400 with spec §8.5 wording.
9
+ // 5. handleChatCompletion (missing 'messages') → 400 short-circuit.
10
+ // 6. handleChatCompletion (empty 'messages' array) → 400 short-circuit.
11
+ //
12
+ // The chat happy path goes through `LanguageModelSession` and is verified
13
+ // on a real iOS device via the instrumented / manual tier (per Task 40
14
+ // plan note: "handleChatCompletion happy path requires real device — skip
15
+ // in unit tests").
16
+ //
17
+ // The whole class is guarded by `#if canImport(FoundationModels)`. On a
18
+ // macOS host without FoundationModels (older Xcode), the tests compile
19
+ // out and the smoke test still runs.
20
+
21
+ import XCTest
22
+ @testable import DVAIFoundationCore
23
+ import DVAISharedCore
24
+
25
+ #if canImport(FoundationModels)
26
+ import FoundationModels
27
+
28
+ @available(iOS 26.0, macOS 26.0, *)
29
+ final class FoundationHandlersTest: XCTestCase {
30
+
31
+ private func ctx() -> HandlerContext {
32
+ HandlerContext(modelId: "apple-foundation-3b", backendName: "foundation")
33
+ }
34
+
35
+ func testEmbeddingsReturnsSpecConformant400() async throws {
36
+ let handlers = FoundationHandlers()
37
+ let response = try await handlers.handleEmbeddings(
38
+ body: ["input": "hi"],
39
+ ctx: ctx()
40
+ )
41
+ if case .error(let status, let message) = response {
42
+ XCTAssertEqual(status, 400)
43
+ XCTAssertTrue(
44
+ message.contains("Embeddings not supported on Apple Foundation Models"),
45
+ "Expected spec §8.5 wording, got: \(message)"
46
+ )
47
+ } else {
48
+ XCTFail("Expected .error(400, ...), got \(response)")
49
+ }
50
+ }
51
+
52
+ func testModelsReturnsAppleFoundationEntry() async throws {
53
+ let handlers = FoundationHandlers()
54
+ let response = try await handlers.handleModels(ctx: ctx())
55
+ guard case .json(let status, let body) = response,
56
+ let dict = body as? [String: Any],
57
+ let data = dict["data"] as? [[String: Any]],
58
+ let first = data.first else {
59
+ XCTFail("Expected models list, got \(response)")
60
+ return
61
+ }
62
+ XCTAssertEqual(status, 200)
63
+ XCTAssertEqual(dict["object"] as? String, "list")
64
+ XCTAssertEqual(first["id"] as? String, "apple-foundation-3b")
65
+ XCTAssertEqual(first["object"] as? String, "model")
66
+ XCTAssertEqual(first["owned_by"] as? String, "apple")
67
+ }
68
+
69
+ func testChatCompletionImagePartReturns400() async throws {
70
+ let handlers = FoundationHandlers()
71
+ let body: [String: Any] = [
72
+ "messages": [[
73
+ "role": "user",
74
+ "content": [
75
+ ["type": "text", "text": "describe this"],
76
+ ["type": "image_url", "image_url": ["url": "data:image/png;base64,iVBORw0KGgo="]],
77
+ ],
78
+ ]],
79
+ ]
80
+ let response = try await handlers.handleChatCompletion(body: body, ctx: ctx())
81
+ if case .error(let status, let message) = response {
82
+ XCTAssertEqual(status, 400)
83
+ XCTAssertTrue(
84
+ message.contains("Image input not supported by Apple Foundation Models"),
85
+ "Expected spec §8.5 image-rejection wording, got: \(message)"
86
+ )
87
+ } else {
88
+ XCTFail("Expected .error(400, ...), got \(response)")
89
+ }
90
+ }
91
+
92
+ func testChatCompletionAudioPartReturns400() async throws {
93
+ let handlers = FoundationHandlers()
94
+ let body: [String: Any] = [
95
+ "messages": [[
96
+ "role": "user",
97
+ "content": [
98
+ ["type": "input_audio", "input_audio": ["data": "AAAA", "format": "pcm16"]],
99
+ ["type": "text", "text": "transcribe"],
100
+ ],
101
+ ]],
102
+ ]
103
+ let response = try await handlers.handleChatCompletion(body: body, ctx: ctx())
104
+ if case .error(let status, let message) = response {
105
+ XCTAssertEqual(status, 400)
106
+ XCTAssertTrue(
107
+ message.contains("Audio input not supported by Apple Foundation Models"),
108
+ "Expected spec §8.5 audio-rejection wording, got: \(message)"
109
+ )
110
+ } else {
111
+ XCTFail("Expected .error(400, ...), got \(response)")
112
+ }
113
+ }
114
+
115
+ func testChatCompletionMissingMessagesReturns400() async throws {
116
+ let handlers = FoundationHandlers()
117
+ // Body without a 'messages' key — must short-circuit to 400 before
118
+ // any session work, mirroring LlamaHandlers' behaviour.
119
+ let response = try await handlers.handleChatCompletion(body: [:], ctx: ctx())
120
+ if case .error(let status, let message) = response {
121
+ XCTAssertEqual(status, 400)
122
+ XCTAssertTrue(
123
+ message.contains("Missing 'messages'"),
124
+ "Expected missing-messages 400, got: \(message)"
125
+ )
126
+ } else {
127
+ XCTFail("Expected .error(400, ...), got \(response)")
128
+ }
129
+ }
130
+
131
+ func testChatCompletionEmptyMessagesReturns400() async throws {
132
+ let handlers = FoundationHandlers()
133
+ // Empty messages array — must short-circuit to 400 before any
134
+ // session work. Without this guard the prompt would be empty and
135
+ // Apple FM behaviour is undefined.
136
+ let body: [String: Any] = ["messages": [[String: Any]]()]
137
+ let response = try await handlers.handleChatCompletion(body: body, ctx: ctx())
138
+ if case .error(let status, let message) = response {
139
+ XCTAssertEqual(status, 400)
140
+ XCTAssertTrue(
141
+ message.contains("Empty messages"),
142
+ "Expected empty-messages 400, got: \(message)"
143
+ )
144
+ } else {
145
+ XCTFail("Expected .error(400, ...), got \(response)")
146
+ }
147
+ }
148
+ }
149
+ #else
150
+ // macOS host without FoundationModels — tests compile out at this guard.
151
+ // The smoke test (in SmokeTest.swift) still runs.
152
+ #endif
@@ -0,0 +1,86 @@
1
+ // Tests/DVAICapacitorFoundationTests/PluginStateTest.swift
2
+ //
3
+ // Unit tests for the `FoundationPluginState` actor — the lifecycle owner wired up
4
+ // in Task 41. Tests focus on state-machine paths that don't require a
5
+ // real Apple FM `LanguageModelSession`:
6
+ //
7
+ // 1. `statusInfo()` reports `running:false` initially.
8
+ // 2. `stop()` is idempotent before any `start()`.
9
+ // 3. Default-modelId behaviour (verified indirectly via runtime gate
10
+ // paths described below).
11
+ //
12
+ // We intentionally do NOT test the happy `start()` path here:
13
+ // - It requires Apple FM to actually be available on the test runner.
14
+ // - On the iOS Simulator destination on Xcode 26+, `#available(iOS
15
+ // 26.0, *)` returns true and `start()` would then attempt to bind
16
+ // a real Telegraph server and instantiate `LanguageModelSession`.
17
+ // A real session call needs a device with the FM model installed.
18
+ // - That path is exercised by the device-tier tests when a real
19
+ // iOS 26+ device is wired up.
20
+ //
21
+ // The runtime-gate "iOS too old" error path is similarly hard to hit
22
+ // reliably from XCTest: on Xcode 26 + iOS 26 Simulator the gate returns
23
+ // true, on macOS host the build itself only sees the macOS availability,
24
+ // and on older Xcodes the whole `FoundationHandlers` symbol compiles out
25
+ // (so FoundationPluginState's `#if canImport(FoundationModels)` branch is dead).
26
+ // We therefore skip a direct test of the gate's error message and rely
27
+ // on the structural fact that `FoundationPluginState.start()` literally does
28
+ // `guard #available(iOS 26.0, ...)` before any other work — verified by
29
+ // code review.
30
+
31
+ import XCTest
32
+ @testable import DVAIFoundationCore
33
+
34
+ final class PluginStateTest: XCTestCase {
35
+
36
+ func testStatusInfoReportsNotRunningInitially() async {
37
+ let state = FoundationPluginState()
38
+ let info = await state.statusInfo()
39
+ XCTAssertEqual(info["running"] as? Bool, false)
40
+ XCTAssertNil(info["baseUrl"], "baseUrl should be absent before start()")
41
+ XCTAssertNil(info["backend"], "backend should be absent before start()")
42
+ }
43
+
44
+ func testStopBeforeStartIsIdempotent() async throws {
45
+ let state = FoundationPluginState()
46
+ // Calling stop() on an idle state must not throw.
47
+ try await state.stop()
48
+ let info = await state.statusInfo()
49
+ XCTAssertEqual(info["running"] as? Bool, false)
50
+ }
51
+
52
+ func testStopAfterStopRemainsIdempotent() async throws {
53
+ let state = FoundationPluginState()
54
+ try await state.stop()
55
+ try await state.stop()
56
+ let info = await state.statusInfo()
57
+ XCTAssertEqual(info["running"] as? Bool, false)
58
+ }
59
+
60
+ /// On hosts where `FoundationModels` does not link at all (older Xcode),
61
+ /// `start()` rejects with a clear "framework not available" message.
62
+ /// On Xcode 26+ this branch is dead — the alternate `iOS 26.0+` runtime
63
+ /// gate kicks in instead, and on the iOS 26 Simulator that gate passes
64
+ /// and `start()` proceeds to bind a real server. We therefore only
65
+ /// assert that `start()` either succeeds or throws — never silently
66
+ /// resolves to an inconsistent state.
67
+ func testStartEitherSucceedsOrThrowsCleanly() async {
68
+ let state = FoundationPluginState()
69
+ do {
70
+ // Use a high port range to avoid conflicting with anything on
71
+ // CI. If start succeeds, immediately stop to free the socket.
72
+ _ = try await state.start(opts: [
73
+ "httpBasePort": 39300,
74
+ "httpMaxPortAttempts": 4,
75
+ ])
76
+ // Cleanup so subsequent tests aren't affected.
77
+ try? await state.stop()
78
+ } catch {
79
+ // Any error path is acceptable for this test — we just want
80
+ // to assert that the failure surfaces cleanly and the state
81
+ // stays "not running".
82
+ let info = await state.statusInfo()
83
+ XCTAssertEqual(info["running"] as? Bool, false)
84
+ }
85
+ }
86
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@dvai-bridge/ios-foundation-core",
3
+ "version": "4.0.1",
4
+ "description": "DVAI-Bridge iOS Foundation Models core — pure Swift embedded HTTP server + handlers wrapping Apple's LanguageModelSession. Capacitor-free. Requires iOS 26.0+ at runtime; iOS 18.1+ at link-time.",
5
+ "author": "Deep Chakraborty <https://github.com/dk013>",
6
+ "license": "Custom (See LICENSE)",
7
+ "main": "Package.swift",
8
+ "files": [
9
+ "ios/Sources",
10
+ "ios/Tests",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "publishConfig": {
15
+ "registry": "https://registry.npmjs.org/",
16
+ "access": "public"
17
+ }
18
+ }