Files
clawdbot/apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift
2026-01-21 19:58:19 +00:00

157 lines
6.5 KiB
Swift

import Foundation
import JavaScriptCore
enum ModelCatalogLoader {
static var defaultPath: String { self.resolveDefaultPath() }
private static let logger = Logger(subsystem: "com.clawdbot", category: "models")
private nonisolated static let appSupportDir: URL = {
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("Clawdbot", isDirectory: true)
}()
private static var cachePath: URL {
self.appSupportDir.appendingPathComponent("model-catalog/models.generated.js", isDirectory: false)
}
static func load(from path: String) async throws -> [ModelChoice] {
let expanded = (path as NSString).expandingTildeInPath
guard let resolved = self.resolvePath(preferred: expanded) else {
self.logger.error("model catalog load failed: file not found")
throw NSError(
domain: "ModelCatalogLoader",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"])
}
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)")
let source = try String(contentsOfFile: resolved.path, encoding: .utf8)
let sanitized = self.sanitize(source: source)
let ctx = JSContext()
ctx?.exceptionHandler = { _, exception in
if let exception {
self.logger.warning("model catalog JS exception: \(exception)")
}
}
ctx?.evaluateScript(sanitized)
guard let rawModels = ctx?.objectForKeyedSubscript("MODELS")?.toDictionary() as? [String: Any] else {
self.logger.error("model catalog parse failed: MODELS missing")
throw NSError(
domain: "ModelCatalogLoader",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Failed to parse models.generated.ts"])
}
var choices: [ModelChoice] = []
for (provider, value) in rawModels {
guard let models = value as? [String: Any] else { continue }
for (id, payload) in models {
guard let dict = payload as? [String: Any] else { continue }
let name = dict["name"] as? String ?? id
let ctxWindow = dict["contextWindow"] as? Int
choices.append(ModelChoice(id: id, name: name, provider: provider, contextWindow: ctxWindow))
}
}
let sorted = choices.sorted { lhs, rhs in
if lhs.provider == rhs.provider {
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
}
return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending
}
self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)")
if resolved.shouldCache {
self.cacheCatalog(sourcePath: resolved.path)
}
return sorted
}
private static func resolveDefaultPath() -> String {
let cache = self.cachePath.path
if FileManager().isReadableFile(atPath: cache) { return cache }
if let bundlePath = self.bundleCatalogPath() { return bundlePath }
if let nodePath = self.nodeModulesCatalogPath() { return nodePath }
return cache
}
private static func resolvePath(preferred: String) -> (path: String, shouldCache: Bool)? {
if FileManager().isReadableFile(atPath: preferred) {
return (preferred, preferred != self.cachePath.path)
}
if let bundlePath = self.bundleCatalogPath(), bundlePath != preferred {
self.logger.warning("model catalog path missing; falling back to bundled catalog")
return (bundlePath, true)
}
let cache = self.cachePath.path
if cache != preferred, FileManager().isReadableFile(atPath: cache) {
self.logger.warning("model catalog path missing; falling back to cached catalog")
return (cache, false)
}
if let nodePath = self.nodeModulesCatalogPath(), nodePath != preferred {
self.logger.warning("model catalog path missing; falling back to node_modules catalog")
return (nodePath, true)
}
return nil
}
private static func bundleCatalogPath() -> String? {
guard let url = Bundle.main.url(forResource: "models.generated", withExtension: "js") else {
return nil
}
return url.path
}
private static func nodeModulesCatalogPath() -> String? {
let roots = [
URL(fileURLWithPath: CommandResolver.projectRootPath()),
URL(fileURLWithPath: FileManager().currentDirectoryPath),
]
for root in roots {
let candidate = root
.appendingPathComponent("node_modules/@mariozechner/pi-ai/dist/models.generated.js")
if FileManager().isReadableFile(atPath: candidate.path) {
return candidate.path
}
}
return nil
}
private static func cacheCatalog(sourcePath: String) {
let destination = self.cachePath
do {
try FileManager().createDirectory(
at: destination.deletingLastPathComponent(),
withIntermediateDirectories: true)
if FileManager().fileExists(atPath: destination.path) {
try FileManager().removeItem(at: destination)
}
try FileManager().copyItem(atPath: sourcePath, toPath: destination.path)
self.logger.debug("model catalog cached file=\(destination.lastPathComponent)")
} catch {
self.logger.warning("model catalog cache failed: \(error.localizedDescription)")
}
}
private static func sanitize(source: String) -> String {
guard let exportRange = source.range(of: "export const MODELS"),
let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"),
let lastBrace = source.lastIndex(of: "}")
else {
return "var MODELS = {}"
}
var body = String(source[firstBrace...lastBrace])
body = body.replacingOccurrences(
of: #"(?m)\bsatisfies\s+[^,}\n]+"#,
with: "",
options: .regularExpression)
body = body.replacingOccurrences(
of: #"(?m)\bas\s+[^;,\n]+"#,
with: "",
options: .regularExpression)
return "var MODELS = \(body);"
}
}