@dvai-bridge/ios 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/DVAIBridge.podspec +120 -0
  2. package/LICENSE +51 -0
  3. package/Package.swift +104 -0
  4. package/README.md +199 -0
  5. package/ios/Sources/DVAIBridge/BackendKind.swift +23 -0
  6. package/ios/Sources/DVAIBridge/BoundServer.swift +46 -0
  7. package/ios/Sources/DVAIBridge/Capability/CapabilityCache.swift +85 -0
  8. package/ios/Sources/DVAIBridge/Capability/CapabilityPrecheck.swift +193 -0
  9. package/ios/Sources/DVAIBridge/Capability/CapabilityScore.swift +51 -0
  10. package/ios/Sources/DVAIBridge/Capability/DeviceID.swift +70 -0
  11. package/ios/Sources/DVAIBridge/Capability/HardwareAssessment.swift +41 -0
  12. package/ios/Sources/DVAIBridge/DVAIBridge.swift +658 -0
  13. package/ios/Sources/DVAIBridge/DVAIBridgeConfig.swift +86 -0
  14. package/ios/Sources/DVAIBridge/DVAIBridgeError.swift +33 -0
  15. package/ios/Sources/DVAIBridge/Discovery/MDNSPeer.swift +64 -0
  16. package/ios/Sources/DVAIBridge/Discovery/NWAdvertiser.swift +103 -0
  17. package/ios/Sources/DVAIBridge/Discovery/NWBrowserDiscovery.swift +212 -0
  18. package/ios/Sources/DVAIBridge/Internal/BackendSelector.swift +59 -0
  19. package/ios/Sources/DVAIBridge/Internal/ProgressBroadcaster.swift +84 -0
  20. package/ios/Sources/DVAIBridge/License/Audience.swift +133 -0
  21. package/ios/Sources/DVAIBridge/License/Discovery.swift +164 -0
  22. package/ios/Sources/DVAIBridge/License/LicenseValidator.swift +392 -0
  23. package/ios/Sources/DVAIBridge/License/PublicKeys.swift +114 -0
  24. package/ios/Sources/DVAIBridge/License/Types.swift +195 -0
  25. package/ios/Sources/DVAIBridge/Offload/OffloadConfig.swift +118 -0
  26. package/ios/Sources/DVAIBridge/Offload/OffloadProxy.swift +604 -0
  27. package/ios/Sources/DVAIBridge/Offload/OffloadRuntime.swift +98 -0
  28. package/ios/Sources/DVAIBridge/Pairing/Pairing.swift +125 -0
  29. package/ios/Sources/DVAIBridge/Pairing/PairingHandshake.swift +141 -0
  30. package/ios/Sources/DVAIBridge/Pairing/PairingPolicy.swift +162 -0
  31. package/ios/Sources/DVAIBridge/Pairing/PairingStore.swift +65 -0
  32. package/ios/Sources/DVAIBridge/ProgressEvent.swift +34 -0
  33. package/ios/Sources/DVAIBridge/ReactiveState.swift +149 -0
  34. package/ios/Sources/DVAICoreMLCore/.gitkeep +0 -0
  35. package/ios/Sources/DVAICoreMLCore/CoreMLBackendError.swift +19 -0
  36. package/ios/Sources/DVAICoreMLCore/CoreMLHandlers.swift +123 -0
  37. package/ios/Sources/DVAICoreMLCore/CoreMLPluginState.swift +130 -0
  38. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLEngine.swift +137 -0
  39. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLGenerator.swift +108 -0
  40. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLSampler.swift +96 -0
  41. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLTokenizer.swift +69 -0
  42. package/ios/Tests/DVAIBridgeTests/BackendSelectorTests.swift +53 -0
  43. package/ios/Tests/DVAIBridgeTests/CapabilityPrecheckTests.swift +108 -0
  44. package/ios/Tests/DVAIBridgeTests/CoreMLEngineTests.swift +18 -0
  45. package/ios/Tests/DVAIBridgeTests/CoreMLGeneratorShapeTests.swift +11 -0
  46. package/ios/Tests/DVAIBridgeTests/CoreMLHandlersTests.swift +32 -0
  47. package/ios/Tests/DVAIBridgeTests/CoreMLPluginStateTests.swift +41 -0
  48. package/ios/Tests/DVAIBridgeTests/CoreMLSamplerTests.swift +40 -0
  49. package/ios/Tests/DVAIBridgeTests/CoreMLTokenizerTests.swift +19 -0
  50. package/ios/Tests/DVAIBridgeTests/DVAIBridgeAPIShapeTests.swift +37 -0
  51. package/ios/Tests/DVAIBridgeTests/DVAIBridgeConfigTests.swift +52 -0
  52. package/ios/Tests/DVAIBridgeTests/DVAIBridgeErrorTests.swift +33 -0
  53. package/ios/Tests/DVAIBridgeTests/LicenseValidatorTests.swift +658 -0
  54. package/ios/Tests/DVAIBridgeTests/OffloadProxyDecisionTests.swift +156 -0
  55. package/ios/Tests/DVAIBridgeTests/OffloadTests.swift +339 -0
  56. package/ios/Tests/DVAIBridgeTests/ProgressBroadcasterTests.swift +69 -0
  57. package/ios/Tests/DVAIBridgeTests/ProgressEventTests.swift +25 -0
  58. package/ios/Tests/DVAIBridgeTests/ReactiveStateTests.swift +45 -0
  59. package/ios/Tests/DVAIBridgeTests/RealModelIntegrationTest.swift +359 -0
  60. package/package.json +19 -0
