Foundation Models exige tres condiciones a la vez: iOS 26 / macOS 26, Apple Intelligence activado en Ajustes y un chip de la lista de compatibles. El deployment target de Lanternly — un diario con una compañera de IA llamada Luna que estoy desarrollando ahora mismo — es iOS 18 / macOS 15. Esa brecha no se puede cerrar: la app está obligada a instalarse y funcionar con normalidad en dispositivos donde Foundation Models físicamente no existe.
Eso significa que la función de IA no puede ser una bifurcación «hay / no hay» al nivel de un único if: tiene que ser una capa arquitectónica que se apague con cuidado en parte del parque de dispositivos, sin tumbar la compilación ni confundir al usuario.
En el código de Lanternly, el mismo patrón de gating de tres capas se repite literalmente en todos los servicios que tocan el modelo: Services/LunaChatService.swift, Services/DailyQuestionService.swift, Services/MonthObservationService.swift, Services/MemoryExtractor.swift. A continuación, cómo está construido y por qué está construido exactamente así.
Por qué el gate en los servicios es binario#
La primera decisión que valía la pena tomar de forma consciente: los propios servicios de la función no averiguan por qué el modelo no está disponible. No lo necesitan — necesitan un solo bit: se puede llamar al modelo o no.
static var isAvailable: Bool {
#if canImport(FoundationModels)
if #available(iOS 26, macOS 26, *) {
if case .available = SystemLanguageModel.default.availability { return true }
}
#endif
return false
}Exactamente el mismo patrón — if #available(iOS 26, macOS 26, *), case .available = SystemLanguageModel.default.availability — está delante de cada llamada al modelo en LunaChatService.reply(to:), en DailyQuestionService.question(for:), en el resto de los servicios.
Da igual si el dispositivo no soporta Apple Intelligence, si está desactivado en Ajustes o si el modelo todavía se está descargando en segundo plano — la reacción del servicio es siempre la misma: no llamar al modelo y ceder el control al fallback. Hinchar la lógica de negocio con una rama de tres causas sería acoplamiento innecesario: el servicio que genera la pregunta del día no tiene por qué saber nada de deviceNotEligible.
El banner sí conoce la causa#
En cambio, al usuario la diferencia entre causas sí le importa — y aquí el gate binario ya no basta. De eso se encarga un módulo aparte, LunaDiagnostics, el único en toda la app que hace un switch exhaustivo sobre .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:
// "Las funciones de IA local están limitadas en este dispositivo. Luna está aquí, pero responde con frases predefinidas sencillas."
return "Функции локального ИИ ограничены на этом устройстве. Луна рядом, но отвечает простыми заготовками."
case .appleIntelligenceNotEnabled:
// "Para que Luna responda con palabras vivas y no con frases predefinidas, activa Apple Intelligence en los Ajustes del dispositivo."
return "Чтобы Луна отвечала живыми словами, а не заготовками, включи Apple Intelligence в Настройках устройства."
case .modelNotReady:
// "El modelo local todavía se está preparando en segundo plano. Por ahora Luna responde con frases predefinidas — vuelve un poco más tarde."
return "Локальная модель ещё готовится в фоне. Пока Луна отвечает заготовками — вернись чуть позже."
@unknown default:
// "Las funciones locales de Luna no están disponibles ahora mismo — responde con frases predefinidas."
return "Локальные функции Луны сейчас недоступны — она отвечает заготовками."
}
}
}Cada estado tiene su formulación honesta, sin un genérico «algo salió mal»: un dispositivo no compatible, un interruptor desactivado y un modelo que aún no está listo son historias distintas, y el usuario tiene derecho a saber cuál es la suya.
El banner ofrece el botón «Abrir Ajustes» solo en un caso — appleIntelligenceNotEnabled, porque es la única causa que el usuario puede arreglar por sí mismo ahora mismo:
var bannerOffersSettings: Bool {
if case .unavailable(.appleIntelligenceNotEnabled) = SystemLanguageModel.default.availability {
return true
}
return false
}En el simulador el banner no se muestra en absoluto: allí Foundation Models no está disponible nunca y por otra razón, y no tiene sentido advertir de ello al usuario de un dispositivo real. El banner explica la situación en la parte superior de la pantalla — pero la propia Luna, en ese momento, aun así tiene que responder algo en el chat, y el silencio no es una opción.
Las frases predefinidas también son contenido#
El fallback en Lanternly no es una cadena-parche tipo «IA no disponible», sino contenido de pleno derecho. Un banco de cinco réplicas de Luna para cuando el modelo no funcionó:
static var bank: [String] {
[
String(localized: "Спасибо за доверие. Я рядом."), // "Gracias por confiar en mí. Estoy aquí."
String(localized: "Это звучит важно. Хочешь побыть с этой мыслью ещё немного?"), // "Suena importante. ¿Quieres quedarte un poco más con ese pensamiento?"
String(localized: "Понимаю тебя. Что чувствуешь, когда говоришь это вслух?"), // "Te entiendo. ¿Qué sientes al decirlo en voz alta?"
String(localized: "Я слушаю. Расскажи, если хочется, ещё."), // "Te escucho. Cuéntame más, si te apetece."
String(localized: "Звучит непросто. Хорошо, что ты говоришь это вслух."), // "Suena difícil. Está bien que lo digas en voz alta."
]
}Cada réplica pasa por String(localized:) y está traducida en Localizable.xcstrings (ru + en); en Tests/LocalizationBankTests.swift hay un test unitario que recorre todo el banco y la réplica de crisis y comprueba que cada cadena tiene una traducción EN no vacía, sin cirílico olvidado por accidente.
Las formulaciones del banco están deliberadamente libres de terminaciones de género: donde el motor vivo del modelo sabe elegir la forma verbal correcta según el perfil («ты записала» o «ты записал»), el banco estático está obligado a permanecer neutro.
Una lógica parecida tiene la pregunta del día: 18 preguntas (3 caminos × 6) en el banco de DailyQuestionService. Para el widget y la barra de menús la pregunta debe ser la misma durante todo el día, así que la elección es determinista — por el día del año:
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]
}Dentro de la propia app, al contrario — elección aleatoria con anti-repetición vía UserDefaults, para que la pregunta no se repita día tras día seguidos. Dos requisitos distintos sobre el mismo banco — estabilidad para el widget y variedad en la app — se resuelven con dos funciones distintas sobre los mismos datos, no con una sola función con un flag.
Responder siempre#
El punto de entrada principal, LunaChatService.reply(to:), está documentado explícitamente en el comentario que lo precede como «siempre devuelve una réplica» — y no es una declaración, sino un invariante que sostiene toda la cadena de llamadas. El orden de prioridad es este: primero la respuesta de crisis — determinista, sin pasar por el modelo, y que deliberadamente no llega a ningún log, ni siquiera de depuración. Después, el camino por Foundation Models, directo o vía traducción pivot cuando el idioma del usuario no está en los supportedLanguages del modelo (ese caso es el tema de otro artículo, cómo Luna responde en ruso). Y solo si ambos caminos fallaron — el banco de frases predefinidas.
Un detalle importante al nivel de la propia llamada al modelo: runFM no se traga el error en silencio.
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 = "пустой ответ модели" // "respuesta vacía del modelo"
} catch {
// NO cambiamos el fallback — solo registramos la causa REAL (antes try? se la tragaba).
LunaDiagnostics.shared.lastGenerationError = String(describing: error)
LunaDiagnostics.shared.logReport()
}
return nilEl fallback sigue siendo exactamente el mismo, pero la causa por la que se llegó a él ya no se pierde — se asienta en LunaDiagnostics.shared.lastGenerationError y aparece en una pantalla dedicada de «Diagnóstico de IA» junto con el estado del modelo, los idiomas soportados y la fuente de la última respuesta (onDevice / pivot / bank / crisis). La diferencia entre «no está claro por qué Luna de repente responde con frases predefinidas» y «está claro qué arreglar» es exactamente la diferencia de una línea de código no envuelta en try?.
El patrón de tres capas#
Técnicamente, todo este gating se sostiene sobre tres capas independientes pero coordinadas. La primera es de compilación, alrededor del import:
#if canImport(FoundationModels)
import FoundationModels
#endifHace falta porque el framework puede estar directamente ausente del SDK con el que se compila el proyecto. La segunda capa es de runtime, en cada punto de llamada:
if #available(iOS 26, macOS 26, *), case .available = SystemLanguageModel.default.availability {
// el modelo está definitivamente disponible ahora mismo
}Atrapa dos condiciones distintas a la vez: la versión del SO en el dispositivo concreto y el estado actual de SystemLanguageModel. La tercera capa está sobre los propios helpers privados, los que realmente tocan los tipos del framework:
@available(iOS 26, macOS 26, *)
private static func runFM(instructions: String, prompt: String) async -> String? { … }El atributo @available aquí no es cosmética — el compilador físicamente no dejará llamar a ese helper desde código que no haya pasado el gate de iOS 26/macOS 26 más arriba en la pila. El error «olvidamos envolver la llamada en #available» pasa de ser un bug en runtime a un error de compilación. Las tres capas cubren tres momentos distintos en los que el código puede «pisar» una API ausente: la compilación sin el framework en el SDK, un SO antiguo en el dispositivo del usuario y un yo futuro despistado que añada una llamada donde no toca.
Si construís un gate parecido#
La decisión principal que repetiría es no arrastrar la causa de la indisponibilidad hasta los servicios de la función: a ellos les basta un solo bit, y el análisis del «porqué» que viva en un único módulo de diagnóstico, junto al banner. El usuario, en cambio, necesita concreción: un interruptor apagado, un dispositivo no compatible y un modelo que todavía se está descargando merecen palabras distintas, y el botón de ajustes solo tiene sentido allí donde la persona puede arreglar algo por sí misma.
El fallback conviene diseñarlo como contenido de pleno derecho, no como un relleno: localizarlo, cubrirlo con un test, vigilar la neutralidad de las formulaciones. Con la elección desde el banco tampoco hay una única respuesta correcta: el widget necesita estabilidad, así que elección determinista por fecha; el chat necesita viveza, así que aleatoriedad con anti-repetición. Son los mismos datos y dos funciones distintas — no una sola con un flag.
Y dos principios para terminar. Un punto de entrada que promete «responder siempre» no tiene derecho a tragarse errores vía try? — el fallback sigue siendo el mismo, pero la causa debe quedar registrada en el diagnóstico. Y las tres capas de gating — #if canImport, #available en el punto de llamada y @available en los helpers — no se duplican: aseguran tres momentos distintos en los que el código puede alcanzar una API que no existe en el dispositivo.



