@dvai-bridge/ios-llama-core 4.0.0 → 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 +341 -34
- package/Package.swift +71 -71
- package/ios/Sources/DVAILlamaCore/AudioDecoder.swift +112 -112
- package/ios/Sources/DVAILlamaCore/ContentPartsTranslator.swift +232 -232
- package/ios/Sources/DVAILlamaCore/ImageDecoder.swift +91 -91
- package/ios/Sources/DVAILlamaCore/LlamaCppBridgeProtocol.swift +59 -59
- package/ios/Sources/DVAILlamaCore/LlamaHandlers.swift +422 -422
- package/ios/Sources/DVAILlamaCore/ModelDownloader.swift +445 -445
- package/ios/Sources/DVAILlamaCore/PluginState.swift +158 -158
- package/ios/Sources/DVAILlamaCoreObjC/LlamaCppBridge.mm +649 -649
- package/ios/Sources/DVAILlamaCoreObjC/include/LlamaCppBridge.h +101 -101
- package/ios/Tests/DVAILlamaCoreTests/AudioDecoderTest.swift +46 -46
- package/ios/Tests/DVAILlamaCoreTests/ContentPartsTranslatorTest.swift +361 -361
- package/ios/Tests/DVAILlamaCoreTests/ImageDecoderTest.swift +139 -139
- package/ios/Tests/DVAILlamaCoreTests/LlamaCppBridgeTest.swift +131 -131
- package/ios/Tests/DVAILlamaCoreTests/LlamaHandlersTest.swift +515 -515
- package/ios/Tests/DVAILlamaCoreTests/ModelDownloaderTest.swift +89 -89
- package/ios/Tests/DVAILlamaCoreTests/PluginStateTest.swift +51 -51
- package/package.json +3 -3
- package/README.md +0 -199
|
@@ -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
|
+
}
|