diff --git a/build.gradle.kts b/build.gradle.kts index 3c58a82..08b1637 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,7 +29,14 @@ dependencies { } apiValidation { - ignoredProjects += listOf("sample", "sample-shared") + ignoredProjects += listOf( + "sample", + "sample-shared", + // TODO: generate halogen-provider-apple-foundation.klib.api on a macOS + // runner via `:halogen-provider-apple-foundation:apiDump` and drop this + // ignore before the next release. + "halogen-provider-apple-foundation", + ) @OptIn(kotlinx.validation.ExperimentalBCVApi::class) klib { enabled = true diff --git a/halogen-provider-apple-foundation/build.gradle.kts b/halogen-provider-apple-foundation/build.gradle.kts new file mode 100644 index 0000000..8c5c7fa --- /dev/null +++ b/halogen-provider-apple-foundation/build.gradle.kts @@ -0,0 +1,47 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.dokka) + id("halogen.publishing") +} + +kotlin { + explicitApi() + + iosArm64() + iosSimulatorArm64() + + sourceSets { + val iosMain by creating { + dependsOn(commonMain.get()) + dependencies { + api(project(":halogen-core")) + implementation(libs.kotlinx.coroutines.core) + } + } + val iosArm64Main by getting { dependsOn(iosMain) } + val iosSimulatorArm64Main by getting { dependsOn(iosMain) } + + val iosTest by creating { + dependsOn(commonTest.get()) + dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + } + val iosArm64Test by getting { dependsOn(iosTest) } + val iosSimulatorArm64Test by getting { dependsOn(iosTest) } + + commonTest.dependencies { + implementation(kotlin("test")) + } + } +} + +mavenPublishing { + pom { + name.set("Halogen Provider — Apple Foundation Models") + description.set("Apple Foundation Models on-device AI provider for Halogen (iOS 26+)") + } +} diff --git a/halogen-provider-apple-foundation/src/iosMain/kotlin/halogen/provider/apple/foundation/AppleFoundationBridge.kt b/halogen-provider-apple-foundation/src/iosMain/kotlin/halogen/provider/apple/foundation/AppleFoundationBridge.kt new file mode 100644 index 0000000..cac945f --- /dev/null +++ b/halogen-provider-apple-foundation/src/iosMain/kotlin/halogen/provider/apple/foundation/AppleFoundationBridge.kt @@ -0,0 +1,42 @@ +package halogen.provider.apple.foundation + +import platform.Foundation.NSError + +/** + * Native-side bridge for Apple's Foundation Models framework (iOS 26+). + * + * The framework is Swift-only, so the actual `LanguageModelSession` / + * `SystemLanguageModel` calls must live in a Swift class that implements + * this protocol. [AppleFoundationProvider] adapts the callback-based + * methods exposed here into the suspend-based [halogen.HalogenLlmProvider] + * contract. + * + * Implementations are expected to dispatch work onto an appropriate queue + * (`Task { ... }` is fine) and to invoke the completion handler exactly + * once on either success or failure. + */ +public interface AppleFoundationBridge { + + /** + * Generate raw JSON theme text for [prompt]. + * + * The completion handler must be invoked with exactly one of: + * - a non-null result string and `null` error on success + * - `null` result and a non-null [NSError] on failure + */ + public fun generate(prompt: String, completion: (String?, NSError?) -> Unit) + + /** + * Probe model availability. + * + * The completion handler receives a status string and an optional reason: + * - `"available"` — model ready for inference + * - `"initializing"` — model is downloading or warming up + * - `"unavailable"` — device not eligible, Apple Intelligence disabled, + * running below iOS 26, or any other unsupported state + * + * The reason (e.g. `"deviceNotEligible"`, `"appleIntelligenceNotEnabled"`, + * `"modelNotReady"`, `"ios<26"`) is informational and may be `null`. + */ + public fun availability(completion: (String, String?) -> Unit) +} diff --git a/halogen-provider-apple-foundation/src/iosMain/kotlin/halogen/provider/apple/foundation/AppleFoundationProvider.kt b/halogen-provider-apple-foundation/src/iosMain/kotlin/halogen/provider/apple/foundation/AppleFoundationProvider.kt new file mode 100644 index 0000000..ee82146 --- /dev/null +++ b/halogen-provider-apple-foundation/src/iosMain/kotlin/halogen/provider/apple/foundation/AppleFoundationProvider.kt @@ -0,0 +1,59 @@ +package halogen.provider.apple.foundation + +import halogen.HalogenLlmAvailability +import halogen.HalogenLlmException +import halogen.HalogenLlmProvider +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Apple Foundation Models on-device AI provider for Halogen. + * + * Wraps a Swift-implemented [AppleFoundationBridge] (which calls + * `LanguageModelSession.respond(to:)` / `SystemLanguageModel.default.availability` + * from `import FoundationModels`) and adapts its callback-style API to the + * [HalogenLlmProvider] suspend interface. + * + * **Device requirements**: iOS 26+, an Apple-Intelligence-eligible device, and + * Apple Intelligence enabled in Settings. On any unsupported configuration + * [availability] returns [HalogenLlmAvailability.UNAVAILABLE] and the caller + * should fall back to a different provider. + */ +public class AppleFoundationProvider( + private val bridge: AppleFoundationBridge, +) : HalogenLlmProvider { + + override suspend fun generate(prompt: String): String = + suspendCancellableCoroutine { cont -> + bridge.generate(prompt) { result, error -> + when { + error != null -> cont.resumeWithException( + HalogenLlmException( + message = "Apple Foundation Models generation failed: ${error.localizedDescription}", + isRetryable = true, + ), + ) + result != null -> cont.resume(result) + else -> cont.resumeWithException( + HalogenLlmException( + "Empty response from Apple Foundation Models", + isRetryable = false, + ), + ) + } + } + } + + override suspend fun availability(): HalogenLlmAvailability = + suspendCancellableCoroutine { cont -> + bridge.availability { status, _ -> + val mapped = when (status) { + "available" -> HalogenLlmAvailability.READY + "initializing" -> HalogenLlmAvailability.INITIALIZING + else -> HalogenLlmAvailability.UNAVAILABLE + } + cont.resume(mapped) + } + } +} diff --git a/halogen-provider-apple-foundation/src/iosTest/kotlin/halogen/provider/apple/foundation/AppleFoundationProviderTest.kt b/halogen-provider-apple-foundation/src/iosTest/kotlin/halogen/provider/apple/foundation/AppleFoundationProviderTest.kt new file mode 100644 index 0000000..82e617f --- /dev/null +++ b/halogen-provider-apple-foundation/src/iosTest/kotlin/halogen/provider/apple/foundation/AppleFoundationProviderTest.kt @@ -0,0 +1,77 @@ +package halogen.provider.apple.foundation + +import halogen.HalogenLlmAvailability +import halogen.HalogenLlmException +import kotlinx.coroutines.test.runTest +import platform.Foundation.NSError +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +private class FakeBridge( + private val generateResult: String? = null, + private val generateError: NSError? = null, + private val availabilityStatus: String = "available", + private val availabilityReason: String? = null, +) : AppleFoundationBridge { + override fun generate(prompt: String, completion: (String?, NSError?) -> Unit) { + completion(generateResult, generateError) + } + + override fun availability(completion: (String, String?) -> Unit) { + completion(availabilityStatus, availabilityReason) + } +} + +class AppleFoundationProviderTest { + + @Test + fun generate_returnsResultFromBridge() = runTest { + val provider = AppleFoundationProvider(FakeBridge(generateResult = "{\"pri\":\"#abc\"}")) + assertEquals("{\"pri\":\"#abc\"}", provider.generate("warm coffee shop")) + } + + @Test + fun generate_wrapsNSErrorAsRetryableException() = runTest { + val error = NSError.errorWithDomain("halogen.test", 42, null) + val provider = AppleFoundationProvider(FakeBridge(generateError = error)) + val ex = assertFailsWith { provider.generate("hint") } + assertTrue(ex.isRetryable, "NSError-derived failures should be retryable") + } + + @Test + fun generate_emptyResponseThrowsNonRetryable() = runTest { + val provider = AppleFoundationProvider(FakeBridge(generateResult = null, generateError = null)) + val ex = assertFailsWith { provider.generate("hint") } + assertEquals(false, ex.isRetryable) + } + + @Test + fun availability_mapsAvailableToReady() = runTest { + val provider = AppleFoundationProvider(FakeBridge(availabilityStatus = "available")) + assertEquals(HalogenLlmAvailability.READY, provider.availability()) + } + + @Test + fun availability_mapsInitializingToInitializing() = runTest { + val provider = AppleFoundationProvider( + FakeBridge(availabilityStatus = "initializing", availabilityReason = "modelNotReady"), + ) + assertEquals(HalogenLlmAvailability.INITIALIZING, provider.availability()) + } + + @Test + fun availability_mapsUnavailableToUnavailable() = runTest { + val provider = AppleFoundationProvider( + FakeBridge(availabilityStatus = "unavailable", availabilityReason = "ios<26"), + ) + assertEquals(HalogenLlmAvailability.UNAVAILABLE, provider.availability()) + } + + @Test + fun availability_mapsUnknownStatusToUnavailable() = runTest { + val provider = AppleFoundationProvider(FakeBridge(availabilityStatus = "garbage")) + assertEquals(HalogenLlmAvailability.UNAVAILABLE, provider.availability()) + } +} diff --git a/sample-ios/iosApp/AppleFoundationBridgeImpl.swift b/sample-ios/iosApp/AppleFoundationBridgeImpl.swift new file mode 100644 index 0000000..81404f3 --- /dev/null +++ b/sample-ios/iosApp/AppleFoundationBridgeImpl.swift @@ -0,0 +1,89 @@ +import Foundation +import HalogenSample +#if canImport(FoundationModels) +import FoundationModels +#endif + +/// Swift implementation of the Kotlin `AppleFoundationBridge` protocol. +/// +/// `FoundationModels` is a Swift-only framework (iOS 26+), so this bridge +/// has to live in the iOS app rather than in the KMP module. The Kotlin +/// `AppleFoundationProvider` adapts the callback methods below into the +/// suspend-based `HalogenLlmProvider` contract. +@objc public final class AppleFoundationBridgeImpl: NSObject, AppleFoundationBridge { + + /// Returns a bridge only when the on-device model is currently + /// `.available`. Callers should treat a `nil` result as "unsupported" + /// and fall back to a different provider. + @objc public static func makeIfAvailable() -> AppleFoundationBridgeImpl? { + if #available(iOS 26.0, *) { + #if canImport(FoundationModels) + switch SystemLanguageModel.default.availability { + case .available: + return AppleFoundationBridgeImpl() + default: + return nil + } + #else + return nil + #endif + } + return nil + } + + public func generate( + prompt: String, + completion: @escaping (String?, NSError?) -> Void + ) { + if #available(iOS 26.0, *) { + #if canImport(FoundationModels) + Task { + do { + let session = LanguageModelSession() + let response = try await session.respond(to: prompt) + completion(response.content, nil) + } catch { + completion(nil, error as NSError) + } + } + return + #endif + } + completion( + nil, + NSError( + domain: "halogen.apple.foundation", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "FoundationModels requires iOS 26+"] + ) + ) + } + + public func availability(completion: @escaping (String, String?) -> Void) { + if #available(iOS 26.0, *) { + #if canImport(FoundationModels) + switch SystemLanguageModel.default.availability { + case .available: + completion("available", nil) + return + case .unavailable(let reason): + switch reason { + case .modelNotReady: + completion("initializing", "modelNotReady") + case .deviceNotEligible: + completion("unavailable", "deviceNotEligible") + case .appleIntelligenceNotEnabled: + completion("unavailable", "appleIntelligenceNotEnabled") + @unknown default: + completion("unavailable", "unknown") + } + return + @unknown default: + completion("unavailable", "unknown") + return + } + #endif + } + completion("unavailable", "ios<26") + } +} diff --git a/sample-ios/iosApp/ContentView.swift b/sample-ios/iosApp/ContentView.swift index 50ea4cd..1e78217 100644 --- a/sample-ios/iosApp/ContentView.swift +++ b/sample-ios/iosApp/ContentView.swift @@ -4,7 +4,8 @@ import HalogenSample struct ComposeView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { - MainViewControllerKt.MainViewController() + let bridge = AppleFoundationBridgeImpl.makeIfAvailable() + return MainViewControllerKt.MainViewController(appleFoundationBridge: bridge) } func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} diff --git a/sample-ios/project.yml b/sample-ios/project.yml index e9ca76a..5a6c722 100644 --- a/sample-ios/project.yml +++ b/sample-ios/project.yml @@ -49,6 +49,8 @@ targets: - "-lc++" - "-framework" - "HalogenSample" + - "-weak_framework" + - "FoundationModels" ENABLE_USER_SCRIPT_SANDBOXING: "NO" CODE_SIGNING_ALLOWED: "NO" preBuildScripts: diff --git a/sample-shared/build.gradle.kts b/sample-shared/build.gradle.kts index aed2df5..f078cc0 100644 --- a/sample-shared/build.gradle.kts +++ b/sample-shared/build.gradle.kts @@ -63,6 +63,7 @@ kotlin { iosMain.dependencies { implementation(libs.ktor.client.darwin) + implementation(project(":halogen-provider-apple-foundation")) } val wasmJsMain by getting { diff --git a/sample-shared/src/commonMain/kotlin/me/mmckenna/halogen/sample/HalogenDemoState.kt b/sample-shared/src/commonMain/kotlin/me/mmckenna/halogen/sample/HalogenDemoState.kt index b4f8cde..e32e5d0 100644 --- a/sample-shared/src/commonMain/kotlin/me/mmckenna/halogen/sample/HalogenDemoState.kt +++ b/sample-shared/src/commonMain/kotlin/me/mmckenna/halogen/sample/HalogenDemoState.kt @@ -1,6 +1,7 @@ package me.mmckenna.halogen.sample import halogen.HalogenDefaults +import halogen.HalogenLlmProvider import halogen.engine.Halogen import halogen.engine.HalogenEngine import kotlinx.coroutines.CoroutineScope @@ -22,25 +23,27 @@ class HalogenDemoState( } companion object { - fun create(scope: CoroutineScope, openAiApiKey: String? = null): HalogenDemoState { + fun create( + scope: CoroutineScope, + openAiApiKey: String? = null, + preferredProvider: HalogenLlmProvider? = null, + preferredProviderName: String? = null, + ): HalogenDemoState { val builder = Halogen.Builder() .defaultTheme(HalogenDefaults.materialYou()) .scope(scope) .tokenBudget(Int.MAX_VALUE) - val provider = if (!openAiApiKey.isNullOrBlank()) { - OpenAiProvider(openAiApiKey) - } else { - DemoProvider() + val (provider, providerName) = when { + preferredProvider != null -> + preferredProvider to (preferredProviderName ?: "Custom") + !openAiApiKey.isNullOrBlank() -> + OpenAiProvider(openAiApiKey) to "OpenAI" + else -> + DemoProvider() to "Demo" } builder.provider(provider) - val providerName = when (provider) { - is OpenAiProvider -> "OpenAI" - is DemoProvider -> "Demo" - else -> "Custom" - } - return HalogenDemoState(engine = builder.build(), scope = scope, providerName = providerName) } } diff --git a/sample-shared/src/iosMain/kotlin/me/mmckenna/halogen/sample/MainViewController.kt b/sample-shared/src/iosMain/kotlin/me/mmckenna/halogen/sample/MainViewController.kt index 2f4193b..4fd132f 100644 --- a/sample-shared/src/iosMain/kotlin/me/mmckenna/halogen/sample/MainViewController.kt +++ b/sample-shared/src/iosMain/kotlin/me/mmckenna/halogen/sample/MainViewController.kt @@ -2,14 +2,28 @@ package me.mmckenna.halogen.sample import androidx.compose.runtime.remember import androidx.compose.ui.window.ComposeUIViewController +import halogen.provider.apple.foundation.AppleFoundationBridge +import halogen.provider.apple.foundation.AppleFoundationProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -fun MainViewController() = ComposeUIViewController { - val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Default) } - val apiKey = remember { getEnvVar("OPENAI_API_KEY") } - val demoState = remember { HalogenDemoState.create(scope, openAiApiKey = apiKey) } +fun MainViewController(appleFoundationBridge: AppleFoundationBridge? = null) = + ComposeUIViewController { + val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Default) } + val apiKey = remember { getEnvVar("OPENAI_API_KEY") } + val demoState = remember { + val appleProvider = appleFoundationBridge?.let { AppleFoundationProvider(it) } + if (appleProvider != null) { + HalogenDemoState.create( + scope = scope, + preferredProvider = appleProvider, + preferredProviderName = "Apple Foundation Models", + ) + } else { + HalogenDemoState.create(scope = scope, openAiApiKey = apiKey) + } + } - HalogenDemoApp(demoState = demoState) -} + HalogenDemoApp(demoState = demoState) + } diff --git a/settings.gradle.kts b/settings.gradle.kts index e8a91b5..a5637e4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,6 +29,7 @@ include( ":halogen-cache-room", ":halogen-image", ":halogen-provider-nano", + ":halogen-provider-apple-foundation", ":sample", ":sample-shared", )