Skip to content

Commit 57a0a63

Browse files
authored
Unify asset resolution into ModelBundle.resolveAssetURL (apple#133)
After coreai-build compile produces .aimodelc from .aimodel, metadata.json still references the original name. ModelBundle now resolves .aimodel → .aimodelc transparently at the bundle layer. - Add ModelBundle.resolveAssetURL (single resolution point) - Wire it into modelURL(for:), verify(), Flux2, SD3, and SD 1.x/2.x - Remove PreparedModel.resolveCoreAIModelURL (dead after bundle-layer resolution) - Remove redundant resolution call from EngineFactory.createEngine
1 parent af98e4d commit 57a0a63

7 files changed

Lines changed: 46 additions & 67 deletions

File tree

swift/Sources/CoreAIDiffusionPipeline/Pipelines/Flux2Pipeline+Resources.swift

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause
55

66
import CoreAI
7+
import CoreAIShared
78
import Foundation
89
import Tokenizers
910

@@ -114,15 +115,9 @@ extension Flux2Pipeline {
114115

115116
/// Resolve an asset name to a filename, checking for .aimodel or .aimodelc.
116117
private static func resolveAsset(at url: URL, name: String) -> String? {
117-
let fm = FileManager.default
118-
let aimodel = "\(name).aimodel"
119-
let aimodelc = "\(name).aimodelc"
120-
if fm.fileExists(atPath: url.appendingPathComponent(aimodel).path) {
121-
return aimodel
122-
} else if fm.fileExists(atPath: url.appendingPathComponent(aimodelc).path) {
123-
return aimodelc
124-
}
125-
return nil
118+
let resolved = ModelBundle.resolveAssetURL("\(name).aimodel", in: url)
119+
guard FileManager.default.fileExists(atPath: resolved.path) else { return nil }
120+
return resolved.lastPathComponent
126121
}
127122

128123
/// Probe available assets and pick the highest quality mode.

swift/Sources/CoreAIDiffusionPipeline/Pipelines/PipelineDescriptor+CoreAI.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause
55

66
import CoreAI
7+
import CoreAIShared
78
import Foundation
89

910
/// Loaded diffusion pipeline components backed by Core AI model functions.
@@ -51,14 +52,14 @@ extension PipelineDescriptor {
5152

5253
// Create model functions
5354
let unetFunction = CoreAIDiffusionModelFunction(
54-
modelURL: baseURL.appendingPathComponent(unetPath))
55+
modelURL: ModelBundle.resolveAssetURL(unetPath, in: baseURL))
5556
let decoderFunction = CoreAIDiffusionModelFunction(
56-
modelURL: baseURL.appendingPathComponent(decoderPath))
57+
modelURL: ModelBundle.resolveAssetURL(decoderPath, in: baseURL))
5758

5859
let encoderFunction: CoreAIDiffusionModelFunction?
5960
if let encoderPath = components.vaeEncoder {
6061
encoderFunction = CoreAIDiffusionModelFunction(
61-
modelURL: baseURL.appendingPathComponent(encoderPath))
62+
modelURL: ModelBundle.resolveAssetURL(encoderPath, in: baseURL))
6263
} else {
6364
encoderFunction = nil
6465
}
@@ -108,7 +109,7 @@ extension PipelineDescriptor {
108109
let textEncoderFunction: CoreAIDiffusionModelFunction
109110
if let tePath = components.textEncoder {
110111
textEncoderFunction = CoreAIDiffusionModelFunction(
111-
modelURL: baseURL.appendingPathComponent(tePath))
112+
modelURL: ModelBundle.resolveAssetURL(tePath, in: baseURL))
112113
} else {
113114
throw PipelineLoadError.missingComponent("text_encoder")
114115
}

swift/Sources/CoreAIDiffusionPipeline/Pipelines/SD3Pipeline+Resources.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause
55

66
import CoreAI
7+
import CoreAIShared
78
import Foundation
89

910
extension SD3Pipeline {
@@ -29,13 +30,13 @@ extension SD3Pipeline {
2930
}
3031

3132
let transformer = CoreAIDiffusionModelFunction(
32-
modelURL: url.appendingPathComponent(transformerPath))
33+
modelURL: ModelBundle.resolveAssetURL(transformerPath, in: url))
3334
let textEncoder = CoreAIDiffusionModelFunction(
34-
modelURL: url.appendingPathComponent(textEncoderPath))
35+
modelURL: ModelBundle.resolveAssetURL(textEncoderPath, in: url))
3536
let textEncoder2 = CoreAIDiffusionModelFunction(
36-
modelURL: url.appendingPathComponent(textEncoder2Path))
37+
modelURL: ModelBundle.resolveAssetURL(textEncoder2Path, in: url))
3738
let decoder = CoreAIDiffusionModelFunction(
38-
modelURL: url.appendingPathComponent(decoderPath))
39+
modelURL: ModelBundle.resolveAssetURL(decoderPath, in: url))
3940

