@dvai-bridge/ios-llama-core 4.0.0 → 4.0.2

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.
@@ -1,515 +1,515 @@
1
- import XCTest
2
- @testable import DVAILlamaCore
3
- import DVAILlamaCoreObjC
4
- import DVAISharedCore
5
-
6
- /// Mock bridge so handler tests don't need a real GGUF model loaded.
7
- /// Records the prompt that was passed in and returns canned values.
8
- final class MockBridge: LlamaCppBridgeProtocol {
9
- var loaded: Bool = true
10
- var completionToReturn: String = "canned response"
11
- var multimodalCompletionToReturn: String = "canned multimodal response"
12
- var embeddingToReturn: [NSNumber] = [NSNumber(value: 0.1), NSNumber(value: 0.2), NSNumber(value: 0.3)]
13
- var receivedPrompt: String?
14
- var receivedMultimodalPrompt: String?
15
- var receivedMedia: [Data] = []
16
- var receivedEmbeddingTexts: [String] = []
17
- var completionShouldThrow: Bool = false
18
- var multimodalShouldThrow: Bool = false
19
- var embeddingShouldThrow: Bool = false
20
- // Phase 2A Pass 2: real mmproj surface.
21
- var mmprojLoaded: Bool = false
22
- var modelHasAudioEncoder: Bool = false
23
- var receivedMmprojPath: String?
24
- var loadMmprojShouldThrow: Bool = false
25
- // Chat template: identity-with-markers by default so tests can assert
26
- // marker count from the rendered string.
27
- var receivedChatTemplate: String?
28
- var receivedChatMessages: [[String: String]] = []
29
- var chatTemplateShouldThrow: Bool = false
30
- /// Closure that builds the rendered template from the messages. Default
31
- /// concatenates `<role>: <content>\n` for each message — preserves marker
32
- /// positions inside content fields so handler tests can verify marker count.
33
- var chatTemplateRenderer: ([[String: String]], Bool) -> String = { msgs, addAssistant in
34
- var s = ""
35
- for m in msgs {
36
- s += "\(m["role"] ?? "user"): \(m["content"] ?? "")\n"
37
- }
38
- if addAssistant { s += "assistant:" }
39
- return s
40
- }
41
-
42
- var isLoaded: Bool { loaded }
43
- var isMmprojLoaded: Bool { mmprojLoaded }
44
-
45
- func completePrompt(_ prompt: String, maxTokens: Int32, temperature: Float, topP: Float) throws -> String {
46
- receivedPrompt = prompt
47
- if completionShouldThrow {
48
- throw NSError(domain: "MockBridge", code: 99, userInfo: [NSLocalizedDescriptionKey: "boom"])
49
- }
50
- return completionToReturn
51
- }
52
-
53
- func embedding(_ text: String) throws -> [NSNumber] {
54
- receivedEmbeddingTexts.append(text)
55
- if embeddingShouldThrow {
56
- throw NSError(domain: "MockBridge", code: 100, userInfo: [NSLocalizedDescriptionKey: "embed boom"])
57
- }
58
- return embeddingToReturn
59
- }
60
-
61
- func loadMmproj(atPath path: String) throws {
62
- receivedMmprojPath = path
63
- if loadMmprojShouldThrow {
64
- throw NSError(domain: "MockBridge", code: 101, userInfo: [NSLocalizedDescriptionKey: "mmproj boom"])
65
- }
66
- mmprojLoaded = true
67
- }
68
-
69
- func unloadMmproj() {
70
- mmprojLoaded = false
71
- }
72
-
73
- func hasAudioEncoder() -> Bool { modelHasAudioEncoder }
74
-
75
- func applyChatTemplate(
76
- _ templateOverride: String?,
77
- messages: [[String: String]],
78
- addAssistant: Bool
79
- ) throws -> String {
80
- receivedChatTemplate = templateOverride
81
- receivedChatMessages = messages
82
- if chatTemplateShouldThrow {
83
- throw NSError(domain: "MockBridge", code: 102, userInfo: [NSLocalizedDescriptionKey: "template boom"])
84
- }
85
- return chatTemplateRenderer(messages, addAssistant)
86
- }
87
-
88
- func completeMultimodalPrompt(
89
- _ prompt: String,
90
- media: [Data],
91
- maxTokens: Int32,
92
- temperature: Float,
93
- topP: Float
94
- ) throws -> String {
95
- receivedMultimodalPrompt = prompt
96
- receivedMedia = media
97
- if multimodalShouldThrow {
98
- throw NSError(domain: "MockBridge", code: 103, userInfo: [NSLocalizedDescriptionKey: "multimodal boom"])
99
- }
100
- return multimodalCompletionToReturn
101
- }
102
- }
103
-
104
- final class LlamaHandlersTest: XCTestCase {
105
- let ctx = HandlerContext(modelId: "test-model", backendName: "llama")
106
-
107
- private func makeHandlers(
108
- bridge: MockBridge = MockBridge(),
109
- mmprojLoaded: Bool = false,
110
- modelHasAudioEncoder: Bool = false,
111
- embeddingMode: Bool = false
112
- ) -> LlamaHandlers {
113
- LlamaHandlers(
114
- bridgeProtocol: bridge,
115
- modelId: "test-model",
116
- mmprojLoaded: mmprojLoaded,
117
- modelHasAudioEncoder: modelHasAudioEncoder,
118
- embeddingMode: embeddingMode
119
- )
120
- }
121
-
122
- // MARK: - Chat completion
123
-
124
- func testChatCompletionTextHappyPath() async throws {
125
- let bridge = MockBridge()
126
- bridge.completionToReturn = "Hello, world!"
127
- let handlers = makeHandlers(bridge: bridge)
128
- let body: [String: Any] = ["messages": [["role": "user", "content": "hi"]]]
129
- let resp = try await handlers.handleChatCompletion(body: body, ctx: ctx)
130
- guard case .json(let status, let bodyAny) = resp else {
131
- XCTFail("expected .json response, got \(resp)")
132
- return
133
- }
134
- XCTAssertEqual(status, 200)
135
- let json = bodyAny as? [String: Any]
136
- XCTAssertEqual(json?["object"] as? String, "chat.completion")
137
- XCTAssertEqual(json?["model"] as? String, "test-model")
138
- let choices = json?["choices"] as? [[String: Any]]
139
- let msg = choices?.first?["message"] as? [String: Any]
140
- XCTAssertEqual(msg?["content"] as? String, "Hello, world!")
141
- XCTAssertEqual(msg?["role"] as? String, "assistant")
142
- XCTAssertEqual(choices?.first?["finish_reason"] as? String, "stop")
143
- // Phase 2A Pass 2: receivedPrompt is now the chat-template-rendered
144
- // string (the mock concatenates `<role>: <content>\n[assistant:]`).
145
- // Just assert it contains the original user content.
146
- XCTAssertTrue(bridge.receivedPrompt?.contains("hi") ?? false, "receivedPrompt: \(String(describing: bridge.receivedPrompt))")
147
- }
148
-
149
- /// Parse a single SSE frame into a `[String: Any]` payload. Returns
150
- /// `["__done__": true]` for `data: [DONE]`, `[:]` if the frame isn't a
151
- /// `data:` line, or the parsed JSON object otherwise.
152
- private func decodeFrame(_ frame: String) -> [String: Any] {
153
- let trimmed = frame.trimmingCharacters(in: .whitespacesAndNewlines)
154
- guard trimmed.hasPrefix("data: ") else { return [:] }
155
- let payload = String(trimmed.dropFirst("data: ".count))
156
- if payload == "[DONE]" { return ["__done__": true] }
157
- guard let data = payload.data(using: .utf8),
158
- let parsed = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
159
- return [:]
160
- }
161
- return parsed
162
- }
163
-
164
- func testChatCompletionStreamingTextEmitsRoleContentFinishDone() async throws {
165
- let bridge = MockBridge()
166
- bridge.completionToReturn = "stream-canned"
167
- let handlers = makeHandlers(bridge: bridge)
168
- let body: [String: Any] = [
169
- "messages": [["role": "user", "content": "hi"]],
170
- "stream": true,
171
- ]
172
- let resp = try await handlers.handleChatCompletion(body: body, ctx: ctx)
173
- guard case .sse(let stream) = resp else {
174
- XCTFail("expected .sse response, got \(resp)")
175
- return
176
- }
177
- var collected: [String] = []
178
- for await chunk in stream {
179
- collected.append(chunk)
180
- }
181
- XCTAssertEqual(collected.count, 4, "expected 4 SSE frames: role / content / finish / [DONE]")
182
-
183
- // Frame 0: role delta
184
- let roleFrame = decodeFrame(collected[0])
185
- let roleChoices = roleFrame["choices"] as? [[String: Any]]
186
- let roleDelta = roleChoices?.first?["delta"] as? [String: Any]
187
- XCTAssertEqual(roleDelta?["role"] as? String, "assistant")
188
-
189
- // Frame 1: content delta with the canned string
190
- let contentFrame = decodeFrame(collected[1])
191
- let contentChoices = contentFrame["choices"] as? [[String: Any]]
192
- let contentDelta = contentChoices?.first?["delta"] as? [String: Any]
193
- XCTAssertEqual(contentDelta?["content"] as? String, "stream-canned")
194
-
195
- // Frame 2: finish chunk
196
- let finishFrame = decodeFrame(collected[2])
197
- let finishChoices = finishFrame["choices"] as? [[String: Any]]
198
- XCTAssertEqual(finishChoices?.first?["finish_reason"] as? String, "stop")
199
-
200
- // Frame 3: [DONE]
201
- XCTAssertEqual(collected[3], "data: [DONE]\n\n")
202
- XCTAssertEqual(decodeFrame(collected[3])["__done__"] as? Bool, true)
203
- }
204
-
205
- func testChatCompletionBridgeThrowsReturns500() async throws {
206
- let bridge = MockBridge()
207
- bridge.completionShouldThrow = true
208
- let handlers = makeHandlers(bridge: bridge)
209
- let body: [String: Any] = ["messages": [["role": "user", "content": "hi"]]]
210
- let resp = try await handlers.handleChatCompletion(body: body, ctx: ctx)
211
- guard case .error(let status, _) = resp else {
212
- XCTFail("expected .error response, got \(resp)")
213
- return
214
- }
215
- XCTAssertEqual(status, 500)
216
- }
217
-
218
- func testEmbeddingsBridgeThrowsReturns500() async throws {
219
- let bridge = MockBridge()
220
- bridge.embeddingShouldThrow = true
221
- let handlers = makeHandlers(bridge: bridge, embeddingMode: true)
222
- let resp = try await handlers.handleEmbeddings(body: ["input": "hi"], ctx: ctx)
223
- guard case .error(let status, _) = resp else {
224
- XCTFail("expected .error response, got \(resp)")
225
- return
226
- }
227
- XCTAssertEqual(status, 500)
228
- }
229
-
230
- func testChatCompletionImageWithoutMmprojReturns400() async throws {
231
- let handlers = makeHandlers(mmprojLoaded: false)
232
- let body: [String: Any] = [
233
- "messages": [[
234
- "role": "user",
235
- "content": [
236
- ["type": "image_url", "image_url": ["url": "data:image/png;base64,iVBOR"]] as [String: Any],
237
- ],
238
- ] as [String: Any]],
239
- ]
240
- let resp = try await handlers.handleChatCompletion(body: body, ctx: ctx)
241
- guard case .error(let status, let message) = resp else {
242
- XCTFail("expected .error response, got \(resp)")
243
- return
244
- }
245
- XCTAssertEqual(status, 400)
246
- XCTAssertTrue(message.contains("no mmproj was loaded"), "message: \(message)")
247
- XCTAssertTrue(message.contains("nativeMmprojPath"), "message: \(message)")
248
- }
249
-
250
- func testChatCompletionAudioWithoutEncoderReturns400() async throws {
251
- let handlers = makeHandlers(modelHasAudioEncoder: false)
252
- let body: [String: Any] = [
253
- "messages": [[
254
- "role": "user",
255
- "content": [
256
- ["type": "input_audio", "input_audio": ["data": "AAAA", "format": "pcm16"]] as [String: Any],
257
- ],
258
- ] as [String: Any]],
259
- ]
260
- let resp = try await handlers.handleChatCompletion(body: body, ctx: ctx)
261
- guard case .error(let status, let message) = resp else {
262
- XCTFail("expected .error response, got \(resp)")
263
- return
264
- }
265
- XCTAssertEqual(status, 400)
266
- XCTAssertTrue(message.contains("native audio encoder"), "message: \(message)")
267
- }
268
-
269
- // MARK: - Multimodal happy paths (Phase 2A Pass 2)
270
-
271
- /// Image happy path: mmprojLoaded=true, body contains a tiny PNG data URL.
272
- /// The mock bridge records the call to completeMultimodalPrompt with
273
- /// media.count == 1; the rendered prompt has exactly one <__media__>
274
- /// marker; assert the OpenAI chat.completion shape on response.
275
- func testChatVisionHappyPath() async throws {
276
- let bridge = MockBridge()
277
- bridge.multimodalCompletionToReturn = "I see a 1x1 transparent pixel."
278
- let handlers = makeHandlers(bridge: bridge, mmprojLoaded: true)
279
- let body: [String: Any] = [
280
- "messages": [[
281
- "role": "user",
282
- "content": [
283
- ["type": "text", "text": "What's in this image?"],
284
- ["type": "image_url", "image_url": ["url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="]] as [String: Any],
285
- ],
286
- ] as [String: Any]],
287
- ]
288
- let resp = try await handlers.handleChatCompletion(body: body, ctx: ctx)
289
- guard case .json(let status, let bodyAny) = resp else {
290
- XCTFail("expected .json response, got \(resp)")
291
- return
292
- }
293
- XCTAssertEqual(status, 200)
294
- XCTAssertEqual(bridge.receivedMedia.count, 1)
295
- // The chat-template renderer in the mock concatenates `<role>: <content>\n`,
296
- // so the rendered prompt should contain exactly one <__media__> marker.
297
- let prompt = bridge.receivedMultimodalPrompt ?? ""
298
- let markerCount = prompt.components(separatedBy: MTMD_MEDIA_MARKER).count - 1
299
- XCTAssertEqual(markerCount, 1, "expected exactly one <__media__> marker, got prompt: \(prompt)")
300
- let json = bodyAny as? [String: Any]
301
- XCTAssertEqual(json?["object"] as? String, "chat.completion")
302
- let choices = json?["choices"] as? [[String: Any]]
303
- let msg = choices?.first?["message"] as? [String: Any]
304
- XCTAssertEqual(msg?["content"] as? String, "I see a 1x1 transparent pixel.")
305
- }
306
-
307
- /// Audio happy path: modelHasAudioEncoder=true, body contains a tiny
308
- /// pcm16 base64. Bridge records the call to completeMultimodalPrompt;
309
- /// media.count == 1.
310
- func testChatAudioHappyPath() async throws {
311
- let bridge = MockBridge()
312
- bridge.multimodalCompletionToReturn = "hello world"
313
- let handlers = makeHandlers(bridge: bridge, modelHasAudioEncoder: true)
314
- // 8 zero-bytes — the pcm16 path is pass-through (no decode), so the
315
- // translator does not need to call AudioDecoder for this format.
316
- let pcm = Data(repeating: 0, count: 32)
317
- let body: [String: Any] = [
318
- "messages": [[
319
- "role": "user",
320
- "content": [
321
- ["type": "text", "text": "Transcribe:"],
322
- ["type": "input_audio", "input_audio": ["data": pcm.base64EncodedString(), "format": "pcm16"]] as [String: Any],
323
- ],
324
- ] as [String: Any]],
325
- ]
326
- let resp = try await handlers.handleChatCompletion(body: body, ctx: ctx)
327
- guard case .json(let status, _) = resp else {
328
- XCTFail("expected .json response, got \(resp)")
329
- return
330
- }
331
- XCTAssertEqual(status, 200)
332
- XCTAssertEqual(bridge.receivedMedia.count, 1)
333
- XCTAssertEqual(bridge.receivedMedia[0], pcm)
334
- let prompt = bridge.receivedMultimodalPrompt ?? ""
335
- let markerCount = prompt.components(separatedBy: MTMD_MEDIA_MARKER).count - 1
336
- XCTAssertEqual(markerCount, 1)
337
- }
338
-
339
- /// Interleaved [text, image, text, audio, text]: media list is
340
- /// [imageBytes, audioBytes] in declaration order; rendered prompt has
341
- /// exactly two <__media__> markers in the right positions.
342
- func testChatInterleavedImageAudio() async throws {
343
- let bridge = MockBridge()
344
- let handlers = makeHandlers(
345
- bridge: bridge,
346
- mmprojLoaded: true,
347
- modelHasAudioEncoder: true
348
- )
349
- let pcm = Data(repeating: 0xAB, count: 16)
350
- let body: [String: Any] = [
351
- "messages": [[
352
- "role": "user",
353
- "content": [
354
- ["type": "text", "text": "alpha"],
355
- ["type": "image_url", "image_url": ["url": "data:image/png;base64,iVBORw0KGgo="]] as [String: Any],
356
- ["type": "text", "text": "beta"],
357
- ["type": "input_audio", "input_audio": ["data": pcm.base64EncodedString(), "format": "pcm16"]] as [String: Any],
358
- ["type": "text", "text": "gamma"],
359
- ],
360
- ] as [String: Any]],
361
- ]
362
- let resp = try await handlers.handleChatCompletion(body: body, ctx: ctx)
363
- guard case .json(let status, _) = resp else {
364
- XCTFail("expected .json response, got \(resp)")
365
- return
366
- }
367
- XCTAssertEqual(status, 200)
368
- XCTAssertEqual(bridge.receivedMedia.count, 2)
369
- // Audio bytes should match (image bytes are the data-URL-decoded PNG
370
- // header — we don't assert exact bytes, just that the audio is at
371
- // index 1 after the image).
372
- XCTAssertEqual(bridge.receivedMedia[1], pcm)
373
- let prompt = bridge.receivedMultimodalPrompt ?? ""
374
- let markerCount = prompt.components(separatedBy: MTMD_MEDIA_MARKER).count - 1
375
- XCTAssertEqual(markerCount, 2, "expected exactly two markers, got prompt: \(prompt)")
376
- }
377
-
378
- func testChatCompletionMissingMessagesReturns400() async throws {
379
- let handlers = makeHandlers()
380
- let resp = try await handlers.handleChatCompletion(body: [:], ctx: ctx)
381
- guard case .error(let status, let message) = resp else {
382
- XCTFail("expected .error response, got \(resp)")
383
- return
384
- }
385
- XCTAssertEqual(status, 400)
386
- XCTAssertTrue(message.contains("messages"), "message: \(message)")
387
- }
388
-
389
- // MARK: - Legacy /v1/completions
390
-
391
- func testCompletionLegacyConvertsToTextCompletion() async throws {
392
- let bridge = MockBridge()
393
- bridge.completionToReturn = "canned-text"
394
- let handlers = makeHandlers(bridge: bridge)
395
- let resp = try await handlers.handleCompletion(body: ["prompt": "say hi"], ctx: ctx)
396
- guard case .json(let status, let bodyAny) = resp else {
397
- XCTFail("expected .json response, got \(resp)")
398
- return
399
- }
400
- XCTAssertEqual(status, 200)
401
- let json = bodyAny as? [String: Any]
402
- XCTAssertEqual(json?["object"] as? String, "text_completion")
403
- let choices = json?["choices"] as? [[String: Any]]
404
- XCTAssertEqual(choices?.first?["text"] as? String, "canned-text")
405
- XCTAssertEqual(choices?.first?["finish_reason"] as? String, "stop")
406
- // logprobs is NSNull — present in the dict.
407
- XCTAssertNotNil(choices?.first?["logprobs"])
408
- // ID was rewritten chatcmpl- → cmpl-
409
- let idStr = json?["id"] as? String ?? ""
410
- XCTAssertTrue(idStr.hasPrefix("cmpl-"), "id should start with cmpl-: \(idStr)")
411
- // Round-tripped prompt -- now wrapped by the chat-template renderer.
412
- XCTAssertTrue(bridge.receivedPrompt?.contains("say hi") ?? false, "receivedPrompt: \(String(describing: bridge.receivedPrompt))")
413
- }
414
-
415
- func testCompletionLegacyArrayPromptJoinedWithNewline() async throws {
416
- let bridge = MockBridge()
417
- let handlers = makeHandlers(bridge: bridge)
418
- let resp = try await handlers.handleCompletion(body: ["prompt": ["alpha", "beta"]], ctx: ctx)
419
- guard case .json = resp else {
420
- XCTFail("expected .json response, got \(resp)")
421
- return
422
- }
423
- XCTAssertTrue(bridge.receivedPrompt?.contains("alpha\nbeta") ?? false, "receivedPrompt: \(String(describing: bridge.receivedPrompt))")
424
- }
425
-
426
- // MARK: - Embeddings
427
-
428
- func testEmbeddingsRejectedWhenNotEmbeddingMode() async throws {
429
- let handlers = makeHandlers(embeddingMode: false)
430
- let resp = try await handlers.handleEmbeddings(body: ["input": "hello"], ctx: ctx)
431
- guard case .error(let status, let message) = resp else {
432
- XCTFail("expected .error response, got \(resp)")
433
- return
434
- }
435
- XCTAssertEqual(status, 400)
436
- XCTAssertTrue(message.contains("nativeEmbeddingMode"), "message: \(message)")
437
- }
438
-
439
- func testEmbeddingsHappyPathSingleString() async throws {
440
- let bridge = MockBridge()
441
- bridge.embeddingToReturn = [NSNumber(value: 0.5), NSNumber(value: -0.25), NSNumber(value: 1.0)]
442
- let handlers = makeHandlers(bridge: bridge, embeddingMode: true)
443
- let resp = try await handlers.handleEmbeddings(body: ["input": "hello"], ctx: ctx)
444
- guard case .json(let status, let bodyAny) = resp else {
445
- XCTFail("expected .json response, got \(resp)")
446
- return
447
- }
448
- XCTAssertEqual(status, 200)
449
- let json = bodyAny as? [String: Any]
450
- XCTAssertEqual(json?["object"] as? String, "list")
451
- XCTAssertEqual(json?["model"] as? String, "test-model")
452
- let data = json?["data"] as? [[String: Any]]
453
- XCTAssertEqual(data?.count, 1)
454
- XCTAssertEqual(data?.first?["object"] as? String, "embedding")
455
- XCTAssertEqual(data?.first?["index"] as? Int, 0)
456
- let vec = data?.first?["embedding"] as? [Double]
457
- XCTAssertEqual(vec?.count, 3)
458
- guard let unwrapped = vec, unwrapped.count == 3 else {
459
- XCTFail("expected 3-element vec; got \(String(describing: vec))")
460
- return
461
- }
462
- XCTAssertEqual(unwrapped[0], 0.5, accuracy: 1e-6)
463
- XCTAssertEqual(unwrapped[1], -0.25, accuracy: 1e-6)
464
- XCTAssertEqual(unwrapped[2], 1.0, accuracy: 1e-6)
465
- XCTAssertEqual(bridge.receivedEmbeddingTexts, ["hello"])
466
- }
467
-
468
- func testEmbeddingsArrayInputProducesMultipleEntries() async throws {
469
- let bridge = MockBridge()
470
- let handlers = makeHandlers(bridge: bridge, embeddingMode: true)
471
- let resp = try await handlers.handleEmbeddings(
472
- body: ["input": ["alpha", "beta", "gamma"]],
473
- ctx: ctx
474
- )
475
- guard case .json(let status, let bodyAny) = resp else {
476
- XCTFail("expected .json response, got \(resp)")
477
- return
478
- }
479
- XCTAssertEqual(status, 200)
480
- let data = (bodyAny as? [String: Any])?["data"] as? [[String: Any]]
481
- XCTAssertEqual(data?.count, 3)
482
- XCTAssertEqual(data?[0]["index"] as? Int, 0)
483
- XCTAssertEqual(data?[1]["index"] as? Int, 1)
484
- XCTAssertEqual(data?[2]["index"] as? Int, 2)
485
- XCTAssertEqual(bridge.receivedEmbeddingTexts, ["alpha", "beta", "gamma"])
486
- }
487
-
488
- func testEmbeddingsMissingInputReturns400() async throws {
489
- let handlers = makeHandlers(embeddingMode: true)
490
- let resp = try await handlers.handleEmbeddings(body: [:], ctx: ctx)
491
- guard case .error(let status, _) = resp else {
492
- XCTFail("expected .error response, got \(resp)")
493
- return
494
- }
495
- XCTAssertEqual(status, 400)
496
- }
497
-
498
- // MARK: - Models
499
-
500
- func testModelsReturnsSingleEntryListWithCtxModelId() async throws {
501
- let handlers = makeHandlers()
502
- let customCtx = HandlerContext(modelId: "/path/to/model.gguf", backendName: "llama")
503
- let resp = try await handlers.handleModels(ctx: customCtx)
504
- guard case .json(let status, let bodyAny) = resp else {
505
- XCTFail("expected .json response, got \(resp)")
506
- return
507
- }
508
- XCTAssertEqual(status, 200)
509
- let json = bodyAny as? [String: Any]
510
- XCTAssertEqual(json?["object"] as? String, "list")
511
- let data = json?["data"] as? [[String: Any]]
512
- XCTAssertEqual(data?.count, 1)
513
- XCTAssertEqual(data?.first?["id"] as? String, "/path/to/model.gguf")
514
- }
515
- }
1
+ import XCTest
2
+ @testable import DVAILlamaCore
3
+ import DVAILlamaCoreObjC
4
+ import DVAISharedCore
5
+
6
+ /// Mock bridge so handler tests don't need a real GGUF model loaded.
7
+ /// Records the prompt that was passed in and returns canned values.
8
+ final class MockBridge: LlamaCppBridgeProtocol {
9
+ var loaded: Bool = true
10
+ var completionToReturn: String = "canned response"
11
+ var multimodalCompletionToReturn: String = "canned multimodal response"
12
+ var embeddingToReturn: [NSNumber] = [NSNumber(value: 0.1), NSNumber(value: 0.2), NSNumber(value: 0.3)]
13
+ var receivedPrompt: String?
14
+ var receivedMultimodalPrompt: String?
15
+ var receivedMedia: [Data] = []
16
+ var receivedEmbeddingTexts: [String] = []
17
+ var completionShouldThrow: Bool = false
18
+ var multimodalShouldThrow: Bool = false
19
+ var embeddingShouldThrow: Bool = false
20
+ // Phase 2A Pass 2: real mmproj surface.
21
+ var mmprojLoaded: Bool = false
22
+ var modelHasAudioEncoder: Bool = false
23
+ var receivedMmprojPath: String?
24
+ var loadMmprojShouldThrow: Bool = false
25
+ // Chat template: identity-with-markers by default so tests can assert
26
+ // marker count from the rendered string.
27
+ var receivedChatTemplate: String?
28
+ var receivedChatMessages: [[String: String]] = []
29
+ var chatTemplateShouldThrow: Bool = false
30
+ /// Closure that builds the rendered template from the messages. Default
31
+ /// concatenates `<role>: <content>\n` for each message — preserves marker
32
+ /// positions inside content fields so handler tests can verify marker count.
33
+ var chatTemplateRenderer: ([[String: String]], Bool) -> String = { msgs, addAssistant in
34
+ var s = ""
35
+ for m in msgs {
36
+ s += "\(m["role"] ?? "user"): \(m["content"] ?? "")\n"
37
+ }
38
+ if addAssistant { s += "assistant:" }
39
+ return s
40
+ }
41
+
42
+ var isLoaded: Bool { loaded }
43
+ var isMmprojLoaded: Bool { mmprojLoaded }
44
+
45
+ func completePrompt(_ prompt: String, maxTokens: Int32, temperature: Float, topP: Float) throws -> String {
46
+ receivedPrompt = prompt
47
+ if completionShouldThrow {
48
+ throw NSError(domain: "MockBridge", code: 99, userInfo: [NSLocalizedDescriptionKey: "boom"])
49
+ }
50
+ return completionToReturn
51
+ }
52
+
53
+ func embedding(_ text: String) throws -> [NSNumber] {
54
+ receivedEmbeddingTexts.append(text)
55
+ if embeddingShouldThrow {
56
+ throw NSError(domain: "MockBridge", code: 100, userInfo: [NSLocalizedDescriptionKey: "embed boom"])
57
+ }
58
+ return embeddingToReturn
59
+ }
60
+
61
+ func loadMmproj(atPath path: String) throws {
62
+ receivedMmprojPath = path
63
+ if loadMmprojShouldThrow {
64
+ throw NSError(domain: "MockBridge", code: 101, userInfo: [NSLocalizedDescriptionKey: "mmproj boom"])
65
+ }
66
+ mmprojLoaded = true
67
+ }
68
+
69
+ func unloadMmproj() {
70
+ mmprojLoaded = false
71
+ }
72
+
73
+ func hasAudioEncoder() -> Bool { modelHasAudioEncoder }
74
+
75
+ func applyChatTemplate(
76
+ _ templateOverride: String?,
77
+ messages: [[String: String]],
78
+ addAssistant: Bool
79
+ ) throws -> String {
80
+ receivedChatTemplate = templateOverride
81
+ receivedChatMessages = messages
82
+ if chatTemplateShouldThrow {
83
+ throw NSError(domain: "MockBridge", code: 102, userInfo: [NSLocalizedDescriptionKey: "template boom"])
84
+ }
85
+ return chatTemplateRenderer(messages, addAssistant)
86
+ }
87
+
88
+ func completeMultimodalPrompt(
89
+ _ prompt: String,
90
+ media: [Data],
91
+ maxTokens: Int32,
92
+ temperature: Float,
93
+ topP: Float
94
+ ) throws -> String {
95
+ receivedMultimodalPrompt = prompt
96
+ receivedMedia = media
97
+ if multimodalShouldThrow {
98
+ throw NSError(domain: "MockBridge", code: 103, userInfo: [NSLocalizedDescriptionKey: "multimodal boom"])
99
+ }
100
+ return multimodalCompletionToReturn
101
+ }
102
+ }
103
+
104
+ final class LlamaHandlersTest: XCTestCase {
105
+ let ctx = HandlerContext(modelId: "test-model", backendName: "llama")
106
+
107
+ private func makeHandlers(
108
+ bridge: MockBridge = MockBridge(),
109
+ mmprojLoaded: Bool = false,
110
+ modelHasAudioEncoder: Bool = false,
111
+ embeddingMode: Bool = false
112
+ ) -> LlamaHandlers {
113
+ LlamaHandlers(
114
+ bridgeProtocol: bridge,
115
+ modelId: "test-model",
116
+ mmprojLoaded: mmprojLoaded,
117
+ modelHasAudioEncoder: modelHasAudioEncoder,
118
+ embeddingMode: embeddingMode
119
+ )
120
+ }
121
+
122
+ // MARK: - Chat completion
123
+
124
+ func testChatCompletionTextHappyPath() async throws {
125
+ let bridge = MockBridge()
126
+ bridge.completionToReturn = "Hello, world!"
127
+ let handlers = makeHandlers(bridge: bridge)
128
+ let body: [String: Any] = ["messages": [["role": "user", "content": "hi"]]]
129
+ let resp = try await handlers.handleChatCompletion(body: body, ctx: ctx)
130
+ guard case .json(let status, let bodyAny) = resp else {
131
+ XCTFail("expected .json response, got \(resp)")
132
+ return
133
+ }
134
+ XCTAssertEqual(status, 200)
135
+ let json = bodyAny as? [String: Any]
136
+ XCTAssertEqual(json?["object"] as? String, "chat.completion")
137
+ XCTAssertEqual(json?["model"] as? String, "test-model")
138
+ let choices = json?["choices"] as? [[String: Any]]
139
+ let msg = choices?.first?["message"] as? [String: Any]
140
+ XCTAssertEqual(msg?["content"] as? String, "Hello, world!")
141
+ XCTAssertEqual(msg?["role"] as? String, "assistant")
142
+ XCTAssertEqual(choices?.first?["finish_reason"] as? String, "stop")
143
+ // Phase 2A Pass 2: receivedPrompt is now the chat-template-rendered
144
+ // string (the mock concatenates `<role>: <content>\n[assistant:]`).
145
+ // Just assert it contains the original user content.
146
+ XCTAssertTrue(bridge.receivedPrompt?.contains("hi") ?? false, "receivedPrompt: \(String(describing: bridge.receivedPrompt))")
147
+ }
148
+
149
+ /// Parse a single SSE frame into a `[String: Any]` payload. Returns
150
+ /// `["__done__": true]` for `data: [DONE]`, `[:]` if the frame isn't a
151
+ /// `data:` line, or the parsed JSON object otherwise.
152
+ private func decodeFrame(_ frame: String) -> [String: Any] {
153
+ let trimmed = frame.trimmingCharacters(in: .whitespacesAndNewlines)
154
+ guard trimmed.hasPrefix("data: ") else { return [:] }
155
+ let payload = String(trimmed.dropFirst("data: ".count))
156
+ if payload == "[DONE]" { return ["__done__": true] }
157
+ guard let data = payload.data(using: .utf8),
158
+ let parsed = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
159
+ return [:]
160
+ }
161
+ return parsed
162
+ }
163
+
164
+ func testChatCompletionStreamingTextEmitsRoleContentFinishDone() async throws {
165
+ let bridge = MockBridge()
166
+ bridge.completionToReturn = "stream-canned"
167
+ let handlers = makeHandlers(bridge: bridge)
168
+ let body: [String: Any] = [
169
+ "messages": [["role": "user", "content": "hi"]],
170
+ "stream": true,
171
+ ]
172
+ let resp = try await handlers.handleChatCompletion(body: body, ctx: ctx)
173
+ guard case .sse(let stream) = resp else {
174
+ XCTFail("expected .sse response, got \(resp)")
175
+ return
176
+ }
177
+ var collected: [String] = []
178
+ for await chunk in stream {
179
+ collected.append(chunk)
180
+ }
181
+ XCTAssertEqual(collected.count, 4, "expected 4 SSE frames: role / content / finish / [DONE]")
182
+
183
+ // Frame 0: role delta
184
+ let roleFrame = decodeFrame(collected[0])
185
+ let roleChoices = roleFrame["choices"] as? [[String: Any]]
186
+ let roleDelta = roleChoices?.first?["delta"] as? [String: Any]
187
+ XCTAssertEqual(roleDelta?["role"] as? String, "assistant")
188
+
189
+ // Frame 1: content delta with the canned string
190
+ let contentFrame = decodeFrame(collected[1])
191
+ let contentChoices = contentFrame["choices"] as? [[String: Any]]
192
+ let contentDelta = contentChoices?.first?["delta"] as? [String: Any]
193
+ XCTAssertEqual(contentDelta?["content"] as? String, "stream-canned")
194
+
195
+ // Frame 2: finish chunk
196
+ let finishFrame = decodeFrame(collected[2])
197
+ let finishChoices = finishFrame["choices"] as? [[String: Any]]
198
+ XCTAssertEqual(finishChoices?.first?["finish_reason"] as? String, "stop")
199
+
200
+ // Frame 3: [DONE]
201
+ XCTAssertEqual(collected[3], "data: [DONE]\n\n")
202
+ XCTAssertEqual(decodeFrame(collected[3])["__done__"] as? Bool, true)
203
+ }
204
+
205
+ func testChatCompletionBridgeThrowsReturns500() async throws {
206
+ let bridge = MockBridge()
207
+ bridge.completionShouldThrow = true
208
+ let handlers = makeHandlers(bridge: bridge)
209
+ let body: [String: Any] = ["messages": [["role": "user", "content": "hi"]]]
210
+ let resp = try await handlers.handleChatCompletion(body: body, ctx: ctx)
211
+ guard case .error(let status, _) = resp else {
212
+ XCTFail("expected .error response, got \(resp)")
213
+ return
214
+ }
215
+ XCTAssertEqual(status, 500)
216
+ }
217
+
218
+ func testEmbeddingsBridgeThrowsReturns500() async throws {
219
+ let bridge = MockBridge()
220
+ bridge.embeddingShouldThrow = true
221
+ let handlers = makeHandlers(bridge: bridge, embeddingMode: true)
222
+ let resp = try await handlers.handleEmbeddings(body: ["input": "hi"], ctx: ctx)
223
+ guard case .error(let status, _) = resp else {
224
+ XCTFail("expected .error response, got \(resp)")
225
+ return
226
+ }
227
+ XCTAssertEqual(status, 500)
228
+ }
229
+
230
+ func testChatCompletionImageWithoutMmprojReturns400() async throws {
231
+ let handlers = makeHandlers(mmprojLoaded: false)
232
+ let body: [String: Any] = [
233
+ "messages": [[
234
+ "role": "user",
235
+ "content": [
236
+ ["type": "image_url", "image_url": ["url": "data:image/png;base64,iVBOR"]] as [String: Any],
237
+ ],
238
+ ] as [String: Any]],
239
+ ]
240
+ let resp = try await handlers.handleChatCompletion(body: body, ctx: ctx)
241
+ guard case .error(let status, let message) = resp else {
242
+ XCTFail("expected .error response, got \(resp)")
243
+ return
244
+ }
245
+ XCTAssertEqual(status, 400)
246
+ XCTAssertTrue(message.contains("no mmproj was loaded"), "message: \(message)")
247
+ XCTAssertTrue(message.contains("nativeMmprojPath"), "message: \(message)")
248
+ }
249
+
250
+ func testChatCompletionAudioWithoutEncoderReturns400() async throws {
251
+ let handlers = makeHandlers(modelHasAudioEncoder: false)
252
+ let body: [String: Any] = [
253
+ "messages": [[
254
+ "role": "user",
255
+ "content": [
256
+ ["type": "input_audio", "input_audio": ["data": "AAAA", "format": "pcm16"]] as [String: Any],
257
+ ],
258
+ ] as [String: Any]],
259
+ ]
260
+ let resp = try await handlers.handleChatCompletion(body: body, ctx: ctx)
261
+ guard case .error(let status, let message) = resp else {
262
+ XCTFail("expected .error response, got \(resp)")
263
+ return
264
+ }
265
+ XCTAssertEqual(status, 400)
266
+ XCTAssertTrue(message.contains("native audio encoder"), "message: \(message)")
267
+ }
268
+
269
+ // MARK: - Multimodal happy paths (Phase 2A Pass 2)
270
+
271
+ /// Image happy path: mmprojLoaded=true, body contains a tiny PNG data URL.
272
+ /// The mock bridge records the call to completeMultimodalPrompt with
273
+ /// media.count == 1; the rendered prompt has exactly one <__media__>
274
+ /// marker; assert the OpenAI chat.completion shape on response.
275
+ func testChatVisionHappyPath() async throws {
276
+ let bridge = MockBridge()
277
+ bridge.multimodalCompletionToReturn = "I see a 1x1 transparent pixel."
278
+ let handlers = makeHandlers(bridge: bridge, mmprojLoaded: true)
279
+ let body: [String: Any] = [
280
+ "messages": [[
281
+ "role": "user",
282
+ "content": [
283
+ ["type": "text", "text": "What's in this image?"],
284
+ ["type": "image_url", "image_url": ["url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="]] as [String: Any],
285
+ ],
286
+ ] as [String: Any]],
287
+ ]
288
+ let resp = try await handlers.handleChatCompletion(body: body, ctx: ctx)
289
+ guard case .json(let status, let bodyAny) = resp else {
290
+ XCTFail("expected .json response, got \(resp)")
291
+ return
292
+ }
293
+ XCTAssertEqual(status, 200)
294
+ XCTAssertEqual(bridge.receivedMedia.count, 1)
295
+ // The chat-template renderer in the mock concatenates `<role>: <content>\n`,
296
+ // so the rendered prompt should contain exactly one <__media__> marker.
297
+ let prompt = bridge.receivedMultimodalPrompt ?? ""
298
+ let markerCount = prompt.components(separatedBy: MTMD_MEDIA_MARKER).count - 1
299
+ XCTAssertEqual(markerCount, 1, "expected exactly one <__media__> marker, got prompt: \(prompt)")
300
+ let json = bodyAny as? [String: Any]
301
+ XCTAssertEqual(json?["object"] as? String, "chat.completion")
302
+ let choices = json?["choices"] as? [[String: Any]]
303
+ let msg = choices?.first?["message"] as? [String: Any]
304
+ XCTAssertEqual(msg?["content"] as? String, "I see a 1x1 transparent pixel.")
305
+ }
306
+
307
+ /// Audio happy path: modelHasAudioEncoder=true, body contains a tiny
308
+ /// pcm16 base64. Bridge records the call to completeMultimodalPrompt;
309
+ /// media.count == 1.
310
+ func testChatAudioHappyPath() async throws {
311
+ let bridge = MockBridge()
312
+ bridge.multimodalCompletionToReturn = "hello world"
313
+ let handlers = makeHandlers(bridge: bridge, modelHasAudioEncoder: true)
314
+ // 8 zero-bytes — the pcm16 path is pass-through (no decode), so the
315
+ // translator does not need to call AudioDecoder for this format.
316
+ let pcm = Data(repeating: 0, count: 32)
317
+ let body: [String: Any] = [
318
+ "messages": [[
319
+ "role": "user",
320
+ "content": [
321
+ ["type": "text", "text": "Transcribe:"],
322
+ ["type": "input_audio", "input_audio": ["data": pcm.base64EncodedString(), "format": "pcm16"]] as [String: Any],
323
+ ],
324
+ ] as [String: Any]],
325
+ ]
326
+ let resp = try await handlers.handleChatCompletion(body: body, ctx: ctx)
327
+ guard case .json(let status, _) = resp else {
328
+ XCTFail("expected .json response, got \(resp)")
329
+ return
330
+ }
331
+ XCTAssertEqual(status, 200)
332
+ XCTAssertEqual(bridge.receivedMedia.count, 1)
333
+ XCTAssertEqual(bridge.receivedMedia[0], pcm)
334
+ let prompt = bridge.receivedMultimodalPrompt ?? ""
335
+ let markerCount = prompt.components(separatedBy: MTMD_MEDIA_MARKER).count - 1
336
+ XCTAssertEqual(markerCount, 1)
337
+ }
338
+
339
+ /// Interleaved [text, image, text, audio, text]: media list is
340
+ /// [imageBytes, audioBytes] in declaration order; rendered prompt has
341
+ /// exactly two <__media__> markers in the right positions.
342
+ func testChatInterleavedImageAudio() async throws {
343
+ let bridge = MockBridge()
344
+ let handlers = makeHandlers(
345
+ bridge: bridge,
346
+ mmprojLoaded: true,
347
+ modelHasAudioEncoder: true
348
+ )
349
+ let pcm = Data(repeating: 0xAB, count: 16)
350
+ let body: [String: Any] = [
351
+ "messages": [[
352
+ "role": "user",
353
+ "content": [
354
+ ["type": "text", "text": "alpha"],
355
+ ["type": "image_url", "image_url": ["url": "data:image/png;base64,iVBORw0KGgo="]] as [String: Any],
356
+ ["type": "text", "text": "beta"],
357
+ ["type": "input_audio", "input_audio": ["data": pcm.base64EncodedString(), "format": "pcm16"]] as [String: Any],
358
+ ["type": "text", "text": "gamma"],
359
+ ],
360
+ ] as [String: Any]],
361
+ ]
362
+ let resp = try await handlers.handleChatCompletion(body: body, ctx: ctx)
363
+ guard case .json(let status, _) = resp else {
364
+ XCTFail("expected .json response, got \(resp)")
365
+ return
366
+ }
367
+ XCTAssertEqual(status, 200)
368
+ XCTAssertEqual(bridge.receivedMedia.count, 2)
369
+ // Audio bytes should match (image bytes are the data-URL-decoded PNG
370
+ // header — we don't assert exact bytes, just that the audio is at
371
+ // index 1 after the image).
372
+ XCTAssertEqual(bridge.receivedMedia[1], pcm)
373
+ let prompt = bridge.receivedMultimodalPrompt ?? ""
374
+ let markerCount = prompt.components(separatedBy: MTMD_MEDIA_MARKER).count - 1
375
+ XCTAssertEqual(markerCount, 2, "expected exactly two markers, got prompt: \(prompt)")
376
+ }
377
+
378
+ func testChatCompletionMissingMessagesReturns400() async throws {
379
+ let handlers = makeHandlers()
380
+ let resp = try await handlers.handleChatCompletion(body: [:], ctx: ctx)
381
+ guard case .error(let status, let message) = resp else {
382
+ XCTFail("expected .error response, got \(resp)")
383
+ return
384
+ }
385
+ XCTAssertEqual(status, 400)
386
+ XCTAssertTrue(message.contains("messages"), "message: \(message)")
387
+ }
388
+
389
+ // MARK: - Legacy /v1/completions
390
+
391
+ func testCompletionLegacyConvertsToTextCompletion() async throws {
392
+ let bridge = MockBridge()
393
+ bridge.completionToReturn = "canned-text"
394
+ let handlers = makeHandlers(bridge: bridge)
395
+ let resp = try await handlers.handleCompletion(body: ["prompt": "say hi"], ctx: ctx)
396
+ guard case .json(let status, let bodyAny) = resp else {
397
+ XCTFail("expected .json response, got \(resp)")
398
+ return
399
+ }
400
+ XCTAssertEqual(status, 200)
401
+ let json = bodyAny as? [String: Any]
402
+ XCTAssertEqual(json?["object"] as? String, "text_completion")
403
+ let choices = json?["choices"] as? [[String: Any]]
404
+ XCTAssertEqual(choices?.first?["text"] as? String, "canned-text")
405
+ XCTAssertEqual(choices?.first?["finish_reason"] as? String, "stop")
406
+ // logprobs is NSNull — present in the dict.
407
+ XCTAssertNotNil(choices?.first?["logprobs"])
408
+ // ID was rewritten chatcmpl- → cmpl-
409
+ let idStr = json?["id"] as? String ?? ""
410
+ XCTAssertTrue(idStr.hasPrefix("cmpl-"), "id should start with cmpl-: \(idStr)")
411
+ // Round-tripped prompt -- now wrapped by the chat-template renderer.
412
+ XCTAssertTrue(bridge.receivedPrompt?.contains("say hi") ?? false, "receivedPrompt: \(String(describing: bridge.receivedPrompt))")
413
+ }
414
+
415
+ func testCompletionLegacyArrayPromptJoinedWithNewline() async throws {
416
+ let bridge = MockBridge()
417
+ let handlers = makeHandlers(bridge: bridge)
418
+ let resp = try await handlers.handleCompletion(body: ["prompt": ["alpha", "beta"]], ctx: ctx)
419
+ guard case .json = resp else {
420
+ XCTFail("expected .json response, got \(resp)")
421
+ return
422
+ }
423
+ XCTAssertTrue(bridge.receivedPrompt?.contains("alpha\nbeta") ?? false, "receivedPrompt: \(String(describing: bridge.receivedPrompt))")
424
+ }
425
+
426
+ // MARK: - Embeddings
427
+
428
+ func testEmbeddingsRejectedWhenNotEmbeddingMode() async throws {
429
+ let handlers = makeHandlers(embeddingMode: false)
430
+ let resp = try await handlers.handleEmbeddings(body: ["input": "hello"], ctx: ctx)
431
+ guard case .error(let status, let message) = resp else {
432
+ XCTFail("expected .error response, got \(resp)")
433
+ return
434
+ }
435
+ XCTAssertEqual(status, 400)
436
+ XCTAssertTrue(message.contains("nativeEmbeddingMode"), "message: \(message)")
437
+ }
438
+
439
+ func testEmbeddingsHappyPathSingleString() async throws {
440
+ let bridge = MockBridge()
441
+ bridge.embeddingToReturn = [NSNumber(value: 0.5), NSNumber(value: -0.25), NSNumber(value: 1.0)]
442
+ let handlers = makeHandlers(bridge: bridge, embeddingMode: true)
443
+ let resp = try await handlers.handleEmbeddings(body: ["input": "hello"], ctx: ctx)
444
+ guard case .json(let status, let bodyAny) = resp else {
445
+ XCTFail("expected .json response, got \(resp)")
446
+ return
447
+ }
448
+ XCTAssertEqual(status, 200)
449
+ let json = bodyAny as? [String: Any]
450
+ XCTAssertEqual(json?["object"] as? String, "list")
451
+ XCTAssertEqual(json?["model"] as? String, "test-model")
452
+ let data = json?["data"] as? [[String: Any]]
453
+ XCTAssertEqual(data?.count, 1)
454
+ XCTAssertEqual(data?.first?["object"] as? String, "embedding")
455
+ XCTAssertEqual(data?.first?["index"] as? Int, 0)
456
+ let vec = data?.first?["embedding"] as? [Double]
457
+ XCTAssertEqual(vec?.count, 3)
458
+ guard let unwrapped = vec, unwrapped.count == 3 else {
459
+ XCTFail("expected 3-element vec; got \(String(describing: vec))")
460
+ return
461
+ }
462
+ XCTAssertEqual(unwrapped[0], 0.5, accuracy: 1e-6)
463
+ XCTAssertEqual(unwrapped[1], -0.25, accuracy: 1e-6)
464
+ XCTAssertEqual(unwrapped[2], 1.0, accuracy: 1e-6)
465
+ XCTAssertEqual(bridge.receivedEmbeddingTexts, ["hello"])
466
+ }
467
+
468
+ func testEmbeddingsArrayInputProducesMultipleEntries() async throws {
469
+ let bridge = MockBridge()
470
+ let handlers = makeHandlers(bridge: bridge, embeddingMode: true)
471
+ let resp = try await handlers.handleEmbeddings(
472
+ body: ["input": ["alpha", "beta", "gamma"]],
473
+ ctx: ctx
474
+ )
475
+ guard case .json(let status, let bodyAny) = resp else {
476
+ XCTFail("expected .json response, got \(resp)")
477
+ return
478
+ }
479
+ XCTAssertEqual(status, 200)
480
+ let data = (bodyAny as? [String: Any])?["data"] as? [[String: Any]]
481
+ XCTAssertEqual(data?.count, 3)
482
+ XCTAssertEqual(data?[0]["index"] as? Int, 0)
483
+ XCTAssertEqual(data?[1]["index"] as? Int, 1)
484
+ XCTAssertEqual(data?[2]["index"] as? Int, 2)
485
+ XCTAssertEqual(bridge.receivedEmbeddingTexts, ["alpha", "beta", "gamma"])
486
+ }
487
+
488
+ func testEmbeddingsMissingInputReturns400() async throws {
489
+ let handlers = makeHandlers(embeddingMode: true)
490
+ let resp = try await handlers.handleEmbeddings(body: [:], ctx: ctx)
491
+ guard case .error(let status, _) = resp else {
492
+ XCTFail("expected .error response, got \(resp)")
493
+ return
494
+ }
495
+ XCTAssertEqual(status, 400)
496
+ }
497
+
498
+ // MARK: - Models
499
+
500
+ func testModelsReturnsSingleEntryListWithCtxModelId() async throws {
501
+ let handlers = makeHandlers()
502
+ let customCtx = HandlerContext(modelId: "/path/to/model.gguf", backendName: "llama")
503
+ let resp = try await handlers.handleModels(ctx: customCtx)
504
+ guard case .json(let status, let bodyAny) = resp else {
505
+ XCTFail("expected .json response, got \(resp)")
506
+ return
507
+ }
508
+ XCTAssertEqual(status, 200)
509
+ let json = bodyAny as? [String: Any]
510
+ XCTAssertEqual(json?["object"] as? String, "list")
511
+ let data = json?["data"] as? [[String: Any]]
512
+ XCTAssertEqual(data?.count, 1)
513
+ XCTAssertEqual(data?.first?["id"] as? String, "/path/to/model.gguf")
514
+ }
515
+ }