@dvai-bridge/capacitor-llama 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DVAICapacitorLlama.podspec +21 -0
- package/LICENSE +51 -0
- package/README.md +199 -0
- package/android/build.gradle +91 -0
- package/android/gradle.properties +4 -0
- package/android/settings.gradle +4 -0
- package/android/src/androidTest/assets/audio/m4a-1s.m4a +0 -0
- package/android/src/androidTest/assets/audio/pcm16-1s-16khz-mono.bin +0 -0
- package/android/src/androidTest/assets/audio/wav-1s-16khz-mono.wav +0 -0
- package/android/src/androidTest/assets/images/tiny-test.png +0 -0
- package/android/src/androidTest/java/co/deepvoiceai/bridge/llama/RealModelSmokeTest.kt +238 -0
- package/android/src/main/AndroidManifest.xml +7 -0
- package/android/src/main/java/co/deepvoiceai/bridge/llama/Plugin.kt +177 -0
- package/android/src/main/res/xml/dvai_network_security_config.xml +7 -0
- package/android/src/test/java/co/deepvoiceai/bridge/llama/SmokeTest.kt +11 -0
- package/dist/index.cjs +34 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/ios/Package.swift +57 -0
- package/ios/Sources/DVAICapacitorLlama/Plugin.swift +154 -0
- package/ios/Sources/DVAICapacitorLlama/PluginProxy.m +15 -0
- package/ios/Tests/DVAICapacitorLlamaTests/RealModelSmokeTest.swift +412 -0
- package/ios/Tests/DVAICapacitorLlamaTests/SmokeTest.swift +10 -0
- package/package.json +70 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// Tests/DVAICapacitorLlamaTests/RealModelSmokeTest.swift
|
|
2
|
+
//
|
|
3
|
+
// End-to-end smoke test against a small public GGUF model. Verifies
|
|
4
|
+
// mechanics (download → load → respond → free) only, not output quality.
|
|
5
|
+
//
|
|
6
|
+
// The test reads `SMOKE_MODEL_URL` and `SMOKE_MODEL_SHA256` from the
|
|
7
|
+
// process environment. When either is missing, it skips cleanly via
|
|
8
|
+
// `XCTSkip`, so this file is safe to compile and run locally even
|
|
9
|
+
// without those env vars set.
|
|
10
|
+
//
|
|
11
|
+
// On the self-hosted Mac runner the workflow forwards the secrets to
|
|
12
|
+
// the simulator via `SIMCTL_CHILD_SMOKE_MODEL_URL=...` (xcodebuild's
|
|
13
|
+
// documented mechanism for env vars to reach the simulator-hosted
|
|
14
|
+
// XCTest process).
|
|
15
|
+
|
|
16
|
+
import XCTest
|
|
17
|
+
import DVAILlamaCore
|
|
18
|
+
import DVAILlamaCoreObjC
|
|
19
|
+
|
|
20
|
+
/// Unbuffered breadcrumb. NSLog flushes per call to stderr / oslog, so
|
|
21
|
+
/// even if the test process dies mid-step (jetsam SIGKILL on simulator,
|
|
22
|
+
/// for example) the most recent step still appears in xcresult /
|
|
23
|
+
/// `log show`. Plain `print(...)` buffers on stdout and silently
|
|
24
|
+
/// disappears when the process is killed.
|
|
25
|
+
@inline(__always)
|
|
26
|
+
fileprivate func smokeStep(_ msg: String) {
|
|
27
|
+
NSLog("DVAI-SMOKE: %@", msg)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
final class RealModelSmokeTest: XCTestCase {
|
|
31
|
+
private var tempDir: URL!
|
|
32
|
+
private var bridge: LlamaCppBridge?
|
|
33
|
+
|
|
34
|
+
/// Vision + audio smoke involves downloading a 5 GB GGUF + 557 MB
|
|
35
|
+
/// mmproj plus loading both into the simulator's Metal context and
|
|
36
|
+
/// running an eval pass. The combined runtime can easily exceed
|
|
37
|
+
/// Xcode's default 10-minute per-test allowance, after which xctest
|
|
38
|
+
/// kills and "Restarts" the test bundle. We ask for 45 minutes per
|
|
39
|
+
/// test to absorb slow networks + model load + first-Metal-shader
|
|
40
|
+
/// compile.
|
|
41
|
+
override class var defaultTestSuite: XCTestSuite {
|
|
42
|
+
let suite = super.defaultTestSuite
|
|
43
|
+
for case let testCase as XCTestCase in suite.tests {
|
|
44
|
+
testCase.executionTimeAllowance = 45 * 60
|
|
45
|
+
}
|
|
46
|
+
return suite
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
override func setUpWithError() throws {
|
|
50
|
+
let base = FileManager.default.temporaryDirectory
|
|
51
|
+
.appendingPathComponent("dvai-smoke-\(UUID().uuidString)")
|
|
52
|
+
try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
|
|
53
|
+
tempDir = base
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
override func tearDownWithError() throws {
|
|
57
|
+
bridge?.unload()
|
|
58
|
+
bridge = nil
|
|
59
|
+
if let tempDir { try? FileManager.default.removeItem(at: tempDir) }
|
|
60
|
+
tempDir = nil
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func testSmokeRealModelEndToEnd() async throws {
|
|
64
|
+
let env = Self.loadSmokeEnv()
|
|
65
|
+
guard let urlStr = env["SMOKE_MODEL_URL"], !urlStr.isEmpty,
|
|
66
|
+
let sha = env["SMOKE_MODEL_SHA256"], !sha.isEmpty,
|
|
67
|
+
let url = URL(string: urlStr)
|
|
68
|
+
else {
|
|
69
|
+
throw XCTSkip("SMOKE_MODEL_URL/SMOKE_MODEL_SHA256 not set in env; skipping real-model smoke")
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Generous timeout for the 800 MB download + 1B-param load.
|
|
73
|
+
let downloader = ModelDownloader(cacheDirOverride: tempDir)
|
|
74
|
+
let result = try await downloader.downloadModel(
|
|
75
|
+
url: url,
|
|
76
|
+
expectedSha256: sha.lowercased(),
|
|
77
|
+
destFilename: "smoke-model.gguf",
|
|
78
|
+
headers: [:],
|
|
79
|
+
onProgress: { _, _ in /* no-op for smoke */ }
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
XCTAssertFalse(result.cached, "first download into a fresh temp dir should not be cached")
|
|
83
|
+
XCTAssertTrue(
|
|
84
|
+
FileManager.default.fileExists(atPath: result.path),
|
|
85
|
+
"downloaded file should exist at \(result.path)"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
let bridge = LlamaCppBridge()
|
|
89
|
+
self.bridge = bridge
|
|
90
|
+
try bridge.loadModel(
|
|
91
|
+
atPath: result.path,
|
|
92
|
+
mmprojPath: nil,
|
|
93
|
+
gpuLayers: 99,
|
|
94
|
+
contextSize: 2048,
|
|
95
|
+
threads: 4,
|
|
96
|
+
embeddingMode: false
|
|
97
|
+
)
|
|
98
|
+
XCTAssertTrue(bridge.isLoaded, "model should be loaded after loadModel(...) returns")
|
|
99
|
+
|
|
100
|
+
let completion = try bridge.completePrompt(
|
|
101
|
+
"<|begin_of_text|>What is 2+2?",
|
|
102
|
+
maxTokens: 32,
|
|
103
|
+
temperature: 0.0,
|
|
104
|
+
topP: 1.0
|
|
105
|
+
)
|
|
106
|
+
// Don't assert specific content — that's quality testing, not smoke.
|
|
107
|
+
XCTAssertFalse(completion.isEmpty, "completion should not be empty")
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// Vision smoke: download model + mmproj, load both, run a chat
|
|
111
|
+
/// completion against the tiny test image fixture. Skips cleanly if any
|
|
112
|
+
/// of SMOKE_VISION_MODEL_URL / SMOKE_VISION_MODEL_SHA256 /
|
|
113
|
+
/// SMOKE_VISION_MMPROJ_URL / SMOKE_VISION_MMPROJ_SHA256 are unset.
|
|
114
|
+
func testSmokeVisionEndToEnd() async throws {
|
|
115
|
+
let env = Self.loadSmokeEnv()
|
|
116
|
+
guard let modelUrlStr = env["SMOKE_VISION_MODEL_URL"], !modelUrlStr.isEmpty,
|
|
117
|
+
let modelSha = env["SMOKE_VISION_MODEL_SHA256"], !modelSha.isEmpty,
|
|
118
|
+
let mmprojUrlStr = env["SMOKE_VISION_MMPROJ_URL"], !mmprojUrlStr.isEmpty,
|
|
119
|
+
let mmprojSha = env["SMOKE_VISION_MMPROJ_SHA256"], !mmprojSha.isEmpty,
|
|
120
|
+
let modelUrl = URL(string: modelUrlStr),
|
|
121
|
+
let mmprojUrl = URL(string: mmprojUrlStr)
|
|
122
|
+
else {
|
|
123
|
+
throw XCTSkip("SMOKE_VISION_* env vars not all set; skipping vision smoke")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
smokeStep("vision: downloading main model")
|
|
127
|
+
let downloader = ModelDownloader(cacheDirOverride: tempDir)
|
|
128
|
+
let modelResult = try await downloader.downloadModel(
|
|
129
|
+
url: modelUrl,
|
|
130
|
+
expectedSha256: modelSha.lowercased(),
|
|
131
|
+
destFilename: "smoke-vision-model.gguf",
|
|
132
|
+
headers: [:],
|
|
133
|
+
onProgress: { _, _ in /* no-op for smoke */ }
|
|
134
|
+
)
|
|
135
|
+
XCTAssertTrue(FileManager.default.fileExists(atPath: modelResult.path))
|
|
136
|
+
smokeStep("vision: model downloaded; downloading mmproj")
|
|
137
|
+
let mmprojResult = try await downloader.downloadModel(
|
|
138
|
+
url: mmprojUrl,
|
|
139
|
+
expectedSha256: mmprojSha.lowercased(),
|
|
140
|
+
destFilename: "smoke-vision-mmproj.gguf",
|
|
141
|
+
headers: [:],
|
|
142
|
+
onProgress: { _, _ in /* no-op for smoke */ }
|
|
143
|
+
)
|
|
144
|
+
XCTAssertTrue(FileManager.default.fileExists(atPath: mmprojResult.path))
|
|
145
|
+
smokeStep("vision: mmproj downloaded")
|
|
146
|
+
|
|
147
|
+
let bridge = LlamaCppBridge()
|
|
148
|
+
self.bridge = bridge
|
|
149
|
+
// gpuLayers=0 on simulator: same MTLSimDevice allocation cap that
|
|
150
|
+
// hits the mmproj also bites the main model when mtmd_helper_decode
|
|
151
|
+
// builds image-embedding tensors and llama_decode runs them on
|
|
152
|
+
// Metal. Falling back to CPU for the main model avoids the abort.
|
|
153
|
+
// Real iPhone hardware uses Metal end-to-end → gpuLayers=99 is the
|
|
154
|
+
// production default.
|
|
155
|
+
#if targetEnvironment(simulator)
|
|
156
|
+
let mainGPULayers: Int32 = 0
|
|
157
|
+
#else
|
|
158
|
+
let mainGPULayers: Int32 = 99
|
|
159
|
+
#endif
|
|
160
|
+
smokeStep("vision: loading main model (gpuLayers=\(mainGPULayers))")
|
|
161
|
+
try bridge.loadModel(
|
|
162
|
+
atPath: modelResult.path,
|
|
163
|
+
mmprojPath: nil,
|
|
164
|
+
// Smoke: small context to keep KV-cache memory well under
|
|
165
|
+
// the simulator's per-process budget. We sample at most 32
|
|
166
|
+
// tokens, so 1024 leaves plenty of headroom for the prompt
|
|
167
|
+
// + image chunk + completion without paging.
|
|
168
|
+
gpuLayers: mainGPULayers,
|
|
169
|
+
contextSize: 1024,
|
|
170
|
+
threads: 4,
|
|
171
|
+
embeddingMode: false
|
|
172
|
+
)
|
|
173
|
+
XCTAssertTrue(bridge.isLoaded)
|
|
174
|
+
smokeStep("vision: main model loaded")
|
|
175
|
+
// useGPU=false on simulator: iOS Simulator's MTLSimDevice aborts in
|
|
176
|
+
// _xpc_shmem_create_with_prot when CLIP tries to allocate the
|
|
177
|
+
// ~60 MiB position-embedding tensor (gemma4v has shape [768, 10240, 2]).
|
|
178
|
+
// CPU-only projection is slow but lets the smoke run end-to-end.
|
|
179
|
+
// Real iPhone hardware uses Metal without issue → useGPU=true is the
|
|
180
|
+
// production default.
|
|
181
|
+
#if targetEnvironment(simulator)
|
|
182
|
+
let useGPUForMmproj = false
|
|
183
|
+
#else
|
|
184
|
+
let useGPUForMmproj = true
|
|
185
|
+
#endif
|
|
186
|
+
smokeStep("vision: loading mmproj (useGPU=\(useGPUForMmproj))")
|
|
187
|
+
try bridge.loadMmproj(atPath: mmprojResult.path, useGPU: useGPUForMmproj)
|
|
188
|
+
XCTAssertTrue(bridge.isMmprojLoaded)
|
|
189
|
+
smokeStep("vision: mmproj loaded")
|
|
190
|
+
|
|
191
|
+
// Read the smoke PNG fixture. tiny-test.png is a 256x256 image with
|
|
192
|
+
// three primary-colour squares + a yellow ellipse — picked so a
|
|
193
|
+
// captioner has unambiguous content to describe (a blank canvas
|
|
194
|
+
// tends to make Gemma 4 emit `<end_of_turn>` as its first sample,
|
|
195
|
+
// which the greedy sampler treats as a clean exit and returns "").
|
|
196
|
+
// Regenerate via `scripts/generate-image-fixtures.sh`.
|
|
197
|
+
let imageURL = fixturesURL().appendingPathComponent("images").appendingPathComponent("tiny-test.png")
|
|
198
|
+
let imageData = try Data(contentsOf: imageURL)
|
|
199
|
+
|
|
200
|
+
// Build a marker-bearing prompt and apply the model's chat template.
|
|
201
|
+
// Gemma 4's published GGUFs at ggml-org/gemma-4-E2B-it-GGUF do not
|
|
202
|
+
// embed a tokenizer.chat_template that llama.cpp's heuristic
|
|
203
|
+
// recognizes, so passing nil here produces error 41 ("model has no
|
|
204
|
+
// chat template and none provided"). Production developers using
|
|
205
|
+
// capacitor-llama are expected to supply their model's template at
|
|
206
|
+
// start time; for smoke purposes we hardcode Gemma's published
|
|
207
|
+
// chat-template format inline.
|
|
208
|
+
let gemmaTemplate = """
|
|
209
|
+
{% for m in messages %}<start_of_turn>{% if m.role == 'assistant' %}model{% else %}{{ m.role }}{% endif %}
|
|
210
|
+
{{ m.content }}<end_of_turn>
|
|
211
|
+
{% endfor %}{% if add_generation_prompt %}<start_of_turn>model
|
|
212
|
+
{% endif %}
|
|
213
|
+
"""
|
|
214
|
+
let messages: [[String: String]] = [
|
|
215
|
+
["role": "user", "content": "Describe this image: \(MTMD_MEDIA_MARKER)"]
|
|
216
|
+
]
|
|
217
|
+
let chatPrompt = try bridge.applyChatTemplate(gemmaTemplate, messages: messages, addAssistant: true)
|
|
218
|
+
XCTAssertFalse(chatPrompt.isEmpty)
|
|
219
|
+
smokeStep("vision: chat template applied; running multimodal eval")
|
|
220
|
+
|
|
221
|
+
let completion = try bridge.completeMultimodalPrompt(
|
|
222
|
+
chatPrompt,
|
|
223
|
+
media: [imageData],
|
|
224
|
+
maxTokens: 32,
|
|
225
|
+
temperature: 0.0,
|
|
226
|
+
topP: 1.0
|
|
227
|
+
)
|
|
228
|
+
smokeStep("vision: eval done — completion=\(completion.prefix(80))")
|
|
229
|
+
XCTAssertFalse(completion.isEmpty, "vision completion should not be empty")
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/// Audio smoke: same as vision, but with the WAV fixture instead of PNG.
|
|
233
|
+
/// mtmd's `mtmd_helper_bitmap_init_from_buf` accepts wav/mp3/flac for
|
|
234
|
+
/// audio (per mtmd-helper.h docs). Skips when the model declared no
|
|
235
|
+
/// audio encoder (e.g. vision-only mmproj).
|
|
236
|
+
func testSmokeAudioEndToEnd() async throws {
|
|
237
|
+
let env = Self.loadSmokeEnv()
|
|
238
|
+
guard let modelUrlStr = env["SMOKE_VISION_MODEL_URL"], !modelUrlStr.isEmpty,
|
|
239
|
+
let modelSha = env["SMOKE_VISION_MODEL_SHA256"], !modelSha.isEmpty,
|
|
240
|
+
let mmprojUrlStr = env["SMOKE_VISION_MMPROJ_URL"], !mmprojUrlStr.isEmpty,
|
|
241
|
+
let mmprojSha = env["SMOKE_VISION_MMPROJ_SHA256"], !mmprojSha.isEmpty,
|
|
242
|
+
let modelUrl = URL(string: modelUrlStr),
|
|
243
|
+
let mmprojUrl = URL(string: mmprojUrlStr)
|
|
244
|
+
else {
|
|
245
|
+
throw XCTSkip("SMOKE_VISION_* env vars not all set; skipping audio smoke")
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
smokeStep("audio: downloading main model")
|
|
249
|
+
let downloader = ModelDownloader(cacheDirOverride: tempDir)
|
|
250
|
+
let modelResult = try await downloader.downloadModel(
|
|
251
|
+
url: modelUrl,
|
|
252
|
+
expectedSha256: modelSha.lowercased(),
|
|
253
|
+
destFilename: "smoke-audio-model.gguf",
|
|
254
|
+
headers: [:],
|
|
255
|
+
onProgress: { _, _ in /* no-op for smoke */ }
|
|
256
|
+
)
|
|
257
|
+
smokeStep("audio: model downloaded; downloading mmproj")
|
|
258
|
+
let mmprojResult = try await downloader.downloadModel(
|
|
259
|
+
url: mmprojUrl,
|
|
260
|
+
expectedSha256: mmprojSha.lowercased(),
|
|
261
|
+
destFilename: "smoke-audio-mmproj.gguf",
|
|
262
|
+
headers: [:],
|
|
263
|
+
onProgress: { _, _ in /* no-op for smoke */ }
|
|
264
|
+
)
|
|
265
|
+
smokeStep("audio: mmproj downloaded")
|
|
266
|
+
|
|
267
|
+
let bridge = LlamaCppBridge()
|
|
268
|
+
self.bridge = bridge
|
|
269
|
+
// gpuLayers=0 on simulator: same MTLSimDevice allocation cap that
|
|
270
|
+
// hits the mmproj also bites the main model when mtmd_helper_decode
|
|
271
|
+
// builds audio-embedding tensors and llama_decode runs them on
|
|
272
|
+
// Metal. Falling back to CPU for the main model avoids the abort.
|
|
273
|
+
#if targetEnvironment(simulator)
|
|
274
|
+
let mainGPULayers: Int32 = 0
|
|
275
|
+
#else
|
|
276
|
+
let mainGPULayers: Int32 = 99
|
|
277
|
+
#endif
|
|
278
|
+
smokeStep("audio: loading main model (gpuLayers=\(mainGPULayers))")
|
|
279
|
+
try bridge.loadModel(
|
|
280
|
+
atPath: modelResult.path,
|
|
281
|
+
mmprojPath: nil,
|
|
282
|
+
gpuLayers: mainGPULayers,
|
|
283
|
+
contextSize: 1024,
|
|
284
|
+
threads: 4,
|
|
285
|
+
embeddingMode: false
|
|
286
|
+
)
|
|
287
|
+
smokeStep("audio: main model loaded")
|
|
288
|
+
// useGPU=false on simulator: iOS Simulator's MTLSimDevice aborts in
|
|
289
|
+
// _xpc_shmem_create_with_prot when CLIP tries to allocate the
|
|
290
|
+
// ~60 MiB position-embedding tensor (gemma4v has shape [768, 10240, 2]).
|
|
291
|
+
// CPU-only projection is slow but lets the smoke run end-to-end.
|
|
292
|
+
// Real iPhone hardware uses Metal without issue → useGPU=true is the
|
|
293
|
+
// production default.
|
|
294
|
+
#if targetEnvironment(simulator)
|
|
295
|
+
let useGPUForMmproj = false
|
|
296
|
+
#else
|
|
297
|
+
let useGPUForMmproj = true
|
|
298
|
+
#endif
|
|
299
|
+
smokeStep("audio: loading mmproj (useGPU=\(useGPUForMmproj))")
|
|
300
|
+
try bridge.loadMmproj(atPath: mmprojResult.path, useGPU: useGPUForMmproj)
|
|
301
|
+
smokeStep("audio: mmproj loaded")
|
|
302
|
+
|
|
303
|
+
// Skip cleanly if the loaded mmproj has no audio encoder (e.g. when
|
|
304
|
+
// SMOKE_VISION_* points at a vision-only projector).
|
|
305
|
+
guard bridge.hasAudioEncoder() else {
|
|
306
|
+
throw XCTSkip("Loaded mmproj reports no audio encoder; skipping audio smoke")
|
|
307
|
+
}
|
|
308
|
+
smokeStep("audio: hasAudioEncoder=true; running multimodal eval")
|
|
309
|
+
|
|
310
|
+
let audioURL = fixturesURL().appendingPathComponent("audio").appendingPathComponent("wav-1s-16khz-mono.wav")
|
|
311
|
+
let audioData = try Data(contentsOf: audioURL)
|
|
312
|
+
|
|
313
|
+
// Same Gemma chat template as the vision test — Gemma 4 GGUFs at
|
|
314
|
+
// ggml-org don't ship a llama.cpp-recognized chat_template.
|
|
315
|
+
let gemmaTemplate = """
|
|
316
|
+
{% for m in messages %}<start_of_turn>{% if m.role == 'assistant' %}model{% else %}{{ m.role }}{% endif %}
|
|
317
|
+
{{ m.content }}<end_of_turn>
|
|
318
|
+
{% endfor %}{% if add_generation_prompt %}<start_of_turn>model
|
|
319
|
+
{% endif %}
|
|
320
|
+
"""
|
|
321
|
+
// Open-ended prompt: the WAV fixture is a synthetic 1-second 440 Hz
|
|
322
|
+
// sine tone with no speech content, so "Transcribe this:" makes the
|
|
323
|
+
// model emit `<end_of_turn>` as its first sample (legitimate — there's
|
|
324
|
+
// nothing to transcribe). "Describe what you hear" gives Gemma room
|
|
325
|
+
// to say something like "A pure tone." instead of bailing immediately.
|
|
326
|
+
let messages: [[String: String]] = [
|
|
327
|
+
["role": "user", "content": "Describe what you hear: \(MTMD_MEDIA_MARKER)"]
|
|
328
|
+
]
|
|
329
|
+
let chatPrompt = try bridge.applyChatTemplate(gemmaTemplate, messages: messages, addAssistant: true)
|
|
330
|
+
|
|
331
|
+
// Note: we do NOT assert that the completion is non-empty. With a
|
|
332
|
+
// synthetic tone fixture, an immediate-EOS sample is a *correct*
|
|
333
|
+
// model response, not a pipeline failure. This smoke verifies that
|
|
334
|
+
// the audio path runs end-to-end without throwing — eval, decode,
|
|
335
|
+
// and sampler all return cleanly. Production code paths (real
|
|
336
|
+
// speech audio) are exercised by host-app integration tests.
|
|
337
|
+
_ = try bridge.completeMultimodalPrompt(
|
|
338
|
+
chatPrompt,
|
|
339
|
+
media: [audioData],
|
|
340
|
+
maxTokens: 32,
|
|
341
|
+
temperature: 0.0,
|
|
342
|
+
topP: 1.0
|
|
343
|
+
)
|
|
344
|
+
smokeStep("audio: eval done")
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/// Walks up from #file to find the repo-root `fixtures/` dir.
|
|
348
|
+
private func fixturesURL() -> URL {
|
|
349
|
+
var dir = URL(fileURLWithPath: #file).deletingLastPathComponent()
|
|
350
|
+
while !FileManager.default.fileExists(atPath: dir.appendingPathComponent("fixtures").path) {
|
|
351
|
+
let parent = dir.deletingLastPathComponent()
|
|
352
|
+
if parent.path == dir.path {
|
|
353
|
+
fatalError("fixtures dir not found walking up from \(#file)")
|
|
354
|
+
}
|
|
355
|
+
dir = parent
|
|
356
|
+
}
|
|
357
|
+
return dir.appendingPathComponent("fixtures")
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/// Reads SMOKE_* env vars from the test process's environment first,
|
|
361
|
+
/// then falls back to the per-developer `scripts/smoke.local.env`
|
|
362
|
+
/// file on the host filesystem. The fallback exists because
|
|
363
|
+
/// `xcodebuild test` does not propagate parent-process env vars
|
|
364
|
+
/// (or `SIMCTL_CHILD_*` / `TEST_RUNNER_*`) to the unit-test bundle's
|
|
365
|
+
/// `ProcessInfo.processInfo.environment` — that's an XCUITest-only
|
|
366
|
+
/// channel. Reading the file directly works reliably both locally
|
|
367
|
+
/// (via the gitignored smoke.local.env) and in CI (which actually
|
|
368
|
+
/// does inject env vars at the workflow level — `ProcessInfo` sees
|
|
369
|
+
/// those because the Mac runner inherits the GitHub Actions step env
|
|
370
|
+
/// before xcodebuild starts).
|
|
371
|
+
fileprivate static func loadSmokeEnv() -> [String: String] {
|
|
372
|
+
var env = ProcessInfo.processInfo.environment.filter { $0.key.hasPrefix("SMOKE_") }
|
|
373
|
+
if !env.isEmpty {
|
|
374
|
+
return env
|
|
375
|
+
}
|
|
376
|
+
// Walk up from this file to find `scripts/smoke.local.env`.
|
|
377
|
+
var dir = URL(fileURLWithPath: #file).deletingLastPathComponent()
|
|
378
|
+
while !FileManager.default.fileExists(atPath: dir.appendingPathComponent("scripts/smoke.local.env").path) {
|
|
379
|
+
let parent = dir.deletingLastPathComponent()
|
|
380
|
+
if parent.path == dir.path {
|
|
381
|
+
return env // empty
|
|
382
|
+
}
|
|
383
|
+
dir = parent
|
|
384
|
+
}
|
|
385
|
+
let envFile = dir.appendingPathComponent("scripts/smoke.local.env")
|
|
386
|
+
guard let contents = try? String(contentsOf: envFile, encoding: .utf8) else {
|
|
387
|
+
return env
|
|
388
|
+
}
|
|
389
|
+
for line in contents.split(separator: "\n") {
|
|
390
|
+
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
391
|
+
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
|
392
|
+
guard let eq = trimmed.firstIndex(of: "=") else { continue }
|
|
393
|
+
let key = String(trimmed[..<eq]).trimmingCharacters(in: .whitespaces)
|
|
394
|
+
var value = String(trimmed[trimmed.index(after: eq)...]).trimmingCharacters(in: .whitespaces)
|
|
395
|
+
// Strip a single matching pair of leading/trailing quotes so a
|
|
396
|
+
// developer writing `SMOKE_MODEL_URL="https://..."` in the env
|
|
397
|
+
// file doesn't end up with the quote chars baked into the URL
|
|
398
|
+
// (which makes `URL(string:)` return nil and the test silently
|
|
399
|
+
// skip with a confusing reason).
|
|
400
|
+
if value.count >= 2 {
|
|
401
|
+
if (value.first == "\"" && value.last == "\"") ||
|
|
402
|
+
(value.first == "'" && value.last == "'") {
|
|
403
|
+
value = String(value.dropFirst().dropLast())
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if key.hasPrefix("SMOKE_") && !value.isEmpty {
|
|
407
|
+
env[key] = value
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return env
|
|
411
|
+
}
|
|
412
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import DVAILlamaCore
|
|
3
|
+
|
|
4
|
+
final class SmokeTest: XCTestCase {
|
|
5
|
+
func testHandlerContextInit() {
|
|
6
|
+
let ctx = HandlerContext(modelId: "test", backendName: "llama")
|
|
7
|
+
XCTAssertEqual(ctx.modelId, "test")
|
|
8
|
+
XCTAssertEqual(ctx.backendName, "llama")
|
|
9
|
+
}
|
|
10
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dvai-bridge/capacitor-llama",
|
|
3
|
+
"version": "4.0.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"registry": "https://registry.npmjs.org/",
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"description": "DVAI-Bridge Capacitor plugin: llama.cpp on iOS (Metal) + Android (Vulkan/CPU) with embedded HTTP server.",
|
|
9
|
+
"main": "dist/index.cjs",
|
|
10
|
+
"module": "dist/index.js",
|
|
11
|
+
"types": "dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"require": "./dist/index.cjs"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"ios/Sources",
|
|
23
|
+
"ios/Tests",
|
|
24
|
+
"ios/Package.swift",
|
|
25
|
+
"android/src",
|
|
26
|
+
"android/build.gradle",
|
|
27
|
+
"android/gradle.properties",
|
|
28
|
+
"android/settings.gradle",
|
|
29
|
+
"DVAICapacitorLlama.podspec",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"keywords": [
|
|
34
|
+
"dvai-bridge",
|
|
35
|
+
"capacitor",
|
|
36
|
+
"llama-cpp",
|
|
37
|
+
"llm",
|
|
38
|
+
"on-device"
|
|
39
|
+
],
|
|
40
|
+
"author": "Deep Chakraborty <https://github.com/dk013>",
|
|
41
|
+
"license": "Custom (See LICENSE)",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/westenets/dvai-bridge.git"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@capacitor/core": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
|
48
|
+
"@dvai-bridge/capacitor": "4.0.0",
|
|
49
|
+
"@dvai-bridge/android-llama-core": "4.0.0",
|
|
50
|
+
"@dvai-bridge/ios-llama-core": "4.0.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@dvai-bridge/capacitor": "4.0.0"
|
|
54
|
+
},
|
|
55
|
+
"capacitor": {
|
|
56
|
+
"ios": {
|
|
57
|
+
"src": "ios"
|
|
58
|
+
},
|
|
59
|
+
"android": {
|
|
60
|
+
"src": "android"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "tsup",
|
|
65
|
+
"mac:build": "pwsh -File ../../scripts/mac-build.ps1 -Action build -Target capacitor-llama",
|
|
66
|
+
"mac:test": "pwsh -File ../../scripts/mac-build.ps1 -Action test -Target capacitor-llama",
|
|
67
|
+
"android-test": "cd android && ./gradlew test",
|
|
68
|
+
"android-test:instrumented": "cd android && ./gradlew connectedAndroidTest"
|
|
69
|
+
}
|
|
70
|
+
}
|