4041
let tokenizer = try Self.loadBPETokenizer(
4142
at: url.appendingPathComponent("tokenizer"))

swift/Sources/CoreAILanguageModels/InferenceEngines/EngineFactory.swift

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public struct EngineFactory: Sendable {
2525
///
2626
/// - Parameters:
2727
/// - config: The JSON model configuration as raw data.
28-
/// - modelURL: The location of the model asset on disk.
28+
/// - modelURL: The location of the CoreAI model asset (`.aimodel` or `.aimodelc`).
2929
/// - options: The engine options, including an optional variant override and KV cache
3030
/// settings. Defaults to a value with auto-detection enabled and the `.auto` KV cache
3131
/// strategy.
@@ -45,23 +45,20 @@ public struct EngineFactory: Sendable {
4545
CLILogger.log(" - kvCacheSize: \(size) (override)")
4646
}
4747

48-
// Step 2: Resolve model URL for Core AI path
49-
let coreAIModelURL = PreparedModel.resolveCoreAIModelURL(from: modelURL)
50-
51-
// Step 3: Prepare model asset via Core AI
52-
let preparedModel = try await PreparedModel.prepare(at: coreAIModelURL)
48+
// Step 2: Prepare model asset via Core AI
49+
let preparedModel = try await PreparedModel.prepare(at: modelURL)
5350

5451
CLILogger.log(" - structure: \(preparedModel.structure.description)")
5552

56-
// Step 4: Resolve variant with structure detection
53+
// Step 3: Resolve variant with structure detection
5754
let variant = try resolveVariant(
5855
override: options.variant,
5956
detectedStructure: preparedModel.structure
6057
)
6158

6259
CLILogger.log(" - resolved variant: \(variant.rawValue)")
6360

64-
// Step 5: Instantiate the appropriate engine
61+
// Step 4: Instantiate the appropriate engine
6562
return try await selectEngine(
6663
variant: variant,
6764
config: parsedConfig,

swift/Sources/CoreAIShared/Bundle/ModelBundle.swift

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,10 @@ public struct ModelBundle: Sendable {
4848
}
4949

5050
/// Resolve a component's URL within the bundle by role key.
51+
/// Falls back to `.aimodelc` if the declared `.aimodel` path doesn't exist on disk.
5152
public func modelURL(for key: String) -> URL? {
5253
guard let path = assets[key] else { return nil }
53-
return bundlePath.appending(path: path)
54+
return Self.resolveAssetURL(path, in: bundlePath)
5455
}
5556

5657
/// Required-component variant — throws `BundleError.missingField` if absent.
@@ -61,13 +62,27 @@ public struct ModelBundle: Sendable {
6162
return url
6263
}
6364

65+
/// Resolve an asset path against a directory, falling back from `.aimodel` to `.aimodelc`.
66+
///
67+
/// When `coreai-build compile` produces a compiled `.aimodelc` from a source `.aimodel`,
68+
/// metadata.json still references the original name. This finds the compiled variant
69+
/// so users don't need to hand-edit metadata.json after compilation.
70+
public static func resolveAssetURL(_ path: String, in directory: URL) -> URL {
71+
let url = directory.appending(path: path)
72+
if FileManager.default.fileExists(atPath: url.path) { return url }
73+
if path.hasSuffix(".aimodel") {
74+
let compiled = directory.appending(path: path + "c")
75+
if FileManager.default.fileExists(atPath: compiled.path) { return compiled }
76+
}
77+
return url
78+
}
79+
6480
/// Verify all declared assets exist on disk. Throws `BundleError.missingAsset`
6581
/// with guidance if a component is missing (e.g. after manual compilation).
6682
public func verify() throws {
67-
let fm = FileManager.default
6883
for (key, filename) in assets {
69-
let url = bundlePath.appending(path: filename)
70-
if !fm.fileExists(atPath: url.path) {
84+
let url = Self.resolveAssetURL(filename, in: bundlePath)
85+
if !FileManager.default.fileExists(atPath: url.path) {
7186
throw BundleError.missingAsset(key: key, path: url)
7287
}
7388
}

swift/Sources/CoreAIShared/Runtime/ModelStructure.swift

Lines changed: 5 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -104,33 +104,6 @@ public struct PreparedModel: Sendable {
104104
/// Detected model structure (chunked/static vs dynamic).
105105
public let structure: ModelStructure
106106

107-
// MARK: - Core AI Model URL Resolution
108-
109-
/// If `url` is already `.aimodel`, returns it unchanged. Otherwise looks for
110-
/// a sibling `.aimodel` directory with the same base name.
111-
public static func resolveCoreAIModelURL(from url: URL) -> URL {
112-
let ext = url.pathExtension
113-
114-
// Already a Core AI format
115-
if ext == "aimodel" {
116-
return url
117-
}
118-
119-
// Check for sibling Core AI model directory
120-
let parentDir = url.deletingLastPathComponent()
121-
let baseName = url.deletingPathExtension().lastPathComponent
122-
123-
let candidate = parentDir.appendingPathComponent("\(baseName).aimodel")
124-
if FileManager.default.fileExists(atPath: candidate.path) {
125-
CLILogger.log(
126-
" - Resolved CoreAI model path: \(url.lastPathComponent)\(candidate.lastPathComponent)")
127-
return candidate
128-
}
129-
130-
// Fall through to original URL (AIModel may still handle it)
131-
return url
132-
}
133-
134107
// MARK: - Cache Inspection
135108

136109
/// File extensions that identify a Core AI model asset (source or compiled).
@@ -175,8 +148,7 @@ public struct PreparedModel: Sendable {
175148
public static func clearCache(at url: URL) throws -> [URL] {
176149
let assetURLs = try modelAssetURLs(at: url)
177150
for assetURL in assetURLs {
178-
let coreaiURL = resolveCoreAIModelURL(from: assetURL)
179-
try AIModelCache.default.deleteEntries(for: coreaiURL)
151+
try AIModelCache.default.deleteEntries(for: assetURL)
180152
}
181153
return assetURLs
182154
}
@@ -192,9 +164,8 @@ public struct PreparedModel: Sendable {
192164
/// the ``isCached(at:)`` overload; callers that load via `AIModel(contentsOf:)` or a custom
193165
/// `SpecializationOptions` must pass the same value here.
194166
public static func isCached(at url: URL, options: SpecializationOptions) -> Bool {
195-
let coreaiURL = resolveCoreAIModelURL(from: url)
196167
do {
197-
return try AIModelCache.default.model(for: coreaiURL, options: options) != nil
168+
return try AIModelCache.default.model(for: url, options: options) != nil
198169
} catch {
199170
return false
200171
}
@@ -206,9 +177,8 @@ public struct PreparedModel: Sendable {
206177
/// Use this only for models loaded through ``prepare(at:)``. For other loaders, use
207178
/// ``isCached(at:options:)`` with the matching options.
208179
public static func isCached(at url: URL) -> Bool {
209-
let coreaiURL = resolveCoreAIModelURL(from: url)
210-
let options = probeStructure(at: coreaiURL).specializationOptions
211-
return isCached(at: coreaiURL, options: options)
180+
let options = probeStructure(at: url).specializationOptions
181+
return isCached(at: url, options: options)
212182
}
213183

214184
// MARK: - Asset Preparation
@@ -219,7 +189,7 @@ public struct PreparedModel: Sendable {
219189
/// dynamic models prefer GPU with frequent reshapes; chunked-static models prefer Neural Engine.
220190
///
221191
/// - Parameters:
222-
/// - url: URL to the model asset (`.aimodel` bundle)
192+
/// - url: URL to the model asset (`.aimodel` or `.aimodelc` bundle)
223193
/// - Returns: Prepared asset with compiled library and detected structure
224194
/// - Throws: Error from `AIModel` if loading or specialization fails
225195
public static func prepare(

swift/Sources/Tools/llm-runner/LLMRunnerMain.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ struct LLMRunner: AsyncParsableCommand, Sendable {
376376
cacheHit = PreparedModel.isCached(at: languageModelURL)
377377
}
378378

379-
let assetLabel = try modelAssetTypeLabel(for: bundle.modelAssetPath)
379+
let assetLabel = try modelAssetTypeLabel(for: languageModelURL.pathExtension)
380380
if !CLILogger.isVerbose {
381381
print("\n⏳ Preparing AI asset from \(assetLabel)...", terminator: "")
382382
fflush(stdout)
@@ -1075,8 +1075,8 @@ struct LLMRunner: AsyncParsableCommand, Sendable {
10751075

10761076
// MARK: - Asset Type Label
10771077

1078-
private func modelAssetTypeLabel(for path: String) throws -> String {
1079-
switch URL(fileURLWithPath: path).pathExtension.lowercased() {
1078+
private func modelAssetTypeLabel(for ext: String) throws -> String {
1079+
switch ext.lowercased() {
10801080
case "aimodelc": return "compiled"
10811081
case "aimodel": return "source"
10821082
default:

0 commit comments

Comments
 (0)