Skip to content

Commit 39049f6

Browse files
committed
feat(ios-sdk): add full LoRA adapter support with registry and compat check
1 parent 2640d0a commit 39049f6

5 files changed

Lines changed: 268 additions & 20 deletions

File tree

sdk/runanywhere-commons/exports/RACommons.exports

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,25 @@ _rac_llm_component_load_model
202202
_rac_llm_component_supports_streaming
203203
_rac_llm_component_unload
204204

205+
# LLM Component - LoRA
206+
_rac_llm_component_load_lora
207+
_rac_llm_component_remove_lora
208+
_rac_llm_component_clear_lora
209+
_rac_llm_component_get_lora_info
210+
_rac_llm_component_check_lora_compat
211+
212+
# LoRA Registry
213+
_rac_lora_registry_create
214+
_rac_lora_registry_destroy
215+
_rac_lora_registry_register
216+
_rac_lora_registry_remove
217+
_rac_lora_registry_get_all
218+
_rac_lora_registry_get_for_model
219+
_rac_lora_registry_get
220+
_rac_lora_entry_free
221+
_rac_lora_entry_array_free
222+
_rac_lora_entry_copy
223+
205224
# LLM Analytics
206225
_rac_llm_analytics_complete_generation
207226
_rac_llm_analytics_create

sdk/runanywhere-swift/Sources/RunAnywhere/Foundation/Bridge/Extensions/CppBridge+LLM.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,23 @@ extension CppBridge {
128128
logger.info("All LoRA adapters cleared")
129129
}
130130

