@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 +358 -0
- package/Package.swift +45 -0
- package/ios/Sources/DVAIFoundationCore/AsyncSemaphore.swift +62 -0
- package/ios/Sources/DVAIFoundationCore/FoundationHandlers.swift +391 -0
- package/ios/Sources/DVAIFoundationCore/FoundationPluginState.swift +136 -0
- package/ios/Tests/DVAIFoundationCoreTests/FoundationHandlersTest.swift +152 -0
- package/ios/Tests/DVAIFoundationCoreTests/FoundationPluginStateTest.swift +86 -0
- package/package.json +18 -0
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
|
+
}
|