The first time I asked the on-device model whether it could run on a real phone, SystemLanguageModel.default.availability answered .unavailable(.deviceNotEligible). The device was an iPhone 17 on iOS 26.6, which is new enough for Apple Intelligence. It was also bought in mainland China, where Apple Intelligence isn't offered, and as far as I can tell that is the whole reason.
That was a problem. It was the night before I submitted an AI menu for CapScribe, my caption app: keyword emphasis, title suggestions and emoji, all generated on device. The one phone I own will never run it. The simulator can't run it either.
So the honest version of this post is: here is how we shipped a Foundation Models feature we could not run, and what we tested instead.
The gate has more than two states
availability isn't a Bool. When it's unavailable you get a reason, and the reasons deserve different copy:
switch SystemLanguageModel.default.availability {
case .available:
return .available
case .unavailable(let reason):
switch reason {
case .deviceNotEligible:
return .unavailable("Device not eligible for Apple Intelligence")
case .appleIntelligenceNotEnabled:
return .unavailable("Apple Intelligence not enabled in Settings")
case .modelNotReady:
return .unavailable("Model is downloading or not ready")
@unknown default:
return .unavailable("Model unavailable")
}
}
Only one of those is permanent. "Not enabled" is a trip to Settings. "Not ready" usually means the model is still downloading and will sort itself out. Collapse them into a single "AI unavailable" string and you tell the user with a fixable problem that they're stuck, while the user with an ineligible phone learns nothing.
My first instinct was to hide the menu whenever the model isn't there. I didn't. A missing button looks like a bug to anyone who read the release notes, and a hidden menu can't explain itself. What shipped: the menu always appears, the three actions are disabled, and a disabled row at the bottom says which case you're in.
A seam so the fallback can be tested
The service takes an optional model behind a one-method protocol:
public protocol AssistantModel: Sendable {
func respond(to prompt: String) async throws -> String
}
If a model is injected, the service sends it the prompt and decodes the JSON it returns. If not, it checks availability and either opens a LanguageModelSession with a @Generable output type or throws .unavailable(reason). The real path is short on purpose. The less code that only runs on hardware I don't have, the better.
Unit tests inject a mock that records the prompt. That's where the input cap gets checked: transcripts are cut to 3,000 characters before they reach the model, and a test feeds a much longer string and asserts nothing past the cap made it into the prompt.
UI tests can't inject anything, so there's a DEBUG-only launch argument:
#if DEBUG
public static func makeFakeIfNeeded() -> OnDeviceAssistant? {
guard ProcessInfo.processInfo.arguments.contains("-uitest-fake-assistant") else { return nil }
return OnDeviceAssistant(model: FakeAssistantModel())
}
#endif
The fake returns fixed JSON: a couple of keywords, three titles, one 🎬. With the flag set, a UI test runs all three actions in the editor. It checks that keyword emphasis gets switched on in the style panel, that three title rows appear, and that the emoji really lands at the end of the caption text. Without the flag, a second UI test opens the same menu in the simulator, where Foundation Models reports unavailable, and asserts the actions are disabled and the reason row exists and isn't empty.
That second test is the one I care about most. On my own phone the unavailable path is the only path, and for plenty of users it will be too.
What this doesn't cover
The real model. Prompt quality, latency, whether @Guide(.count(3)) really yields three titles every time on a real device: none of that has run on hardware we own. The App Review notes spell out the device requirement, and real-model verification sits on our roadmap as blocked until we get an eligible phone.
I went back and forth on shipping like that. What tipped it was proportion. The code that only runs on eligible hardware is a handful of lines handed straight to Apple's framework. The code that decides what everyone else sees runs on every build.
If you're adding Foundation Models to an app, two things are worth doing on day one. Give each unavailability reason its own message, and put a protocol between your feature and LanguageModelSession, so the fallback path gets tested in the simulator, where it's the default anyway.
I build CapScribe, an on-device caption app for iOS. The menu in question is in the editor: https://apps.apple.com/app/id6801041856?utm_source=devto
Top comments (0)