Le framework Foundation Models d’Apple (macOS 26) expose un LLM d’environ 3 milliards de paramètres qui fonctionne directement sur l’appareil, gratuitement, sans clé API. Voici le problème : il ne parle que Swift. Et pas n’importe quel Swift — Swift async.
Votre outil tourne avec Python ? Aucun binding. Avec Rust ? Rien non plus. Avec un script shell ? Pas possible. Le LLM le moins cher du marché (gratos, littéralement) est enfermé derrière deux barrières : le langage et le modèle de concurrence.
La solution évidente serait de monter un serveur HTTP local pour exposer le modèle en tant qu’API REST, un peu comme Ollama. Mais utiliser un canon pour tuer une mouche ne semble pas optimal. Un processus en fonctionnement, un port occupé, du JSON aller-retour, et un curl pour chaque question triviale. Pour classifier un commit en fix ou feat, un serveur HTTP est excessif. Ce qu’il vous faut, c’est une fonction C.
La dylib en 4 fonctions
Alors, j’ai créé libfoundationmodels : une bibliothèque dynamique qui compile le framework d’Apple dans une .dylib et exporte exactement 4 fonctions C :
int32_t fm_init(void);
int32_t fm_is_available(void);
int32_t fm_generate(system, prompt, output, output_len);
int32_t fm_classify(system, prompt, choices, output, output_len);
---
Bon, 5 si on compte `fm_generate_json`, mais l'idée reste la même. Quatre concepts : initialiser, vérifier la disponibilité, générer du texte libre et classifier en forçant une réponse parmi des options. Tout est synchrone, tout est bloquant : buffer d'entrée, buffer de sortie, code de retour. Du vrai C, comme on l'aime.
Depuis Python, voici comment ça fonctionne :
```python
import ctypes
fm = ctypes.CDLL("libfoundationmodels.dylib")
fm.fm_init()
buf = ctypes.create_string_buffer(256)
fm.fm_classify(
None, # pas de system prompt
b"fix: handle nil response in OAuth", # texte à classifier
b"fix\nfeat\nrefactor\ntest\ndocs", # options possibles
buf, 256
)
print(buf.value.decode()) # "fix"
C’est tout. Pas de requests, pas de urllib, pas de JSON. Pas de serveur en fonctionnement. Une simple fonction C qui réveille le Neural Engine de votre Mac, lui passe le texte, et vous retourne la réponse dans un buffer. Latence typique : 200-800 ms.
Le problème intéressant : Swift async vers API C synchrone
Et maintenant, la partie amusante. Le framework Foundation Models est async :
let result = try await session.respond(to: prompt)
Ce await pose problème. C ne connaît pas await. C n’a pas de structured concurrency. C offre des fonctions qui entrent par le haut, font quelque chose, et ressortent par le bas. Point final.
Il vous faut un bridge qui convertisse un appel async en un appel bloquant. En clair : le thread de C doit rester en attente jusqu’à ce que l’async de Swift se termine.
La solution classique, c’est un sémaphore. Mais dans Swift 6, avec la strict concurrency activée, utiliser DispatchSemaphore dans du code async est risqué. Le compilateur vous regardera de travers, car un sémaphore peut bloquer un thread du cooperative thread pool, ce que Swift 6 cherche précisément à éviter.
Voici la solution :
private func blockingCall<T: Sendable>(
_ body: @Sendable @escaping () async -> T?
) -> T? {
let box = Mutex<T?>(nil)
let semaphore = DispatchSemaphore(value: 0)
Task {
let value = await body()
box.withLock { $0 = value }
semaphore.signal()
}
semaphore.wait()
return box.withLock { $0 }
}
Trois éléments travaillent ensemble :
Mutex<T?>(du frameworkSynchronizationde Swift, disponible depuis macOS 15). Un lock protège la valeur de retour. Pourquoi pas simplement unvar? Parce que leTaskécrit depuis un thread et lesemaphore.wait()lit depuis un autre. Sans mutex, ça provoquerait un data race. Swift 6 vous le signalerait immédiatement.DispatchSemaphore. Le mécanisme de signalisation : le thread C se bloque sur.wait(), et leTaskappelle.signal()lorsqu’il se termine. Le point clé ici est que le sémaphore bloque uniquement le thread appelant (celui de C), pas un thread du cooperative pool. LeTasks’exécute librement dans son propre thread coopératif.@_cdecl. L’attribut indique au compilateur Swift : “exporte cette fonction avec du name mangling conforme à C”. Grâce à cela,fm_generateapparaît dans la table de symboles de la.dylibcomme une fonction C normale, appelable depuis n’importe quel langage.
La combinaison est élégante parce que chaque pièce résout exactement un problème : le mutex protège la mémoire partagée, le sémaphore synchronise les threads, et @_cdecl expose l’interface. Aucun sortilège — juste de la plomberie propre.
Pourquoi ça fonctionne (et pourquoi ce n’est pas un hack)
L’argument contre DispatchSemaphore dans du code Swift moderne est valide : si vous bloquez un thread du cooperative thread pool, vous pourriez provoquer un deadlock, car le pool a un nombre fixe de threads. Si tous sont bloqués sur des sémaphores, aucun ne peut exécuter les Task qui déclencheraient un .signal().
Mais ici, le .wait() est exécuté par le thread de C — un thread externe qui n’appartient pas au cooperative pool. Le Task s’exécute dans le pool Swift, termine son travail async, puis signalise. Il n’y a donc pas de risque de starvation dans le pool, car le thread bloqué ne fait pas partie du pool.
C’est comme un serveur (le thread de C) qui commande un plat en cuisine (le Task async) et attend au bar. La cuisine a ses propres chefs (le thread pool) et continue de fonctionner sans être bloquée par le serveur qui attend dehors. Le serveur n’empêche pas les cuisiniers d’utiliser leurs équipements.
@_cdecl : l’attribut peu documenté
Petit aperçu de @_cdecl. Remarquez le tiret bas : @_cdecl, et non @cdecl. Le tiret bas indique “attribut interne, instable, susceptible de changer sans avertissement”. Il est comme ça depuis Swift 2 et constitue la méthode standard de facto pour exposer des fonctions Swift en C.
Swift Evolution a approuvé la proposition SE-0495 pour formaliser @cdecl (sans tiret bas) comme partie intégrante du langage. Swift 6.2 inclut déjà un support initial, et cette fonctionnalité sera stabilisée dans Swift 6.3 avec une nouvelle famille d’attributs @c pour l’interopérabilité C/C++.
Cela signifie-t-il que @_cdecl cessera de fonctionner ? Pas à court terme. Mais si vous construisez quelque chose que vous souhaitez pérenniser, migrez vers @cdecl dès que votre outil de compilation le permet. Son fonctionnement est identique, seul le nom change.
Classification forcée : le truc des choices
La fonction la plus utile de la bibliothèque n’est pas fm_generate (texte libre). C’est fm_classify :
fm_classify(
"You classify git commit messages.", // system prompt
"fix: handle nil in OAuth refresh", // texte à classifier
"fix\nfeat\nrefactor\ntest\ndocs\nchore", // options valides
buffer, sizeof(buffer)
);
En interne, fm_classify fait quelque chose que le framework d’Apple n’offre pas directement au niveau C : il construit un prompt qui oblige le modèle à choisir parmi les options et valide ensuite la réponse :
let constrainedPrompt = """
\(promptStr)
You MUST reply with exactly one of these values, nothing else:
\(choiceList.joined(separator: "\n"))
"""
// Après génération, validation :
if choiceList.contains(where: {
$0.caseInsensitiveCompare(raw) == .orderedSame
}) {
return raw
}
// Fallback : chercher l'option dans la réponse
return choiceList.first {
raw.localizedCaseInsensitiveContains($0)
} ?? raw
C’est une génération constrainée pour les modestes : au lieu d’utiliser la génération guidée de @Generable (qui nécessite de définir une struct Swift), vous dites au modèle “choisis parmi celles-ci”, puis vous vérifiez qu’il obéit. Si le modèle répond “The answer is fix” au lieu de “fix”, le fallback le détecte.
Est-ce aussi robuste que @Generable ? Non. Mais depuis C, vous ne pouvez pas définir une struct @Generable. Et pour la classification simple — ce qui représente 80 % des cas d’usage dans les outils — ça fonctionne.
Tests simples : 9 sur 9
Les smoke tests constituent un fichier C de 78 lignes qui vérifie :
- Que
fm_is_available()retourne 0 ou 1 (pas du bruit) - Que
fm_init()retourne 0 si le modèle est disponible - Que
fm_classifyretourne des bytes positifs et un buffer non vide - Que la classification génère une réponse correcte
- Que
fm_generateproduit du texte - Qu’un buffer trop petit retourne -3 (tronqué), pas de crash
- Que
fm_generate_jsonproduit un JSON valide
Neuf assertions, neuf réussites. Sur un Mac avec Apple Intelligence activé, make test prend environ 3 secondes. Si vous n’avez pas Apple Intelligence, les tests de génération sont ignorés, et seuls ceux de fm_is_available() vérifient qu’ils retournent 0. La bibliothèque ne plante pas sur du matériel non compatible — elle retourne simplement -1.
Pourquoi pas un serveur HTTP ?
La question évidente. Ollama, LM Studio, llama.cpp — tous exposent des modèles en tant que serveurs HTTP locaux. Pourquoi une dylib C serait-elle meilleure ?
| Serveur HTTP | dylib C | |
|---|---|---|
| Latence | ~10-50ms de surcharge (TCP + JSON parse) | ~0 (appel fonction) |
| Processus | Nécessite un daemon actif | Chargement à la demande |
| Dépendances | Port libre, client HTTP | Une ligne ctypes/extern "C" |
| Intégration | HTTP depuis n’importe quel langage | FFI depuis n’importe quel langage |
| Surcharge mémoire | Processus séparé (~50-200MB) | Chargé directement dans le processus |
Pour un service qui gère des clients multiples en concurrence, un serveur HTTP est pertinent. Mais pour des outils de développement — un hook de pré-commit, un script de CI local, une barre de menu — vous n’avez pas besoin d’un serveur. Ce qu’il vous faut, c’est une fonction que vous appelez, qui vous retourne une réponse, et qui disparaît.
C’est la même différence qu’entre installer PostgreSQL pour une simple liste de courses et utiliser SQLite. Parfois, la solution la plus simple est la meilleure.
Comment l’utiliser avec votre langage
La bibliothèque produit un fichier : libfoundationmodels.dylib, et un header : foundationmodels.h. Tout langage supportant le FFI pour C peut l’utiliser :
- Python :
ctypes.CDLL("libfoundationmodels.dylib") - Rust :
extern "C" { fn fm_classify(...) -> i32; } - Go :
// #cgo LDFLAGS: -lfoundationmodels+import "C" - Ruby :
FFI::Libraryavecffi_lib "foundationmodels" - Node.js :
ffi-napiounode-ffi
Le modèle est identique partout : charger la dylib, déclarer les signatures, appeler la fonction, lire le buffer. Si vous maîtrisez ctypes en Python ou extern "C" en Rust, vous avez déjà tout ce qu’il faut.
Ce que cela complète (et ce que cela ne remplace pas)
Cette bibliothèque ne remplace pas Ollama ou llama.cpp. Elle ne permet pas d’exécuter des modèles arbitraires, ne supporte pas les LoRA, et n’offre pas de streaming. C’est un wrapper minimaliste pour le modèle donné par Apple, adapté spécifiquement aux usages tooling où vous souhaitez une classification rapide, une génération courte, ou du JSON structuré sans besoin d’infrastructure.
Si le précédent poste disait “votre Mac possède un LLM gratuit et vous ne l’utilisez pas”, celui-ci dit “et maintenant, vous pouvez l’utiliser depuis n’importe quel langage, pas seulement Swift”. La couche 1 de l’architecture des modèles par couches est désormais ouverte à tout l’écosystème.
Quatre fonctions C. Une dylib. Pas de serveur. Pas de clé API. Pas de dépendance. Parfois, les meilleurs outils sont ceux qui n’ont pas besoin de notice.
Essayez-le
git clone https://github.com/frr149/libfoundationmodels
cd libfoundationmodels
make test # 9 tests simples (~3s)
make examples # exemple en C
python3 examples/classify.py # exemple en Python
Prérequis : Apple Silicon, macOS 26, Apple Intelligence activé. Si vous n’avez pas macOS 26, les tests seront ignorés, et non échoués.