Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions halogen-provider-apple-foundation/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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+)")
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<HalogenLlmException> { 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<HalogenLlmException> { 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())
}
}
89 changes: 89 additions & 0 deletions sample-ios/iosApp/AppleFoundationBridgeImpl.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
3 changes: 2 additions & 1 deletion sample-ios/iosApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down
2 changes: 2 additions & 0 deletions sample-ios/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ targets:
- "-lc++"
- "-framework"
- "HalogenSample"
- "-weak_framework"
- "FoundationModels"
ENABLE_USER_SCRIPT_SANDBOXING: "NO"
CODE_SIGNING_ALLOWED: "NO"
preBuildScripts:
Expand Down
1 change: 1 addition & 0 deletions sample-shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ kotlin {

iosMain.dependencies {
implementation(libs.ktor.client.darwin)
implementation(project(":halogen-provider-apple-foundation"))
}

val wasmJsMain by getting {
Expand Down
Loading
Loading