El framework de Foundation Models de Apple (macOS 26) expone un LLM de ~3B parámetros que corre on-device, gratis, sin API key. Hay un problema: solo habla Swift. Y no cualquier Swift — Swift async.
¿Tu tooling está en Python? No hay binding. ¿En Rust? Tampoco. ¿Un script de shell? Ni opción. El LLM más barato del mundo (literalmente gratis) está encerrado detrás de dos barreras: el lenguaje y el modelo de concurrencia.
La solución obvia es montar un servidor HTTP local que exponga el modelo como una API REST, tipo Ollama. Pero eso es usar un cañón para matar una mosca. Un proceso corriendo, un puerto ocupado, JSON de ida y vuelta, un curl por cada pregunta trivial. Para clasificar un commit como fix o feat, no necesitas HTTP. Necesitas una función C.
La dylib de 4 funciones
Así que hice libfoundationmodels: una librería dinámica que compila el framework de Apple en una .dylib que exporta exactamente 4 funciones 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);
Bueno, 5 si cuentas fm_generate_json, pero la idea es la misma. Cuatro conceptos: inicializar, comprobar disponibilidad, generar texto libre, y clasificar forzando una respuesta entre opciones. Todo síncrono. Todo bloqueante. Buffer de entrada, buffer de salida, código de retorno. C de toda la vida.
Desde Python, usarlo es esto:
import ctypes
fm = ctypes.CDLL("libfoundationmodels.dylib")
fm.fm_init()
buf = ctypes.create_string_buffer(256)
fm.fm_classify(
None, # sin system prompt
b"fix: handle nil response in OAuth", # el texto a clasificar
b"fix\nfeat\nrefactor\ntest\ndocs", # las opciones
buf, 256
)
print(buf.value.decode()) # "fix"
Eso es todo. Sin requests. Sin urllib. Sin JSON. Sin servidor corriendo. Una llamada a una función C que internamente despierta el Neural Engine de tu Mac, le pasa el texto, y te devuelve la respuesta en un buffer. Latencia típica: 200-800ms.
El problema interesante: async Swift en una API síncrona C
Aquí viene la parte divertida. El framework de Foundation Models es async:
let result = try await session.respond(to: prompt)
Ese await es el problema. C no sabe qué es await. C no tiene structured concurrency. C tiene funciones que entran por arriba, hacen algo, y salen por abajo. Punto.
Necesitas un bridge que convierta una llamada async en una llamada bloqueante. Es decir: el hilo de C tiene que quedarse parado esperando hasta que el async de Swift termine.
La solución clásica es un semáforo. Pero en Swift 6, con strict concurrency activado, usar un DispatchSemaphore dentro de código async es territorio peligroso. El compilador te va a mirar con cara de pocos amigos porque un semáforo puede bloquear un hilo del cooperative thread pool, y eso es exactamente lo que Swift 6 intenta prevenir.
Veamos cómo se resuelve:
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 }
}
Hay tres piezas que trabajan juntas:
-
Mutex<T?>(del frameworkSynchronizationde Swift, disponible desde macOS 15). Es un lock que protege el valor de retorno. ¿Por qué no un simplevar? Porque elTaskescribe desde un hilo y elsemaphore.wait()lee desde otro. Sin el mutex, tienes un data race. Swift 6 te lo cantaría en la cara. -
DispatchSemaphore. El mecanismo de señalización: el hilo de C se bloquea en.wait(), y elTaskhace.signal()cuando termina. Aquí el truco es que el semáforo bloquea el hilo que llama (el de C), no un hilo del cooperative pool. ElTaskcorre libre en su propio hilo cooperativo. -
@_cdecl. El atributo que le dice al compilador de Swift: «exporta esta función con name mangling de C». Gracias a esto,fm_generateaparece en la tabla de símbolos de la.dylibcomo una función C normal, invocable desde cualquier lenguaje.
La combinación es elegante porque cada pieza resuelve exactamente un problema: el mutex protege la memoria compartida, el semáforo sincroniza los hilos, y @_cdecl expone la interfaz. No hay magia — solo fontanería bien encajada.
Por qué funciona (y por qué no es un hack)
El argumento en contra de usar DispatchSemaphore en código Swift moderno es legítimo: si bloqueas un hilo del cooperative thread pool, puedes provocar un deadlock porque el pool tiene un número fijo de hilos. Si todos están bloqueados esperando semáforos, nadie puede ejecutar los Task que harían .signal().
Pero en este caso, el .wait() lo hace el hilo de C — un hilo externo que no pertenece al cooperative pool. El Task se ejecuta en el pool de Swift, hace su trabajo async, y señaliza. No hay riesgo de starvation del pool porque el hilo bloqueado no es del pool.
Es como un camarero (el hilo de C) que pide un plato a la cocina (el Task async) y se queda esperando en la barra. La cocina tiene sus propios cocineros (el thread pool) y nunca se bloquea porque un camarero esté esperando fuera. El camarero no está ocupando un fogón.
@_cdecl: el atributo que nadie documenta
Un apunte sobre @_cdecl. Nótese el guion bajo: @_cdecl, no @cdecl. El guion bajo significa «atributo interno, no estable, puede cambiar sin aviso». Lleva así desde Swift 2, y es la forma estándar de facto de exportar funciones Swift a C.
Swift Evolution aprobó la propuesta SE-0495 para formalizar @cdecl (sin guion bajo) como parte del lenguaje. En Swift 6.2 ya está el soporte inicial, y en 6.3 se estabiliza junto con una nueva familia de atributos @c para interop C/C++.
¿Significa que @_cdecl va a dejar de funcionar? No a corto plazo. Pero si estás construyendo algo que quieras mantener, migra a @cdecl en cuanto tu toolchain lo soporte. El comportamiento es el mismo; solo cambia el nombre.
Clasificación forzada: el truco de los choices
La función más útil de la librería no es fm_generate (texto libre). Es fm_classify:
fm_classify(
"You classify git commit messages.", // system prompt
"fix: handle nil in OAuth refresh", // texto a clasificar
"fix\nfeat\nrefactor\ntest\ndocs\nchore", // opciones válidas
buffer, sizeof(buffer)
);
Internamente, fm_classify hace algo que el framework de Apple no ofrece directamente a nivel C: construye un prompt que fuerza al modelo a elegir una de las opciones, y luego valida la respuesta:
let constrainedPrompt = """
\(promptStr)
You MUST reply with exactly one of these values, nothing else:
\(choiceList.joined(separator: "\n"))
"""
// Después de generar, validar:
if choiceList.contains(where: {
$0.caseInsensitiveCompare(raw) == .orderedSame
}) {
return raw
}
// Fallback: buscar la opción dentro de la respuesta
return choiceList.first {
raw.localizedCaseInsensitiveContains($0)
} ?? raw
Esto es constrained generation para pobres: no usas el guided generation de @Generable (que requiere definir un struct Swift), sino que le dices al modelo «elige una de estas» y luego verificas que lo hizo. Si el modelo responde «The answer is fix» en vez de «fix», el fallback lo detecta.
¿Es tan robusto como @Generable? No. Pero desde C no puedes definir un struct @Generable. Y para clasificación simple — que es el 80% de los casos de uso de tooling — funciona.
Smoke tests: 9 de 9
Los smoke tests son un fichero C de 78 líneas que verifica:
- Que
fm_is_available()devuelve 0 o 1 (no basura) - Que
fm_init()retorna 0 si el modelo está disponible - Que
fm_classifydevuelve bytes positivos y un buffer no vacío - Que la clasificación produce una respuesta razonable
- Que
fm_generateproduce texto - Que un buffer demasiado pequeño devuelve -3 (truncado), no un crash
- Que
fm_generate_jsonproduce JSON válido
9 aserciones, 9 pasan. En un Mac con Apple Intelligence activado, make test tarda unos 3 segundos. Si no tienes Apple Intelligence, los tests de generación se skipean y solo se verifica que fm_is_available() devuelve 0. La librería no crashea en hardware sin soporte — simplemente devuelve -1.
¿Por qué no un servidor HTTP?
Es la pregunta obvia. Ollama, LM Studio, llama.cpp — todos exponen modelos como servidores HTTP locales. ¿Por qué una dylib C es mejor?
| Servidor HTTP | dylib C | |
|---|---|---|
| Latencia | ~10-50ms overhead (TCP + JSON parse) | ~0 (llamada a función) |
| Proceso | Necesita un daemon corriendo | Se carga bajo demanda |
| Dependencias | Puerto libre, HTTP client | Una línea de ctypes/extern "C" |
| Integración | HTTP desde cualquier lenguaje | FFI desde cualquier lenguaje |
| Overhead memoria | Proceso separado (~50-200MB) | Se carga en tu proceso |
Para un servicio que atiende múltiples clientes concurrentes, un servidor HTTP tiene sentido. Pero para tooling de desarrollo — un pre-commit hook, un script de CI local, una barra de menú — no necesitas un servidor. Necesitas una función que llamas, que te devuelve una respuesta, y que desaparece.
Es la diferencia entre instalar PostgreSQL para guardar una lista de la compra y usar SQLite. A veces la solución sencilla es la correcta.
Cómo usarlo en tu lenguaje
La librería produce un fichero: libfoundationmodels.dylib. Un header: foundationmodels.h. Cualquier lenguaje con soporte FFI para C puede usarla:
- Python:
ctypes.CDLL("libfoundationmodels.dylib") - Rust:
extern "C" { fn fm_classify(...) -> i32; } - Go:
// #cgo LDFLAGS: -lfoundationmodels+import "C" - Ruby:
FFI::Libraryconffi_lib "foundationmodels" - Node.js:
ffi-napionode-ffi
El patrón es idéntico en todos: cargar la dylib, declarar las firmas, llamar a la función, leer el buffer. Si sabes usar ctypes en Python o extern "C" en Rust, ya sabes usar esto.
Lo que complementa (y lo que no reemplaza)
Esta librería no reemplaza a Ollama ni a llama.cpp. No ejecuta modelos arbitrarios, no soporta LoRA, no tiene streaming. Es un wrapper minimalista para el modelo que Apple ya te dio, para el caso de uso específico de tooling donde quieres clasificación rápida, generación corta, o JSON estructurado sin montar infraestructura.
Si el post anterior era «tu Mac tiene un LLM gratis y no lo usas», este es «y ahora lo puedes usar desde cualquier lenguaje, no solo Swift». La capa 1 de la arquitectura de modelos por capas acaba de abrirse a todo el ecosistema.
Cuatro funciones C. Una dylib. Sin servidor. Sin API key. Sin dependencias. A veces la mejor herramienta es la que no necesita manual de instrucciones.
Pruébalo
git clone https://github.com/frr/libfoundationmodels
cd libfoundationmodels
make test # 9 smoke tests (~3s)
make examples # ejemplo en C
python3 examples/classify.py # ejemplo en Python
Requisitos: Apple Silicon, macOS 26, Apple Intelligence activado. Si no tienes macOS 26, los tests se skipean en vez de fallar.
Read this article in English.



