O framework Foundation Models da Apple (macOS 26) expõe um LLM de ~3B parâmetros que roda on-device, de graça, sem necessidade de API key. Mas tem um problema: ele só trabalha com Swift. E não qualquer Swift — Swift async.
Seu tooling está em Python? Não tem binding. Em Rust? Também não. Um script de shell? Sem chance. O LLM mais barato do mundo (literalmente gratuito) está preso atrás de duas barreiras: o idioma e o modelo de concorrência.
A solução óbvia seria rodar um servidor HTTP local que expõe o modelo como uma API REST, tipo Ollama. Mas isso é como usar um canhão para matar uma mosca. Um processo rodando, uma porta ocupada, JSON indo e voltando, um curl para cada pergunta trivial. Para classificar um commit como fix ou feat, você não precisa de HTTP. Você precisa de uma função C.
A dylib de 4 funções
Por isso criei libfoundationmodels: uma biblioteca dinâmica que compila o framework da Apple em uma .dylib que exporta exatamente 4 funções 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);
Bem, 5 se você contar fm_generate_json, mas a ideia permanece a mesma. Quatro conceitos: inicializar, verificar disponibilidade, gerar texto livre e classificar forçando uma resposta entre opções. Tudo síncrono. Tudo bloqueante. Buffer de entrada, buffer de saída, código de retorno. C de sempre.
Usar isso a partir de Python é assim:
import ctypes
fm = ctypes.CDLL("libfoundationmodels.dylib")
fm.fm_init()
buf = ctypes.create_string_buffer(256)
fm.fm_classify(
None, # sem system prompt
b"fix: handle nil response in OAuth", # o texto para classificar
b"fix\nfeat\nrefactor\ntest\ndocs", # as opções
buf, 256
)
print(buf.value.decode()) # "fix"
E pronto. Sem requests. Sem urllib. Sem JSON. Sem servidor rodando. Uma chamada a uma função C que, internamente, acorda o Neural Engine do seu Mac, passa o texto para ele e te devolve a resposta em um buffer. Latência típica: 200-800ms.
O problema interessante: async Swift em uma API síncrona C
Aqui começa a parte divertida. O framework Foundation Models é async:
let result = try await session.respond(to: prompt)
Esse await é o problema. C não sabe o que é await. C não tem structured concurrency. C tem funções que entram por cima, fazem algo e saem por baixo. Ponto final.
Você precisa de uma ponte que converta uma chamada async em uma chamada bloqueante. Ou seja: a thread de C precisa ficar parada esperando até que o async em Swift termine.
A solução clássica é usar um semáforo. Mas em Swift 6, com strict concurrency ativada, usar DispatchSemaphore dentro de código async é território perigoso. O compilador vai te dar um olhar zangado porque um semáforo pode bloquear uma thread do cooperative thread pool, exatamente o que Swift 6 tenta evitar.
Veja como resolvemos isso:
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 }
}
Há três peças trabalhando juntas:
Mutex<T?>(do frameworkSynchronizationde Swift, disponível desde macOS 15). É um lock que protege o valor de retorno. Por que não apenas uma simplesvar? Porque oTaskescreve a partir de uma thread e osemaphore.wait()lê de outra. Sem o mutex, você teria um data race. Swift 6 acusaria isso imediatamente.DispatchSemaphore. O mecanismo de sinalização: a thread de C se bloqueia em.wait()e oTaskfaz.signal()quando termina. Aqui o truque é que o semáforo bloqueia a thread que chama (a de C), não uma thread do cooperative pool. OTaskroda livremente em sua própria thread cooperativa.@_cdecl. O atributo que informa ao compilador Swift: “exporte esta função com name mangling de C”. Graças a isso,fm_generateaparece na tabela de símbolos da.dylibcomo uma função C normal, invocável a partir de qualquer linguagem.
A combinação é elegante porque cada peça resolve exatamente um problema: o mutex protege a memória compartilhada, o semáforo sincroniza as threads e @_cdecl expõe a interface. Não há mágica — apenas uma boa engenharia.
Por que funciona (e por que não é um hack)
O argumento contra usar DispatchSemaphore em código Swift moderno é válido: se você bloquear uma thread do cooperative thread pool, pode provocar um deadlock porque o pool tem um número fixo de threads. Se todas estiverem bloqueadas esperando semáforos, nenhuma executará os Task que fariam .signal().
Mas neste caso, o .wait() é feito pela thread de C — uma thread externa que não pertence ao cooperative pool. O Task executa no pool de Swift, faz seu trabalho async e sinaliza. Não há risco de starvation do pool porque a thread bloqueada não faz parte do pool.
É como um garçom (a thread de C) que pede um prato na cozinha (o Task async) e fica esperando no balcão. A cozinha tem seus próprios cozinheiros (o thread pool) e nunca se bloqueia por causa de um garçom esperando do lado de fora. O garçom não está ocupando um fogão.
@_cdecl: o atributo que ninguém documenta
Sobre @_cdecl. Repare no sublinhado: @_cdecl, não @cdecl. O sublinhado significa “atributo interno, não estável, pode mudar sem aviso”. Ele existe assim desde Swift 2 e é a forma padrão de facto de exportar funções Swift para C.
O Swift Evolution aprovou a proposta SE-0495 para formalizar @cdecl (sem sublinhado) como parte da linguagem. No Swift 6.2 já há suporte inicial, e no 6.3 ele será estabilizado junto com uma nova família de atributos @c para interop C/C++.
Significa que @_cdecl vai parar de funcionar? Não a curto prazo. Mas se você está construindo algo que pretende manter, migre para @cdecl assim que sua toolchain o suportar. O comportamento é o mesmo; só muda o nome.
Classificação forçada: o truque dos choices
A função mais útil da biblioteca não é fm_generate (texto livre), mas sim fm_classify:
fm_classify(
"You classify git commit messages.", // system prompt
"fix: handle nil in OAuth refresh", // texto para classificar
"fix\nfeat\nrefactor\ntest\ndocs\nchore", // opções válidas
buffer, sizeof(buffer)
);
Internamente, fm_classify faz algo que o framework da Apple não oferece diretamente a nível C: constrói um prompt que força o modelo a escolher uma das opções e valida a resposta:
let constrainedPrompt = """
\(promptStr)
You MUST reply with exactly one of these values, nothing else:
\(choiceList.joined(separator: "\n"))
"""
// Depois de gerar, validar:
if choiceList.contains(where: {
$0.caseInsensitiveCompare(raw) == .orderedSame
}) {
return raw
}
// Fallback: buscar a opção dentro da resposta
return choiceList.first {
raw.localizedCaseInsensitiveContains($0)
} ?? raw
Isso é constrained generation para pobres: você não usa o guided generation de @Generable (que exige definir um struct Swift), mas diz ao modelo “escolha uma destas” e depois verifica se ele fez isso. Se o modelo responder “The answer is fix” em vez de “fix”, o fallback detecta.
É tão robusto quanto @Generable? Não. Mas a partir de C você não pode definir um struct @Generable. E para classificação simples — que é o que cobre 80% dos casos de uso para tooling — funciona bem.
Smoke tests: 9 de 9
Os smoke tests são um arquivo C de 78 linhas que verifica:
- Que
fm_is_available()retorna 0 ou 1 (não lixo) - Que
fm_init()retorna 0 se o modelo estiver disponível - Que
fm_classifyretorna bytes positivos e um buffer não vazio - Que a classificação produz uma resposta razoável
- Que
fm_generateproduz texto - Que um buffer pequeno demais retorna -3 (truncado), sem crash
- Que
fm_generate_jsonproduz JSON válido
9 asserções, 9 passam. Em um Mac com Apple Intelligence ativado, make test leva cerca de 3 segundos. Se você não tem Apple Intelligence, os testes de geração são skipados e apenas verifica se fm_is_available() retorna 0. A biblioteca não faz crash em hardware sem suporte — apenas retorna -1.
Por que não um servidor HTTP?
Essa é a pergunta óbvia. Ollama, LM Studio, llama.cpp — todos expõem modelos como servidores HTTP locais. Por que uma dylib C é melhor?
| Servidor HTTP | dylib C | |
|---|---|---|
| Latência | ~10-50ms overhead (TCP + JSON parse) | ~0 (chamada de função) |
| Processo | Precisa de um daemon rodando | Carregado sob demanda |
| Dependências | Porta livre, cliente HTTP | Uma linha de ctypes/extern "C" |
| Integração | HTTP de qualquer linguagem | FFI de qualquer linguagem |
| Overhead de memória | Processo separado (~50-200MB) | Carregado no seu processo |
Para um serviço que atende múltiplos clientes concorrentes, um servidor HTTP faz sentido. Mas para tooling de desenvolvimento — um pre-commit hook, um script de CI local, uma barra de menu — você não precisa de um servidor. Você precisa de uma função que você chama, obtém uma resposta e que desaparece.
É a diferença entre instalar PostgreSQL para guardar uma lista de compras e usar SQLite. Às vezes, a solução simples é a certa.
Como usar isso no seu idioma
A biblioteca produz um arquivo: libfoundationmodels.dylib. Um header: foundationmodels.h. Qualquer linguagem com suporte FFI para C pode utilizá-la:
- Python:
ctypes.CDLL("libfoundationmodels.dylib") - Rust:
extern "C" { fn fm_classify(...) -> i32; } - Go:
// #cgo LDFLAGS: -lfoundationmodels+import "C" - Ruby:
FFI::Librarycomffi_lib "foundationmodels" - Node.js:
ffi-napiounode-ffi
O padrão é idêntico em todos: carregar a dylib, declarar as assinaturas, chamar a função, ler o buffer. Se você sabe usar ctypes em Python ou extern "C" em Rust, já sabe usar isso.
O que complementa (e o que não substitui)
Esta biblioteca não substitui Ollama nem llama.cpp. Não roda modelos arbitrários, não suporta LoRA, não tem streaming. É um wrapper minimalista para o modelo que a Apple já te deu, para o caso de uso específico de tooling onde você quer classificação rápida, geração curta ou JSON estruturado sem montar infraestrutura.
Se o post anterior foi “seu Mac tem um LLM gratuito e você não está usando”, este é “e agora você pode usá-lo a partir de qualquer linguagem, não só Swift”. A camada 1 da arquitetura de modelos por camadas acabou de se abrir para todo o ecossistema.
Quatro funções C. Uma dylib. Sem servidor. Sem API key. Sem dependências. Às vezes, a melhor ferramenta é a que não precisa de manual de instruções.
Experimente
git clone https://github.com/frr149/libfoundationmodels
cd libfoundationmodels
make test # 9 smoke tests (~3s)
make examples # exemplo em C
python3 examples/classify.py # exemplo em Python
Requisitos: Apple Silicon, macOS 26, Apple Intelligence ativado. Se você não tem macOS 26, os testes são skipados em vez de falhar.