@@ -0,0 +1,96 @@
1
+ import Foundation
2
+ import CoreML
3
+
4
+ /// Sampling strategies for autoregressive decoding.
5
+ ///
6
+ /// Note on `seed:` — `SystemRandomNumberGenerator` cannot be seeded; for
7
+ /// reproducible sampling a custom PRNG (e.g. Mulberry32) would be needed.
8
+ /// Per the plan, we drop `seed:` from the public-facing init entirely rather
9
+ /// than silently ignoring it. Apple-managed entropy is fine for production LLM
10
+ /// sampling.
11
+ internal struct CoreMLSampler {
12
+ let temperature: Float
13
+ let topP: Float
14
+ let topK: Int // 0 = disabled
15
+
16
+ /// Sample a token id from a logits vector.
17
+ /// - Parameter logits: 1-D MLMultiArray<Float32> of length vocab_size.
18
+ func sample(logits: MLMultiArray) -> Int {
19
+ let count = logits.count
20
+ let ptr = UnsafeMutablePointer<Float32>(OpaquePointer(logits.dataPointer))
21
+
22
+ // 1. Greedy fast-path
23
+ if temperature <= 0 {
24
+ return argmax(ptr, count: count)
25
+ }
26
+
27
+ // 2. Apply temperature
28
+ var scaled = [Float](repeating: 0, count: count)
29
+ for i in 0 ..< count { scaled[i] = ptr[i] / temperature }
30
+
31
+ // 3. Optional top-K filter
32
+ if topK > 0 && topK < count {
33
+ applyTopK(&scaled, k: topK)
34
+ }
35
+
36
+ // 4. Softmax → probabilities
37
+ let probs = softmax(scaled)
38
+
39
+ // 5. Optional nucleus (top-p) filter
40
+ let final = topP < 1.0 ? applyTopP(probs, p: topP) : probs
41
+
42
+ // 6. Categorical draw
43
+ return categoricalSample(final)
44
+ }
45
+
46
+ // MARK: - Helpers
47
+
48
+ private func argmax(_ ptr: UnsafeMutablePointer<Float32>, count: Int) -> Int {
49
+ var bestIdx = 0
50
+ var bestVal = ptr[0]
51
+ for i in 1 ..< count {
52
+ if ptr[i] > bestVal { bestVal = ptr[i]; bestIdx = i }
53
+ }
54
+ return bestIdx
55
+ }
56
+
57
+ private func softmax(_ logits: [Float]) -> [Float] {
58
+ let maxVal = logits.max() ?? 0
59
+ var exps = logits.map { Float(exp(Double($0 - maxVal))) }
60
+ let sum = exps.reduce(0, +)
61
+ if sum > 0 { for i in 0 ..< exps.count { exps[i] /= sum } }
62
+ return exps
63
+ }
64
+
65
+ private func applyTopK(_ logits: inout [Float], k: Int) {
66
+ let kth = logits.sorted(by: >).prefix(k).last ?? -.greatestFiniteMagnitude
67
+ for i in 0 ..< logits.count where logits[i] < kth { logits[i] = -.greatestFiniteMagnitude }
68
+ }
69
+
70
+ private func applyTopP(_ probs: [Float], p: Float) -> [Float] {
71
+ let sorted = probs.enumerated().sorted { $0.element > $1.element }
72
+ var cum: Float = 0
73
+ var keep = Set<Int>()
74
+ for (idx, prob) in sorted {
75
+ keep.insert(idx)
76
+ cum += prob
77
+ if cum >= p { break }
78
+ }
79
+ var result = probs
80
+ for i in 0 ..< result.count where !keep.contains(i) { result[i] = 0 }
81
+ let sum = result.reduce(0, +)
82
+ if sum > 0 { for i in 0 ..< result.count { result[i] /= sum } }
83
+ return result
84
+ }
85
+
86
+ private func categoricalSample(_ probs: [Float]) -> Int {
87
+ var rng = SystemRandomNumberGenerator()
88
+ let r = Float.random(in: 0 ..< 1, using: &rng)
89
+ var cum: Float = 0
90
+ for i in 0 ..< probs.count {
91
+ cum += probs[i]
92
+ if r < cum { return i }
93
+ }
94
+ return probs.count - 1
95
+ }
96
+ }
@@ -0,0 +1,69 @@
1
+ import Foundation
2
+ #if !COCOAPODS
3
+ import Tokenizers
4
+ #endif
5
+
6
+ /// Loads a HuggingFace-style tokenizer.json + tokenizer_config.json from a
7
+ /// local directory. Provides chat-template application, encode, and decode.
8
+ ///
9
+ /// swift-transformers 1.3.0 API notes:
10
+ /// - `AutoTokenizer.from(modelFolder:hubApi:strict:)` — hubApi and strict
11
+ /// have default values so the two-arg form `from(modelFolder:)` is NOT
12
+ /// available; must pass at minimum `modelFolder:`.
13
+ /// - `Message` is `typealias Message = [String: any Sendable]` so we convert
14
+ /// `[[String: String]]` → `[[String: any Sendable]]` before passing.
15
+ /// - `applyChatTemplate(messages:)` has all other params defaulted.
16
+ /// - `eosTokenId` is `Int?` (optional) — we fall back to 0 if absent.
17
+ internal struct CoreMLTokenizer: @unchecked Sendable {
18
+ private let inner: any Tokenizer
19
+
20
+ init(tokenizerDir: URL) async throws {
21
+ do {
22
+ // `from(modelFolder:)` resolves to `from(modelFolder:hubApi:strict:)`
23
+ // with default HubApi() and strict: true.
24
+ self.inner = try await AutoTokenizer.from(modelFolder: tokenizerDir)
25
+ } catch {
26
+ throw CoreMLBackendError.tokenizerLoadFailed(reason: "\(error)")
27
+ }
28
+ }
29
+
30
+ /// Apply the model's chat template to convert messages into token IDs.
31
+ /// - Parameter messages: Array of {"role": ..., "content": ...} dicts.
32
+ /// - Parameter addGenerationPrompt: Append the generation-start marker.
33
+ func applyChatTemplate(
34
+ messages: [[String: String]],
35
+ addGenerationPrompt: Bool = true
36
+ ) throws -> [Int] {
37
+ // Convert [[String: String]] → [[String: any Sendable]] (Tokenizers.Message)
38
+ let normalized: [Message] = messages.map { dict in
39
+ var m: Message = [:]
40
+ for (k, v) in dict { m[k] = v }
41
+ return m
42
+ }
43
+ do {
44
+ // swift-transformers 1.x's applyChatTemplate signature drops the
45
+ // addGenerationPrompt parameter (defaulted to true server-side).
46
+ // The `addGenerationPrompt` knob in our wrapper is preserved for
47
+ // future API symmetry but currently passed through implicitly.
48
+ _ = addGenerationPrompt
49
+ return try inner.applyChatTemplate(messages: normalized)
50
+ } catch {
51
+ throw CoreMLBackendError.generationFailed(reason: "applyChatTemplate failed: \(error)")
52
+ }
53
+ }
54
+
55
+ func encode(text: String) -> [Int] {
56
+ inner.encode(text: text)
57
+ }
58
+
59
+ func decode(tokens: [Int]) -> String {
60
+ inner.decode(tokens: tokens, skipSpecialTokens: true)
61
+ }
62
+
63
+ func decode(token: Int) -> String {
64
+ decode(tokens: [token])
65
+ }
66
+
67
+ /// EOS token id. Falls back to 0 if the tokenizer config doesn't specify one.
68
+ var eosTokenId: Int { inner.eosTokenId ?? 0 }
69
+ }
@@ -0,0 +1,53 @@
1
+ import XCTest
2
+ @testable import DVAIBridge
3
+
4
+ final class BackendSelectorTests: XCTestCase {
5
+ func testExplicitChoicePassesThrough() throws {
6
+ for kind in [BackendKind.llama, .foundation, .coreml] {
7
+ let resolved = try BackendSelector.resolve(kind, config: DVAIBridgeConfig())
8
+ XCTAssertEqual(resolved, kind)
9
+ }
10
+ }
11
+
12
+ func testAutoWithGGUFResolvesToLlama() throws {
13
+ let cfg = DVAIBridgeConfig(modelPath: "/path/to/model.gguf")
14
+ XCTAssertEqual(try BackendSelector.resolve(.auto, config: cfg), .llama)
15
+ }
16
+
17
+ func testAutoWithMlmodelcResolvesToCoreML() throws {
18
+ let cfg = DVAIBridgeConfig(modelPath: "/path/to/model.mlmodelc")
19
+ XCTAssertEqual(try BackendSelector.resolve(.auto, config: cfg), .coreml)
20
+ }
21
+
22
+ func testAutoWithMlpackageResolvesToCoreML() throws {
23
+ let cfg = DVAIBridgeConfig(modelPath: "/path/to/model.mlpackage")
24
+ XCTAssertEqual(try BackendSelector.resolve(.auto, config: cfg), .coreml)
25
+ }
26
+
27
+ func testAutoWithTaskFileThrows() {
28
+ let cfg = DVAIBridgeConfig(modelPath: "/path/to/model.task")
29
+ XCTAssertThrowsError(try BackendSelector.resolve(.auto, config: cfg)) { err in
30
+ guard case let DVAIBridgeError.configurationInvalid(reason) = err else {
31
+ return XCTFail("wrong error type")
32
+ }
33
+ XCTAssertTrue(reason.contains("Android"))
34
+ }
35
+ }
36
+
37
+ func testAutoWithUnknownExtensionThrows() {
38
+ let cfg = DVAIBridgeConfig(modelPath: "/path/to/something.unknown")
39
+ XCTAssertThrowsError(try BackendSelector.resolve(.auto, config: cfg))
40
+ }
41
+
42
+ func testAutoWithNoModelPathOnIOS26ResolvesToFoundation() throws {
43
+ // This test only meaningfully runs on iOS 26+. On older simulators
44
+ // the no-modelPath branch throws. Both outcomes are well-defined;
45
+ // assert the right one based on availability.
46
+ let cfg = DVAIBridgeConfig(modelPath: nil)
47
+ if #available(iOS 26.0, macOS 26.0, *) {
48
+ XCTAssertEqual(try BackendSelector.resolve(.auto, config: cfg), .foundation)
49
+ } else {
50
+ XCTAssertThrowsError(try BackendSelector.resolve(.auto, config: cfg))
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,108 @@
1
+ import XCTest
2
+ @testable import DVAIBridge
3
+
4
+ /// v3.2 — pre-init capability gate (Swift parallel to Android's
5
+ /// CapabilityPrecheckTest.kt and TS precheck.test.ts). Same hint
6
+ /// shapes, same expected modes — guarantees that iOS, Android, and
7
+ /// the TS core agree on what's a "too-weak" or "offload-only" device
8
+ /// for any given hardware profile.
9
+ @available(iOS 14.0, macOS 11.0, *)
10
+ final class CapabilityPrecheckTests: XCTestCase {
11
+
12
+ private let highEndDesktop = DeviceCapabilityHints(
13
+ hasNpu: false, ramGb: 32, gpuClass: .discrete, cpuClass: .high
14
+ )
15
+ private let appleSiliconLaptop = DeviceCapabilityHints(
16
+ hasNpu: true, ramGb: 16, gpuClass: .appleSilicon, cpuClass: .high
17
+ )
18
+ private let midRangeLaptop = DeviceCapabilityHints(
19
+ hasNpu: false, ramGb: 8, gpuClass: .integrated, cpuClass: .mid
20
+ )
21
+ private let lowEndLaptop = DeviceCapabilityHints(
22
+ hasNpu: false, ramGb: 4, gpuClass: .integrated, cpuClass: .low
23
+ )
24
+ private let veryWeakDevice = DeviceCapabilityHints(
25
+ hasNpu: false, ramGb: 2, gpuClass: .none, cpuClass: .low
26
+ )
27
+
28
+ func testHighEndDesktopClassifiesAsOk() {
29
+ let result = CapabilityPrecheck.assess(hints: highEndDesktop)
30
+ XCTAssertEqual(result.mode, .ok)
31
+ XCTAssertGreaterThan(result.tokPerSec, 10.0)
32
+ }
33
+
34
+ func testAppleSiliconClassifiesAsOk() {
35
+ let result = CapabilityPrecheck.assess(hints: appleSiliconLaptop)
36
+ XCTAssertEqual(result.mode, .ok)
37
+ }
38
+
39
+ func testMidRangeLaptopClassifiesAsOffloadOnly() {
40
+ // 8 (integrated) * 1.0 (mid CPU) * 1.0 (8 GB RAM) * 1.0 (no NPU) = 8 tok/s
41
+ // Above hardwareMinimum (3), below minLocalCapability (10) → offload-only.
42
+ let result = CapabilityPrecheck.assess(hints: midRangeLaptop)
43
+ XCTAssertEqual(result.mode, .offloadOnly)
44
+ XCTAssertEqual(result.tokPerSec, 8.0, accuracy: 0.01)
45
+ }
46
+
47
+ func testLowEndLaptopClassifiesAsOffloadOnly() {
48
+ // 8 * 0.6 * 0.7 = 3.4 tok/s → above floor (3), below comfort (10).
49
+ let result = CapabilityPrecheck.assess(hints: lowEndLaptop)
50
+ XCTAssertEqual(result.mode, .offloadOnly)
51
+ }
52
+
53
+ func testVeryWeakDeviceClassifiesAsTooWeak() {
54
+ // 3 (no GPU) * 0.6 (low CPU) * 0.3 (RAM < 4) = 0.5 tok/s → too-weak.
55
+ let result = CapabilityPrecheck.assess(hints: veryWeakDevice)
56
+ XCTAssertEqual(result.mode, .tooWeak)
57
+ XCTAssertLessThan(result.tokPerSec, 3.0)
58
+ }
59
+
60
+ func testCustomHardwareMinimumIsHonored() {
61
+ // Mid-range gets 8 tok/s. Raise the floor above that → too-weak.
62
+ let result = CapabilityPrecheck.assess(
63
+ thresholds: CapabilityPrecheck.Thresholds(hardwareMinimum: 12.0),
64
+ hints: midRangeLaptop
65
+ )
66
+ XCTAssertEqual(result.mode, .tooWeak)
67
+ }
68
+
69
+ func testCustomMinLocalCapabilityIsHonored() {
70
+ // Mid-range gets 8 tok/s. Lower the comfort threshold to 5 → ok.
71
+ let result = CapabilityPrecheck.assess(
72
+ thresholds: CapabilityPrecheck.Thresholds(minLocalCapability: 5.0),
73
+ hints: midRangeLaptop
74
+ )
75
+ XCTAssertEqual(result.mode, .ok)
76
+ }
77
+
78
+ func testReasonContainsTokPerSec() {
79
+ let result = CapabilityPrecheck.assess(hints: veryWeakDevice)
80
+ XCTAssertTrue(
81
+ result.reason.contains("tok/s"),
82
+ "reason should mention tok/s — got: \(result.reason)"
83
+ )
84
+ }
85
+
86
+ func testHardwareAssessmentIsCodableRoundTrip() throws {
87
+ let result = CapabilityPrecheck.assess(hints: veryWeakDevice)
88
+ let assessment = HardwareAssessment(from: result)
89
+ let encoded = try JSONEncoder().encode(assessment)
90
+ let decoded = try JSONDecoder().decode(HardwareAssessment.self, from: encoded)
91
+ XCTAssertEqual(decoded.mode, .tooWeak)
92
+ XCTAssertLessThan(decoded.tokPerSec, 3.0)
93
+ XCTAssertEqual(decoded.hints, veryWeakDevice)
94
+ }
95
+
96
+ /// Wire-format strings should be stable across SDK versions for
97
+ /// cross-platform parity; rendering a JSON with this exact shape
98
+ /// is the contract every other SDK reads / writes.
99
+ func testWireFormatMatchesCrossPlatform() throws {
100
+ let result = CapabilityPrecheck.assess(hints: midRangeLaptop)
101
+ let assessment = HardwareAssessment(from: result)
102
+ let encoded = try JSONEncoder().encode(assessment)
103
+ let json = String(decoding: encoded, as: UTF8.self)
104
+ XCTAssertTrue(json.contains("\"offload-only\""), "expected kebab-case mode in JSON: \(json)")
105
+ XCTAssertTrue(json.contains("\"integrated\""), "expected kebab-case gpuClass in JSON: \(json)")
106
+ XCTAssertTrue(json.contains("\"mid\""), "expected lower-case cpuClass in JSON: \(json)")
107
+ }
108
+ }
@@ -0,0 +1,18 @@
1
+ import XCTest
2
+ @testable import DVAICoreMLCore
3
+
4
+ @available(iOS 18.0, macOS 15.0, *)
5
+ final class CoreMLEngineTests: XCTestCase {
6
+ func testLoadFailsForMissingFile() {
7
+ let bogusURL = URL(fileURLWithPath: "/tmp/definitely-does-not-exist.mlmodelc")
8
+ XCTAssertThrowsError(try CoreMLEngine(modelURL: bogusURL, eosTokenId: 0)) { err in
9
+ guard case let CoreMLBackendError.modelLoadFailed(reason) = err else {
10
+ return XCTFail("wrong error: \(err)")
11
+ }
12
+ XCTAssertTrue(
13
+ reason.contains("error") || reason.contains("Error") || reason.contains("file"),
14
+ "reason should mention error details, got: \(reason)"
15
+ )
16
+ }
17
+ }
18
+ }
@@ -0,0 +1,11 @@
1
+ import XCTest
2
+ @testable import DVAICoreMLCore
3
+
4
+ @available(iOS 18.0, macOS 15.0, *)
5
+ final class CoreMLGeneratorShapeTests: XCTestCase {
6
+ func testTypesCompile() {
7
+ // Ensures the public-internal API compiles correctly at the type level.
8
+ // Real generation is tested end-to-end in RealModelIntegrationTest (Task 18).
9
+ let _: AsyncThrowingStream<String, Error>.Type = AsyncThrowingStream<String, Error>.self
10
+ }
11
+ }
@@ -0,0 +1,32 @@
1
+ import XCTest
2
+ import DVAISharedCore
3
+ @testable import DVAICoreMLCore
4
+
5
+ @available(iOS 18.0, macOS 15.0, *)
6
+ final class CoreMLHandlersTests: XCTestCase {
7
+ func testHandleEmbeddingsReturns501() async throws {
8
+ // The embeddings endpoint short-circuits without invoking the generator.
9
+ // We verify the response shape without needing a real MLModel.
10
+ let response: HandlerResponse = .error(501, "embeddings not yet supported by the CoreML backend")
11
+ if case let .error(status, msg) = response {
12
+ XCTAssertEqual(status, 501)
13
+ XCTAssertTrue(msg.contains("embeddings"), "expected 'embeddings' in '\(msg)'")
14
+ } else {
15
+ XCTFail("expected error response")
16
+ }
17
+ }
18
+
19
+ func testHandleModelsReturnsConfiguredModel() async throws {
20
+ let response: HandlerResponse = .json(200, [
21
+ "object": "list",
22
+ "data": [["id": "test-model", "object": "model", "owned_by": "dvai-bridge"]]
23
+ ])
24
+ if case let .json(status, body) = response {
25
+ XCTAssertEqual(status, 200)
26
+ let dict = body as? [String: Any]
27
+ XCTAssertEqual(dict?["object"] as? String, "list")
28
+ } else {
29
+ XCTFail("expected json response")
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,41 @@
1
+ import XCTest
2
+ @testable import DVAICoreMLCore
3
+
4
+ @available(iOS 18.0, macOS 15.0, *)
5
+ final class CoreMLPluginStateTests: XCTestCase {
6
+ func testStartFailsWithoutModelPath() async {
7
+ let state = CoreMLPluginState()
8
+ do {
9
+ _ = try await state.start(opts: [:])
10
+ XCTFail("Expected throw")
11
+ } catch let err as CoreMLBackendError {
12
+ guard case .modelLoadFailed = err else { return XCTFail("wrong error: \(err)") }
13
+ // Pass — empty opts correctly throws modelLoadFailed
14
+ } catch {
15
+ XCTFail("wrong error type: \(error)")
16
+ }
17
+ }
18
+
19
+ func testStartFailsWithoutTokenizerPath() async {
20
+ let state = CoreMLPluginState()
21
+ do {
22
+ _ = try await state.start(opts: ["modelPath": "/tmp/x.mlmodelc"])
23
+ XCTFail("Expected throw")
24
+ } catch let err as CoreMLBackendError {
25
+ guard case .tokenizerLoadFailed = err else { return XCTFail("wrong error: \(err)") }
26
+ // Pass — missing tokenizerPath correctly throws tokenizerLoadFailed
27
+ } catch {
28
+ XCTFail("wrong error type: \(error)")
29
+ }
30
+ }
31
+
32
+ func testStopWhenNotStartedIsIdempotent() async throws {
33
+ try await CoreMLPluginState().stop()
34
+ // Doesn't throw — idempotent stop is required by the API contract
35
+ }
36
+
37
+ func testStatusInfoReportsNotRunning() async {
38
+ let info = await CoreMLPluginState().statusInfo()
39
+ XCTAssertEqual(info["running"] as? Bool, false)
40
+ }
41
+ }
@@ -0,0 +1,40 @@
1
+ import XCTest
2
+ import CoreML
3
+ @testable import DVAICoreMLCore
4
+
5
+ final class CoreMLSamplerTests: XCTestCase {
6
+ func makeLogits(_ vals: [Float]) -> MLMultiArray {
7
+ let arr = try! MLMultiArray(shape: [NSNumber(value: vals.count)], dataType: .float32)
8
+ for (i, v) in vals.enumerated() { arr[i] = NSNumber(value: v) }
9
+ return arr
10
+ }
11
+
12
+ func testGreedyReturnsArgmax() {
13
+ let s = CoreMLSampler(temperature: 0, topP: 1.0, topK: 0)
14
+ let logits = makeLogits([1.0, 5.0, 2.0, 4.0])
15
+ XCTAssertEqual(s.sample(logits: logits), 1) // argmax index
16
+ }
17
+
18
+ func testTemperatureSamplingNeverThrows() {
19
+ let s = CoreMLSampler(temperature: 1.0, topP: 1.0, topK: 0)
20
+ let logits = makeLogits([1.0, 2.0, 3.0, 4.0])
21
+ for _ in 0 ..< 100 {
22
+ let token = s.sample(logits: logits)
23
+ XCTAssertGreaterThanOrEqual(token, 0)
24
+ XCTAssertLessThan(token, 4)
25
+ }
26
+ }
27
+
28
+ func testTopPTruncationFavorsHighProb() {
29
+ // With top_p = 0.5 and a sharply skewed distribution, only the top
30
+ // few tokens should ever be selected.
31
+ let s = CoreMLSampler(temperature: 1.0, topP: 0.5, topK: 0)
32
+ // Logits chosen so that softmax(logits) ≈ [0.0, 0.0, 0.05, 0.95]
33
+ let logits = makeLogits([-100.0, -100.0, 1.0, 4.0])
34
+ var counts = [0, 0, 0, 0]
35
+ for _ in 0 ..< 1000 { counts[s.sample(logits: logits)] += 1 }
36
+ XCTAssertEqual(counts[0], 0)
37
+ XCTAssertEqual(counts[1], 0)
38
+ XCTAssertGreaterThan(counts[3], counts[2]) // 4.0 picked far more often than 1.0
39
+ }
40
+ }
@@ -0,0 +1,19 @@
1
+ import XCTest
2
+ @testable import DVAICoreMLCore
3
+
4
+ final class CoreMLTokenizerTests: XCTestCase {
5
+ func testInitFailsForMissingDir() async {
6
+ let bogus = URL(fileURLWithPath: "/tmp/no-such-tokenizer-dir-xyz")
7
+ do {
8
+ _ = try await CoreMLTokenizer(tokenizerDir: bogus)
9
+ XCTFail("Expected throw for missing tokenizer dir")
10
+ } catch let err as CoreMLBackendError {
11
+ guard case .tokenizerLoadFailed = err else {
12
+ return XCTFail("wrong error type: \(err)")
13
+ }
14
+ // Pass — missing dir correctly converts to tokenizerLoadFailed
15
+ } catch {
16
+ XCTFail("wrong error type: \(error)")
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,37 @@
1
+ import XCTest
2
+ @testable import DVAIBridge
3
+
4
+ final class DVAIBridgeAPIShapeTests: XCTestCase {
5
+ func testSingletonExists() {
6
+ let bridge: DVAIBridge = DVAIBridge.shared
7
+ XCTAssertNotNil(bridge)
8
+ }
9
+
10
+ func testStatusBeforeStartReportsNotRunning() async {
11
+ let bridge = DVAIBridge() // fresh instance for test isolation
12
+ let info = await bridge.status()
13
+ XCTAssertFalse(info.running)
14
+ XCTAssertNil(info.backend)
15
+ XCTAssertNil(info.baseUrl)
16
+ }
17
+
18
+ func testStopWhenNotStartedIsIdempotent() async throws {
19
+ let bridge = DVAIBridge()
20
+ try await bridge.stop()
21
+ try await bridge.stop() // no throw
22
+ }
23
+
24
+ func testStartCoreMLThrowsBackendUnavailable() async {
25
+ let bridge = DVAIBridge()
26
+ do {
27
+ _ = try await bridge.start(.init(backend: .coreml))
28
+ XCTFail("Expected throw")
29
+ } catch let err as DVAIBridgeError {
30
+ if case .backendUnavailable(.coreml, _) = err { /* expected */ } else {
31
+ XCTFail("wrong error: \(err)")
32
+ }
33
+ } catch {
34
+ XCTFail("wrong error type: \(error)")
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,52 @@
1
+ import XCTest
2
+ @testable import DVAIBridge
3
+
4
+ final class DVAIBridgeConfigTests: XCTestCase {
5
+ func testDefaultsMatchSpec() {
6
+ let c = DVAIBridgeConfig()
7
+ XCTAssertEqual(c.backend, .auto)
8
+ XCTAssertNil(c.modelPath)
9
+ XCTAssertEqual(c.gpuLayers, 99)
10
+ XCTAssertEqual(c.contextSize, 2048)
11
+ XCTAssertEqual(c.threads, 4)
12
+ XCTAssertFalse(c.embeddingMode)
13
+ XCTAssertEqual(c.httpBasePort, 38883)
14
+ XCTAssertEqual(c.httpMaxPortAttempts, 16)
15
+ XCTAssertFalse(c.autoUnloadOnLowMemory)
16
+ XCTAssertEqual(c.logLevel, "info")
17
+ }
18
+
19
+ func testToCoreOptsWildcardCors() {
20
+ let c = DVAIBridgeConfig(modelPath: "/x.gguf")
21
+ let opts = c.toCoreOpts()
22
+ XCTAssertEqual(opts["modelPath"] as? String, "/x.gguf")
23
+ XCTAssertEqual(opts["corsOrigin"] as? String, "*")
24
+ }
25
+
26
+ func testToCoreOptsExactCors() {
27
+ let c = DVAIBridgeConfig(corsOrigin: .exact("https://example.com"))
28
+ XCTAssertEqual(c.toCoreOpts()["corsOrigin"] as? String, "https://example.com")
29
+ }
30
+
31
+ func testToCoreOptsAllowlistCors() {
32
+ let c = DVAIBridgeConfig(corsOrigin: .allowlist(["https://a.com", "https://b.com"]))
33
+ XCTAssertEqual(c.toCoreOpts()["corsOrigin"] as? [String], ["https://a.com", "https://b.com"])
34
+ }
35
+
36
+ func testBoundServerInitFromCoreResult() throws {
37
+ let result: [String: Any] = [
38
+ "baseUrl": "http://127.0.0.1:38883/v1",
39
+ "port": 38883,
40
+ "modelId": "test-model"
41
+ ]
42
+ let server = try BoundServer(coreResult: result, backend: .llama)
43
+ XCTAssertEqual(server.baseUrl, "http://127.0.0.1:38883/v1")
44
+ XCTAssertEqual(server.port, 38883)
45
+ XCTAssertEqual(server.backend, .llama)
46
+ XCTAssertEqual(server.modelId, "test-model")
47
+ }
48
+
49
+ func testBoundServerInitMalformedResult() {
50
+ XCTAssertThrowsError(try BoundServer(coreResult: ["baseUrl": "x"], backend: .llama))
51
+ }
52
+ }
@@ -0,0 +1,33 @@
1
+ import XCTest
2
+ @testable import DVAIBridge
3
+
4
+ final class DVAIBridgeErrorTests: XCTestCase {
5
+ func testErrorDescriptionsAreUserFacing() {
6
+ let cases: [(DVAIBridgeError, String)] = [
7
+ (.notStarted, "has not been started"),
8
+ (.alreadyStarted(currentBackend: .llama, baseUrl: "http://127.0.0.1:38883/v1"), "already running"),
9
+ (.configurationInvalid(reason: "x"), "invalid"),
10
+ (.backendUnavailable(.foundation, reason: "iOS 26+ required"), "unavailable"),
11
+ (.modelLoadFailed(reason: "x"), "Model load failed"),
12
+ (.downloadFailed(reason: "x"), "Download failed"),
13
+ (.checksumMismatch, "SHA-256"),
14
+ (.backendError(underlying: "x"), "Backend error"),
15
+ ]
16
+ for (err, expectedFragment) in cases {
17
+ XCTAssertNotNil(err.errorDescription, "error has no description: \(err)")
18
+ XCTAssertTrue(
19
+ err.errorDescription!.contains(expectedFragment),
20
+ "expected '\(expectedFragment)' in '\(err.errorDescription!)'"
21
+ )
22
+ }
23
+ }
24
+
25
+ func testBackendKindAllCases() {
26
+ XCTAssertEqual(BackendKind.allCases.count, 5)
27
+ XCTAssertTrue(BackendKind.allCases.contains(.auto))
28
+ XCTAssertTrue(BackendKind.allCases.contains(.llama))
29
+ XCTAssertTrue(BackendKind.allCases.contains(.foundation))
30
+ XCTAssertTrue(BackendKind.allCases.contains(.coreml))
31
+ XCTAssertTrue(BackendKind.allCases.contains(.mlx))
32
+ }
33
+ }