Foundation Models demands three conditions at once: iOS 26 / macOS 26, Apple Intelligence enabled in Settings, and a chip from the compatibility list. The deployment target of Lanternly — a journaling app with an AI companion named Luna that I'm currently building — is iOS 18 / macOS 15. This gap cannot be closed: the app must install and work properly on devices where Foundation Models physically doesn't exist.
That means the AI feature can't be a "have it / don't" fork at the level of a single if — it has to be an architectural layer that switches off cleanly on part of the device fleet instead of breaking the build or confusing the user.
In the Lanternly codebase, the same three-layer gating pattern repeats in literally every service that touches the model: Services/LunaChatService.swift, Services/DailyQuestionService.swift, Services/MonthObservationService.swift, Services/MemoryExtractor.swift. Below is how it's built and why it's built exactly this way.
Why the gate inside services is binary#
The first decision worth making deliberately: the feature services themselves don't figure out why the model is unavailable. They don't need to — they need exactly one bit: may we call the model or not.
static var isAvailable: Bool {
#if canImport(FoundationModels)
if #available(iOS 26, macOS 26, *) {
if case .available = SystemLanguageModel.default.availability { return true }
}
#endif
return false
}The exact same pattern — if #available(iOS 26, macOS 26, *), case .available = SystemLanguageModel.default.availability — sits in front of every model call in LunaChatService.reply(to:), in DailyQuestionService.question(for:), and in the other services.
Whether the device doesn't support Apple Intelligence, it's switched off in Settings, or the model is still downloading in the background — the service reacts the same way: don't call the model, hand control to the fallback. Inflating business logic with a three-way branch on the reason would be needless coupling: the service that generates the question of the day has no business knowing about deviceNotEligible.
The banner knows the reason#
For the user, though, the difference between reasons matters — and here a binary gate is no longer enough. That job belongs to a separate module, LunaDiagnostics, which is the only place in the entire app that does an exhaustive switch over .unavailable(reason:):
var bannerMessage: String? {
if isSimulator { return nil }
switch SystemLanguageModel.default.availability {
case .available:
return nil
case .unavailable(let reason):
switch reason {
case .deviceNotEligible:
// "On-device AI features are limited on this device. Luna is here, but replies with simple presets."
return "Функции локального ИИ ограничены на этом устройстве. Луна рядом, но отвечает простыми заготовками."
case .appleIntelligenceNotEnabled:
// "For Luna to reply in her own live words instead of presets, enable Apple Intelligence in the device Settings."
return "Чтобы Луна отвечала живыми словами, а не заготовками, включи Apple Intelligence в Настройках устройства."
case .modelNotReady:
// "The on-device model is still getting ready in the background. Luna replies with presets for now — check back a bit later."
return "Локальная модель ещё готовится в фоне. Пока Луна отвечает заготовками — вернись чуть позже."
@unknown default:
// "Luna's on-device features are unavailable right now — she replies with presets."
return "Локальные функции Луны сейчас недоступны — она отвечает заготовками."
}
}
}Each state gets its own honest wording, without a generic "something went wrong": an ineligible device, a disabled toggle, and a model that isn't ready yet are three different stories, and the user has the right to know which one is theirs.
The banner offers an "Open Settings" button for exactly one case — appleIntelligenceNotEnabled, because it's the only reason the user can fix themselves right now:
var bannerOffersSettings: Bool {
if case .unavailable(.appleIntelligenceNotEnabled) = SystemLanguageModel.default.availability {
return true
}
return false
}In the Simulator the banner is never shown at all: Foundation Models is always unavailable there, and for a different reason — no point warning a real-device user about that. The banner explains the situation at the top of the screen — but Luna herself still has to answer something in the chat at that moment, and silence is not an option.
Presets are content too#
The fallback in Lanternly isn't a placeholder string saying "AI unavailable" — it's full-fledged content. A bank of five Luna replies for when the model didn't come through:
static var bank: [String] {
[
String(localized: "Спасибо за доверие. Я рядом."), // "Thank you for trusting me. I'm here."
String(localized: "Это звучит важно. Хочешь побыть с этой мыслью ещё немного?"), // "That sounds important. Want to stay with that thought a little longer?"
String(localized: "Понимаю тебя. Что чувствуешь, когда говоришь это вслух?"), // "I understand you. What do you feel when you say it out loud?"
String(localized: "Я слушаю. Расскажи, если хочется, ещё."), // "I'm listening. Tell me more, if you feel like it."
String(localized: "Звучит непросто. Хорошо, что ты говоришь это вслух."), // "That sounds hard. It's good that you're saying it out loud."
]
}Every reply goes through String(localized:) and is translated in Localizable.xcstrings (ru + en); Tests/LocalizationBankTests.swift has a unit test that walks the entire bank plus the crisis reply and checks that every string has a non-empty EN translation with no accidentally leftover Cyrillic.
The bank's wording is deliberately free of gendered endings — where the live model engine can pick the right gendered verb form ("ты записала" vs "ты записал") from the profile, a static bank has to stay neutral.
Similar logic drives the question of the day: 18 questions (3 paths × 6) in the DailyQuestionService bank. For the widget and the menu bar the question must stay the same all day, so the pick is deterministic — by day of the year:
static func dailyBankQuestion(for path: LifePath, on date: Date = .now,
calendar: Calendar = .current) -> String {
let bank = bank(for: path)
let day = calendar.ordinality(of: .day, in: .year, for: date) ?? 1
return bank[(day - 1) % bank.count]
}Inside the app itself it's the opposite — a random pick with anti-repeat via UserDefaults, so the question doesn't repeat day after day in a row. Two different requirements on the same bank — stability for the widget and variety in the app — are covered by two different functions over the same data, not one function with a flag.
Always reply#
The main entry point, LunaChatService.reply(to:), is explicitly documented in the comment above it as "always returns a reply" — and that's not a declaration but an invariant held up by the entire call chain. The priority order goes like this: first the crisis response — deterministic, bypassing the model, and deliberately never landing in any log, even a debug one. Then the Foundation Models path, direct or via pivot translation when the user's language isn't in the model's supportedLanguages (that case is the subject of a separate article, how Luna replies in Russian). And only if both paths failed — the preset bank.
An important detail at the level of the model call itself: runFM doesn't swallow the error silently.
do {
let response = try await session.respond(to: prompt)
let text = response.content.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
LunaDiagnostics.shared.lastGenerationError = nil
return text
}
LunaDiagnostics.shared.lastGenerationError = "пустой ответ модели" // "empty model response"
} catch {
// Do NOT change the fallback — just record the REAL reason (this used to be swallowed by try?).
LunaDiagnostics.shared.lastGenerationError = String(describing: error)
LunaDiagnostics.shared.logReport()
}
return nilThe fallback stays exactly the same, but the reason it was reached is no longer lost — it settles in LunaDiagnostics.shared.lastGenerationError and lands on a dedicated "AI Diagnostics" screen together with the model state, the supported languages, and the source of the last reply (onDevice / pivot / bank / crisis). The difference between "no idea why Luna suddenly replies with presets" and "clear what to fix" is the difference of exactly one line of code not wrapped in try?.
The three-layer pattern#
Technically, this whole gating rests on three independent but coordinated layers. The first is compile-time, around the import:
#if canImport(FoundationModels)
import FoundationModels
#endifIt's needed because the framework may be entirely absent from the SDK the project is built with. The second layer is at runtime, at every call site:
if #available(iOS 26, macOS 26, *), case .available = SystemLanguageModel.default.availability {
// the model is definitely available right now
}It catches two different conditions at once: the OS version on the specific device and the current state of SystemLanguageModel. The third layer sits on the private helpers themselves — the ones that actually touch the framework's types:
@available(iOS 26, macOS 26, *)
private static func runFM(instructions: String, prompt: String) async -> String? { … }The @available attribute here isn't cosmetic — the compiler physically won't let such a helper be called from code that hasn't passed the iOS 26/macOS 26 gate higher up the stack. The "forgot to wrap the call in #available" mistake turns from a runtime bug into a compile error. Three layers cover three different moments when code can step on a missing API: a build against an SDK without the framework, an old OS on the user's device, and an inattentive future self who adds a call in the wrong place.
If You're Building a Similar Gate#
The one decision I would repeat first is keeping the reason for unavailability out of the feature services: a single bit is all they need, while the "why" gets analyzed in one diagnostics module, right next to the banner. The user, on the other hand, needs specifics: a disabled toggle, an ineligible device, and a model that is still downloading each deserve their own wording — and a Settings button belongs only where the person can actually fix something themselves.
The fallback is worth designing as full-fledged content, not a stub: localize it, cover it with a test, keep an eye on the neutrality of the wording. There is no single right answer for picking from the bank either: a widget needs stability, so it gets a deterministic pick by date; a chat needs liveliness, so it gets randomness with anti-repeat. Same data, two different functions — not one function with a flag.
And two principles to close on. An entry point that promises to "always reply" has no right to swallow errors via try? — the fallback stays the same, but the reason must land in the diagnostics. And the three gating layers — #if canImport, #available at the call site, and @available on the helpers — don't duplicate each other; they insure three different moments when code could reach for an API the device doesn't have.