131+
/// Check if a LoRA adapter is compatible with the currently loaded model
132+
public func checkLoraCompatibility(loraPath: String) -> LoraCompatibilityResult {
133+
guard let handle = handle else {
134+
return LoraCompatibilityResult(isCompatible: false, error: "No LLM component active")
135+
}
136+
var errorPtr: UnsafeMutablePointer<CChar>?
137+
let result = loraPath.withCString { pathPtr in
138+
rac_llm_component_check_lora_compat(handle, pathPtr, &errorPtr)
139+
}
140+
if result == RAC_SUCCESS {
141+
return LoraCompatibilityResult(isCompatible: true)
142+
}
143+
let errorMsg = errorPtr.map { String(cString: $0) }
144+
if let ptr = errorPtr { rac_free(ptr) }
145+
return LoraCompatibilityResult(isCompatible: false, error: errorMsg)
146+
}
147+
131148
/// Get info about all loaded LoRA adapters
132149
public func getLoadedLoraAdapters() throws -> [LoRAAdapterInfo] {
133150
guard let handle = handle else { return [] }
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// CppBridge+LoraRegistry.swift
2+
// RunAnywhere SDK
3+
//
4+
// LoRA registry bridge - wraps C++ rac_lora_registry_* for adapter catalog management.
5+
6+
import CRACommons
7+
import Foundation
8+
9+
extension CppBridge {
10+
11+
// MARK: - LoRA Registry Bridge
12+
13+
/// Actor wrapping the C++ LoRA adapter registry.
14+
/// Holds an in-memory catalog of adapters registered at startup.
15+
public actor LoraRegistry {
16+
17+
/// Shared registry instance
18+
public static let shared = LoraRegistry()
19+
20+
private var handle: rac_lora_registry_handle_t?
21+
private let logger = SDKLogger(category: "CppBridge.LoraRegistry")
22+
23+
private init() {
24+
var h: rac_lora_registry_handle_t?
25+
let result = rac_lora_registry_create(&h)
26+
if result == RAC_SUCCESS {
27+
handle = h
28+
logger.debug("LoRA registry created")
29+
} else {
30+
logger.error("Failed to create LoRA registry: \(result)")
31+
}
32+
}
33+
34+
deinit {
35+
if let h = handle {
36+
rac_lora_registry_destroy(h)
37+
}
38+
}
39+
40+
// MARK: - Registration
41+
42+
/// Register a LoRA adapter in the catalog
43+
public func register(_ entry: LoraAdapterCatalogEntry) throws {
44+
guard let handle = handle else {
45+
throw SDKError.general(.initializationFailed, "LoRA registry not initialized")
46+
}
47+
48+
// Allocate C strings via strdup so lifetime is independent of Swift strings
49+
let cId = strdup(entry.id)
50+
let cName = strdup(entry.name)
51+
let cDesc = strdup(entry.adapterDescription)
52+
let cUrl = strdup(entry.downloadURL.absoluteString)
53+
let cFile = strdup(entry.filename)
54+
let cCompatIds = entry.compatibleModelIds.map { strdup($0) }
55+
defer {
56+
[cId, cName, cDesc, cUrl, cFile].forEach { if let p = $0 { free(p) } }
57+
cCompatIds.forEach { if let p = $0 { free(p) } }
58+
}
59+
60+
var mutableCompatIds = cCompatIds
61+
let result: rac_result_t = mutableCompatIds.withUnsafeMutableBufferPointer { compatBuf in
62+
var cEntry = rac_lora_entry_t()
63+
cEntry.id = cId
64+
cEntry.name = cName
65+
cEntry.description = cDesc
66+
cEntry.download_url = cUrl
67+
cEntry.filename = cFile
68+
cEntry.compatible_model_ids = compatBuf.baseAddress
69+
cEntry.compatible_model_count = entry.compatibleModelIds.count
70+
cEntry.file_size = entry.fileSize
71+
cEntry.default_scale = entry.defaultScale
72+
return rac_lora_registry_register(handle, &cEntry)
73+
}
74+
75+
guard result == RAC_SUCCESS else {
76+
throw SDKError.general(.processingFailed, "Failed to register LoRA adapter '\(entry.id)': \(result)")
77+
}
78+
logger.info("LoRA adapter registered: \(entry.id)")
79+
}
80+
81+
// MARK: - Queries
82+
83+
/// Get all registered LoRA adapters
84+
public func getAll() -> [LoraAdapterCatalogEntry] {
85+
guard let handle = handle else { return [] }
86+
87+
var entriesPtr: UnsafeMutablePointer<UnsafeMutablePointer<rac_lora_entry_t>?>?
88+
var count: Int = 0
89+
let result = rac_lora_registry_get_all(handle, &entriesPtr, &count)
90+
guard result == RAC_SUCCESS, let entries = entriesPtr else { return [] }
91+
defer { rac_lora_entry_array_free(entries, count) }
92+
93+
return (0..<count).compactMap { i in
94+
guard let entry = entries[i] else { return nil }
95+
return LoraAdapterCatalogEntry(from: entry.pointee)
96+
}
97+
}
98+
99+
/// Get LoRA adapters compatible with a specific model
100+
public func getForModel(_ modelId: String) -> [LoraAdapterCatalogEntry] {
101+
guard let handle = handle else { return [] }
102+
103+
var entriesPtr: UnsafeMutablePointer<UnsafeMutablePointer<rac_lora_entry_t>?>?
104+
var count: Int = 0
105+
let result = modelId.withCString { mid in
106+
rac_lora_registry_get_for_model(handle, mid, &entriesPtr, &count)
107+
}
108+
guard result == RAC_SUCCESS, let entries = entriesPtr else { return [] }
109+
defer { rac_lora_entry_array_free(entries, count) }
110+
111+
return (0..<count).compactMap { i in
112+
guard let entry = entries[i] else { return nil }
113+
return LoraAdapterCatalogEntry(from: entry.pointee)
114+
}
115+
}
116+
}
117+
}

sdk/runanywhere-swift/Sources/RunAnywhere/Public/Extensions/LLM/LLMTypes.swift

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,6 @@ public struct ThinkingTagPattern: Codable, Sendable {
405405
// MARK: - LoRA Adapter Types
406406

407407
/// Configuration for loading a LoRA adapter.
408-
/// Mirrors the C++ LoraAdapterEntry and Kotlin LoRAAdapterConfig.
409408
public struct LoRAAdapterConfig: Sendable {
410409

411410
/// Path to the LoRA adapter GGUF file
@@ -422,7 +421,6 @@ public struct LoRAAdapterConfig: Sendable {
422421
}
423422

424423
/// Info about a loaded LoRA adapter (read-only).
425-
/// Mirrors the C++ LoRA info JSON structure.
426424
public struct LoRAAdapterInfo: Sendable {
427425

428426
/// Path used when loading the adapter
@@ -435,6 +433,88 @@ public struct LoRAAdapterInfo: Sendable {
435433
public let applied: Bool
436434
}
437435

436+
/// Catalog entry for a LoRA adapter registered with the SDK.
437+
/// Register adapters at app startup via RunAnywhere.registerLoraAdapter(_:).
438+
public struct LoraAdapterCatalogEntry: Sendable {
439+
440+
/// Unique adapter identifier
441+
public let id: String
442+
443+
/// Human-readable display name
444+
public let name: String
445+
446+
/// Short description of what this adapter does
447+
public let adapterDescription: String
448+
449+
/// Direct download URL for the GGUF file
450+
public let downloadURL: URL
451+
452+
/// Filename to save as on disk
453+
public let filename: String
454+
455+
/// Model IDs this adapter is compatible with
456+
public let compatibleModelIds: [String]
457+
458+
/// File size in bytes (0 if unknown)
459+
public let fileSize: Int64
460+
461+
/// Recommended LoRA scale (e.g. 0.3 for F16 adapters on quantized bases)
462+
public let defaultScale: Float
463+
464+
public init(
465+
id: String,
466+
name: String,
467+
description: String,
468+
downloadURL: URL,
469+
filename: String,
470+
compatibleModelIds: [String],
471+
fileSize: Int64 = 0,
472+
defaultScale: Float = 1.0
473+
) {
474+
self.id = id
475+
self.name = name
476+
self.adapterDescription = description
477+
self.downloadURL = downloadURL
478+
self.filename = filename
479+
self.compatibleModelIds = compatibleModelIds
480+
self.fileSize = fileSize
481+
self.defaultScale = defaultScale
482+
}
483+
484+
// Internal init from C struct
485+
init(from cEntry: rac_lora_entry_t) {
486+
self.id = String(cString: cEntry.id)
487+
self.name = String(cString: cEntry.name)
488+
self.adapterDescription = String(cString: cEntry.description)
489+
self.downloadURL = URL(string: String(cString: cEntry.download_url)) ?? URL(fileURLWithPath: "")
490+
self.filename = String(cString: cEntry.filename)
491+
var modelIds: [String] = []
492+
if let ids = cEntry.compatible_model_ids {
493+
for i in 0..<cEntry.compatible_model_count {
494+
if let ptr = ids[i] { modelIds.append(String(cString: ptr)) }
495+
}
496+
}
497+
self.compatibleModelIds = modelIds
498+
self.fileSize = cEntry.file_size
499+
self.defaultScale = cEntry.default_scale
500+
}
501+
}
502+
503+
/// Result of a LoRA compatibility pre-check.
504+
public struct LoraCompatibilityResult: Sendable {
505+
506+
/// Whether the adapter is compatible with the currently loaded model
507+
public let isCompatible: Bool
508+
509+
/// Error message if not compatible
510+
public let error: String?
511+
512+
public init(isCompatible: Bool, error: String? = nil) {
513+
self.isCompatible = isCompatible
514+
self.error = error
515+
}
516+
}
517+
438518
// MARK: - Structured Output Types
439519

440520
/// Protocol for types that can be generated as structured output from LLMs
Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,19 @@
1+
// RunAnywhere+LoRA.swift
2+
// RunAnywhere SDK
13
//
2-
// RunAnywhere+LoRA.swift
3-
// RunAnywhere SDK
4-
//
5-
// Public API for LoRA adapter management.
6-
// Delegates to C++ via CppBridge.LLM for all operations.
7-
//
4+
// Public API for LoRA adapter management.
5+
// Runtime operations delegate to CppBridge.LLM; catalog operations delegate to CppBridge.LoraRegistry.
86

97
import Foundation
108

119
// MARK: - LoRA Adapter Management
1210

1311
public extension RunAnywhere {
1412

13+
// MARK: Runtime Operations
14+
1515
/// Load and apply a LoRA adapter to the currently loaded model.
16-
///
17-
/// The adapter is loaded from a GGUF file and applied with the given scale.
1816
/// Multiple adapters can be stacked. Context is recreated internally.
19-
///
20-
/// - Parameter config: LoRA adapter configuration (path and scale)
21-
/// - Throws: `SDKError` if no model is loaded or loading fails
2217
static func loadLoraAdapter(_ config: LoRAAdapterConfig) async throws {
2318
guard isInitialized else {
2419
throw SDKError.general(.notInitialized, "SDK not initialized")
@@ -27,9 +22,6 @@ public extension RunAnywhere {
2722
}
2823

2924
/// Remove a specific LoRA adapter by path.
30-
///
31-
/// - Parameter path: Path that was used when loading the adapter
32-
/// - Throws: `SDKError` if adapter not found or removal fails
3325
static func removeLoraAdapter(_ path: String) async throws {
3426
guard isInitialized else {
3527
throw SDKError.general(.notInitialized, "SDK not initialized")
@@ -38,8 +30,6 @@ public extension RunAnywhere {
3830
}
3931

4032
/// Remove all loaded LoRA adapters.
41-
///
42-
/// - Throws: `SDKError` if clearing fails
4333
static func clearLoraAdapters() async throws {
4434
guard isInitialized else {
4535
throw SDKError.general(.notInitialized, "SDK not initialized")
@@ -48,12 +38,37 @@ public extension RunAnywhere {
4838
}
4939

5040
/// Get info about all currently loaded LoRA adapters.
51-
///
52-
/// - Returns: List of loaded adapter info (path, scale, applied status)
5341
static func getLoadedLoraAdapters() async throws -> [LoRAAdapterInfo] {
5442
guard isInitialized else {
5543
throw SDKError.general(.notInitialized, "SDK not initialized")
5644
}
5745
return try await CppBridge.LLM.shared.getLoadedLoraAdapters()
5846
}
47+
48+
/// Check if a LoRA adapter file is compatible with the currently loaded model.
49+
/// This is a lightweight pre-check; the definitive check happens on load.
50+
static func checkLoraCompatibility(loraPath: String) async -> LoraCompatibilityResult {
51+
guard isInitialized else {
52+
return LoraCompatibilityResult(isCompatible: false, error: "SDK not initialized")
53+
}
54+
return await CppBridge.LLM.shared.checkLoraCompatibility(loraPath: loraPath)
55+
}
56+
57+
// MARK: Catalog Operations
58+
59+
/// Register a LoRA adapter in the SDK catalog at app startup.
60+
/// Call this before loading any adapters so the SDK knows what's available.
61+
static func registerLoraAdapter(_ entry: LoraAdapterCatalogEntry) async throws {
62+
try await CppBridge.LoraRegistry.shared.register(entry)
63+
}
64+
65+
/// Get all LoRA adapters compatible with a specific model.
66+
static func loraAdaptersForModel(_ modelId: String) async -> [LoraAdapterCatalogEntry] {
67+
return await CppBridge.LoraRegistry.shared.getForModel(modelId)
68+
}
69+
70+
/// Get all registered LoRA adapters.
71+
static func allRegisteredLoraAdapters() async -> [LoraAdapterCatalogEntry] {
72+
return await CppBridge.LoraRegistry.shared.getAll()
73+
}
5974
}

0 commit comments

Comments
 (0)