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 :

  1. Mutex<T?> (du framework Synchronization de Swift, disponible depuis macOS 15). Un lock protège la valeur de retour. Pourquoi pas simplement un var ? Parce que le Task écrit depuis un thread et le semaphore.wait() lit depuis un autre. Sans mutex, ça provoquerait un data race. Swift 6 vous le signalerait immédiatement.

  2. DispatchSemaphore. Le mécanisme de signalisation : le thread C se bloque sur .wait(), et le Task appelle .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. Le Task s’exécute librement dans son propre thread coopératif.

  3. @_cdecl. L’attribut indique au compilateur Swift : “exporte cette fonction avec du name mangling conforme à C”. Grâce à cela, fm_generate apparaît dans la table de symboles de la .dylib comme 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 :

  1. Que fm_is_available() retourne 0 ou 1 (pas du bruit)
  2. Que fm_init() retourne 0 si le modèle est disponible
  3. Que fm_classify retourne des bytes positifs et un buffer non vide
  4. Que la classification génère une réponse correcte
  5. Que fm_generate produit du texte
  6. Qu’un buffer trop petit retourne -3 (tronqué), pas de crash
  7. Que fm_generate_json produit 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 HTTPdylib C
Latence~10-50ms de surcharge (TCP + JSON parse)~0 (appel fonction)
ProcessusNécessite un daemon actifChargement à la demande
DépendancesPort libre, client HTTPUne ligne ctypes/extern "C"
IntégrationHTTP depuis n’importe quel langageFFI depuis n’importe quel langage
Surcharge mémoireProcessus 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::Library avec ffi_lib "foundationmodels"
  • Node.js : ffi-napi ou node-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.