Teaching Apple Intelligence to Speak Russian On-Device#
Foundation Models is the framework that, starting with iOS 26, gives developers direct access to the same on-device model that powers Apple Intelligence. One Swift type, LanguageModelSession, and your app can generate text with no cloud round-trip, no API key, and no user data ever leaving the device. It sounds like the answer to almost any text feature.
Except for one thing: the model doesn't speak Russian. Not "speaks it poorly" — doesn't speak it at all, officially, at the level of supportedLanguages.
I ran into this while building Lanternly, a journaling app with an AI companion named Luna, currently in active development. The whole premise of the app is that the AI runs fully on-device: no login, no server, no entries ever leaving the phone. In that architecture, Russian isn't a nice-to-have locale — it's the difference between the product working for a Russian-speaking user at all or not. Here's how I solved it at the architecture level, with real code.
The problem: Foundation Models has no Russian#
The model behind Apple Intelligence was trained as multilingual — Apple's own research publications make that clear. But a language being present somewhere in training data and a language being officially shipped as supported in the consumer-facing framework are two different things. As of mid-2026, Apple Intelligence's publicly stated language list covers English, French, German, Italian, Portuguese (Brazil), Spanish, Japanese, Korean, and Simplified Chinese, plus a handful of additional locales such as Dutch, Swedish, and Turkish — roughly two dozen in total. Russian isn't on it.
It's not about generation quality — it's that SystemLanguageModel doesn't officially declare Russian as supported, so any call that assumes direct Russian generation either silently degrades or simply isn't guaranteed to work. This has been a recurring topic on developer forums: people periodically ask on Apple Discussions whether Russian is planned, and get the answer you get on any community forum — no official information, plus speculation (including the theory that Apple's exit from the Russian retail market may have lowered the priority of the language). Neither answer changes the practical requirement: the app still needs to reply to the user in Russian.
One detail matters for engineering: this list isn't a constant. Apple explicitly tells developers to check it at runtime rather than hardcode it, because the set of supported languages can change release to release. That directly shaped Lanternly's architecture — the code is written so that the day Apple adds Russian, the app switches to the direct generation path with zero changes.
Don't guess: check supportedLanguages at runtime#
The first architectural decision is to never hardcode a list of "supported" languages inside the app. Instead, query the model directly:
@available(iOS 26, macOS 26, *)
private static func fmSupports(_ lang: Locale.Language) -> Bool {
guard let code = lang.languageCode else { return false }
return SystemLanguageModel.default.supportedLanguages
.contains { $0.languageCode == code }
}This is a small function, but the entire architectural fork rests on it: it decides whether a reply takes the short path (direct generation) or the long one (translate there and back). Right next to it sits a second practical problem — detecting the user's language from short text. NLLanguageRecognizer from NaturalLanguage isn't reliable on short phrases: it's not uncommon for it to mistake Cyrillic for Bulgarian, Ukrainian, or Macedonian. The fix is to narrow the candidate set to languages you can actually handle and set priors:
private static func languageOf(_ text: String) -> Locale.Language {
let recognizer = NLLanguageRecognizer()
recognizer.languageConstraints = candidateLanguages() // model-supported + ru + device languages
recognizer.languageHints = [.russian: 0.5, .english: 0.35]
recognizer.processString(text)
if let lang = recognizer.dominantLanguage {
return Locale.Language(identifier: lang.rawValue)
}
return Locale.current.language
}Without that constraint, a short "hi, how are you" style greeting in Russian has a real chance of being classified as Bulgarian — and every downstream decision follows the wrong branch.
Path A: when the language is supported directly#
If fmSupports returns true, the flow is simple: one LanguageModelSession with instructions in the reply language, one call to respond(to:). For English, Spanish, Japanese, and every other officially supported language, Lanternly uses exactly this path — no translation, minimal latency.
Russian deserves a special note here: even though it isn't officially supported, Lanternly's codebase keeps a full Russian-language system prompt as the verbatim source of truth. Not because it's called directly today — but because on the day Apple adds Russian to supportedLanguages, the app won't need to rewrite the companion's tone and personality logic. It's already there, waiting for its branch of the condition to fire.
Path B: pivot-translation — working around the limit on-device#
When the language isn't supported directly, a second path kicks in: generation still happens on-device, but in English rather than the user's language, with translation on the way in and on the way out.
@available(iOS 26, macOS 26, *)
private static func pivotReply(userText: String, userLang: Locale.Language,
history: [ChatMessage]) async -> String? {
let english = Locale.Language(identifier: "en")
// 1. Translate the input into English — requires a downloaded language pack.
guard let userEN = await Translator.translate(userText, from: userLang, to: english) else {
return nil // pack not downloaded — don't fake it, return nil honestly
}
// 2. Generate in English — inside the model's officially supported range.
guard let replyEN = await runFM(instructions: systemPromptEN, prompt: userEN) else {
return nil
}
// 3. Translate the reply back into the user's language.
return await Translator.translate(replyEN, from: english, to: userLang)
}Three stages, three on-device calls instead of one — and it's noticeably slower than direct generation. But for a language outside the supported list, it's the only way to give a real, generated reply instead of a canned one, without a single step leaving the device.
The production code in Lanternly is a bit more resilient than the simplified version above: if translating the message itself succeeds but translating the surrounding context (chat history) fails, the app doesn't drop the whole reply into the fallback bank — it answers with the model, without history. A partial translation failure doesn't kill the conversation; that's a deliberate trade-off between full context and a live reply.
Translation framework: on-device, but not free of conditions#
A detail that's easy to miss: Translation is also fully on-device (introduced back in iOS 18 at WWDC 2024), but it doesn't ship with every language pair baked in. Translating a given pair of languages only works if the corresponding language pack is already downloaded on the device — a system-wide resource shared by every app. If the user has never opened the system translator and never downloaded the ru↔en pack, the first TranslationSession call either triggers a download (in SwiftUI, .translationTask will prompt the system to offer one) or simply fails, if the app isn't in a position to request it.
The practical rule that follows: a pivot-translation pipeline can never silently assume success. If the pack isn't downloaded, translation fails — and that's a normal, expected outcome, not a rare edge case.
Honest diagnostics instead of a silent fallback#
The most expensive mistake in this kind of architecture isn't having a fallback — it's the fallback being opaque. An earlier version of similar code in Lanternly wrapped the model call in try? — convenient, but it means that on failure (model refused, translation unavailable, language pack missing) the reason vanishes silently, leaving only one signal: "the reply came from the fallback bank." There's no way to diagnose that on a real user's device.
The current version records the real reason at every step:
do {
let response = try await session.respond(to: prompt)
return response.content
} catch {
// NOT try? — record the real reason instead of swallowing it.
Diagnostics.shared.lastGenerationError = String(describing: error)
return nil
}Plus a dedicated diagnostics layer that can answer, at any moment, "why is Luna replying with canned lines instead of generating one herself right now": is the model available, is the language supported, is this a simulator or a real device, what was the last generation error. This isn't for the end user in production — it's the difference between "it's unclear why this isn't working" and "it's clear what needs fixing" during development and support.
One privacy detail matters here: the diagnostics layer never logs message content — only technical metadata (language code, error type, reply source). And it's explicitly disabled for any message caught by the crisis-detection protocol — under no circumstance should those interactions leave a trace, even in a device debug log.
Here's how the three paths compare side by side:
| Path A (direct) | Path B (pivot) | Bank (fallback) | |
|---|---|---|---|
| Fires when | Language is in supportedLanguages | Language unsupported, but translation is available | Model unavailable, language pack missing, any step failed |
| On-device calls | 1 (generation) | up to 3 (translate → generate → translate) | 0 |
| Requirements | Apple Intelligence on, iOS 26+ | + downloaded ru↔en translation pack | none |
| Latency | lowest | noticeably higher (two translation hops on top of generation) | instant |
| Privacy | 100% on-device | 100% on-device | on-device (static text) |
| If it fails | falls to bank | falls to bank | never fails — always returns a reply |
What to take from this pattern#
This approach isn't specific to a journaling app with an AI companion — it's a general pattern for any on-device LLM feature serving an audience beyond the model's officially supported languages: detect the language with a narrowed candidate set and priors, check support at runtime instead of hardcoding a list, use a local system language pack as a temporary bridge, and — most importantly — never swallow errors silently in the place where a fallback would otherwise mask the real cause.
Russian will likely land in supportedLanguages sooner or later — Apple keeps expanding the list, and Russian already appears among the languages the underlying model is trained on, according to the company's own research. Until then, pivot-translation isn't a workaround — it's a working architecture that keeps the app honest with its users: it either answers meaningfully on-device, or it tells you, through diagnostics, exactly why it didn't today.



