Foundation Models требует три условия разом: iOS 26 / macOS 26, Apple Intelligence, включённый в Настройках, и чип из списка совместимых. У Lanternly — дневника с AI-компаньоном Луна, который я сейчас разрабатываю, — деплой-таргет iOS 18 / macOS 15. Этот разрыв не закрыть: приложение обязано ставиться и нормально работать там, где Foundation Models физически нет.
Значит, ИИ-фича не может быть развилкой «есть — нет» на уровне одного if — она должна быть архитектурным слоем, который аккуратно выключается на части парка устройств, а не роняет сборку и не путает пользователя.
В коде Lanternly один и тот же трёхслойный паттерн гейтинга повторяется буквально во всех сервисах, которые трогают модель: Services/LunaChatService.swift, Services/DailyQuestionService.swift, Services/MonthObservationService.swift, Services/MemoryExtractor.swift. Ниже — как он устроен и почему устроен именно так.
Почему бинарный гейт в сервисах#
Первое решение, которое стоило принять осознанно: сами фичевые сервисы не разбираются, почему модель недоступна. Им это не нужно — им нужен один бит: можно вызывать модель или нет.
static var isAvailable: Bool {
#if canImport(FoundationModels)
if #available(iOS 26, macOS 26, *) {
if case .available = SystemLanguageModel.default.availability { return true }
}
#endif
return false
}Точно такой же паттерн — if #available(iOS 26, macOS 26, *), case .available = SystemLanguageModel.default.availability — стоит перед каждым вызовом модели в LunaChatService.reply(to:), в DailyQuestionService.question(for:), в остальных сервисах.
Неважно, устройство ли не поддерживает Apple Intelligence, выключено ли оно в Настройках или модель ещё докачивается фоном — реакция сервиса одна и та же: не вызывать модель, отдать управление фолбэку. Раздувать бизнес-логику веткой на три причины было бы лишней связностью: сервис, который генерирует вопрос дня, не должен знать про deviceNotEligible.
Плашка знает причину#
А вот пользователю разница между причинами важна — и здесь бинарного гейта уже недостаточно. Этим занимается отдельный модуль, LunaDiagnostics, который единственный во всём приложении делает исчерпывающий switch по .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:
return "Функции локального ИИ ограничены на этом устройстве. Луна рядом, но отвечает простыми заготовками."
case .appleIntelligenceNotEnabled:
return "Чтобы Луна отвечала живыми словами, а не заготовками, включи Apple Intelligence в Настройках устройства."
case .modelNotReady:
return "Локальная модель ещё готовится в фоне. Пока Луна отвечает заготовками — вернись чуть позже."
@unknown default:
return "Локальные функции Луны сейчас недоступны — она отвечает заготовками."
}
}
}У каждого состояния — своя честная формулировка, без общего «что-то пошло не так»: неподходящее устройство, выключенный тумблер и ещё не готовая модель — это разные истории, и пользователь имеет право знать, какая из них его.
Кнопку «Открыть Настройки» плашка предлагает только для одного случая — appleIntelligenceNotEnabled, потому что это единственная причина, которую пользователь может исправить сам прямо сейчас:
var bannerOffersSettings: Bool {
if case .unavailable(.appleIntelligenceNotEnabled) = SystemLanguageModel.default.availability {
return true
}
return false
}В симуляторе плашка не показывается вовсе: Foundation Models там недоступны всегда и по другой причине, и предупреждать об этом пользователя реального устройства незачем. Плашка объясняет ситуацию сверху экрана — но сама Луна в этот момент всё равно должна что-то ответить в чате, и тишина не вариант.
Заготовки — тоже контент#
Фолбэк в Lanternly — это не строка-заглушка «ИИ недоступен», а полноценный контент. Банк из пяти реплик Луны на случай, когда модель не сработала:
static var bank: [String] {
[
String(localized: "Спасибо за доверие. Я рядом."),
String(localized: "Это звучит важно. Хочешь побыть с этой мыслью ещё немного?"),
String(localized: "Понимаю тебя. Что чувствуешь, когда говоришь это вслух?"),
String(localized: "Я слушаю. Расскажи, если хочется, ещё."),
String(localized: "Звучит непросто. Хорошо, что ты говоришь это вслух."),
]
}Каждая реплика идёт через String(localized:) и переведена в Localizable.xcstrings (ru + en); в Tests/LocalizationBankTests.swift есть юнит-тест, который проходит по всему банку и по кризисной реплике и проверяет, что у каждой строки есть непустой EN-перевод без случайно оставшейся кириллицы.
Формулировки банка сознательно без родовых окончаний — там, где живой движок модели умеет подставить «ты записала» или «ты записал» по профилю, статичный банк вынужден оставаться нейтральным.
Похожая логика — у вопроса дня: 18 вопросов (3 пути × 6) в банке DailyQuestionService. Для виджета и меню-бара вопрос обязан быть одним и тем же весь день, поэтому выбор детерминированный — по дню года:
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]
}А внутри самого приложения — наоборот, случайный выбор с анти-повтором через UserDefaults, чтобы вопрос не повторялся день за днём подряд. Два разных требования к одному и тому же банку — стабильность для виджета и разнообразие в приложении — закрыты двумя разными функциями поверх одних данных, а не одной с флагом.
Всегда отвечать#
Главная точка входа, LunaChatService.reply(to:), в комментарии над собой прямо документирована как «всегда возвращает реплику» — и это не декларация, а инвариант, который держит вся цепочка вызовов. Порядок приоритета такой: сначала кризисный ответ — детерминированный, мимо модели, и намеренно не попадающий ни в один лог, даже отладочный. Затем — путь через Foundation Models, прямой или через pivot-перевод, если язык пользователя не входит в supportedLanguages модели (этот случай — тема отдельной статьи, как Луна отвечает по-русски). И только если оба пути не сработали — банк заготовок.
Важная деталь на уровне самого вызова модели: runFM не глотает ошибку молча.
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 = "пустой ответ модели"
} catch {
// НЕ меняем фолбэк — просто фиксируем РЕАЛЬНУЮ причину (раньше глотал try?).
LunaDiagnostics.shared.lastGenerationError = String(describing: error)
LunaDiagnostics.shared.logReport()
}
return nilФолбэк остаётся тем же самым, но причина, по которой до него дошло, больше не теряется — она оседает в LunaDiagnostics.shared.lastGenerationError и попадает на отдельный экран «Диагностика ИИ» вместе с состоянием модели, поддерживаемыми языками и источником последнего ответа (onDevice / pivot / bank / crisis). Разница между «непонятно, почему Луна вдруг отвечает заготовками» и «понятно, что чинить» — это разница ровно в одну необёрнутую в try? строку кода.
Паттерн трёх слоёв#
Технически весь этот гейтинг держится на трёх независимых, но согласованных слоях. Первый — компиляционный, вокруг импорта:
#if canImport(FoundationModels)
import FoundationModels
#endifОн нужен, потому что фреймворк вообще может отсутствовать в SDK, которым собирается проект. Второй слой — рантаймовый, в каждой точке вызова:
if #available(iOS 26, macOS 26, *), case .available = SystemLanguageModel.default.availability {
// модель точно доступна прямо сейчас
}Он ловит два разных условия сразу: и версию ОС на конкретном устройстве, и текущее состояние SystemLanguageModel. Третий слой — на самих приватных хелперах, которые реально трогают типы фреймворка:
@available(iOS 26, macOS 26, *)
private static func runFM(instructions: String, prompt: String) async -> String? { … }Атрибут @available здесь не косметика — компилятор физически не даст вызвать такой хелпер из кода, который не прошёл гейт на iOS 26/macOS 26 выше по стеку. Ошибка «забыли обернуть вызов в #available» превращается из бага в рантайме в ошибку компиляции. Три слоя закрывают три разных момента, когда код может «наступить» на отсутствующий API: сборку без фреймворка в SDK, старую ОС на устройстве пользователя и невнимательного будущего себя, который допишет вызов не в том месте.
Если строите похожий гейт#
Главное решение, которое я бы повторил, — не тащить причину недоступности в фичевые сервисы: им хватает одного бита, а разбор «почему» пусть живёт в одном диагностическом модуле, рядом с плашкой. Пользователю при этом нужна конкретика: выключенный тумблер, неподходящее устройство и докачивающаяся модель заслуживают разных слов, а кнопка настроек уместна только там, где человек может что-то исправить сам.
Фолбэк стоит проектировать как полноценный контент, а не заглушку — локализовать, покрыть тестом, следить за нейтральностью формулировок. С выбором из банка тоже нет одного правильного ответа: виджету нужна стабильность, поэтому детерминированный выбор по дате; чату нужна живость, поэтому случайность с анти-повтором. Это одни и те же данные и две разные функции — не одна с флагом.
И два принципа напоследок. Точка входа, обещающая «всегда отвечать», не имеет права глотать ошибки через try? — фолбэк остаётся тем же, но причина обязана осесть в диагностике. А три слоя гейтинга — #if canImport, #available в точке вызова и @available на хелперах — не дублируют, а страхуют три разных момента, когда код может дотянуться до API, которого на устройстве нет.



