If you already have Python code that talks to a local model through
openai.OpenAI, there is a second way to run that model: import it directly.
The llama-cpp-python package binds llama.cpp into your own process, so the
weights load inside your program and generation is a function call rather than
an HTTP request.
By the end of this article you will have taken a working OpenAI-SDK client and
rewritten it around the Llama constructor. No server to start, no base_url,
no API key, and no port to keep free. The finished module is about forty lines
and comes with a test suite that runs without loading a model at all.
This is a porting article. It covers the swap and what the swap changes, and deliberately stops there — the bindings expose a great deal more (custom sampling, token probabilities, direct access to the context), and none of it is needed to move a script across.
Versions used throughout: llama-cpp-python 0.3.35 on macOS 26.6.2
(arm64, Apple M5 Max), uv 0.11.26, Python 3.12.9, with the same
ggml-org/Qwen3.5-0.8B-GGUF:Q8_0 model as
Getting Started with llama.cpp on macOS.
What you end up with
A package called local-llm-direct with:
load()— builds aLlamafrom either a file path or a Hugging Face repo id.ask()— sends one chat turn and returns a smallReplyvalue.- A
pytestsuite that exercises the parsing with a stub, plus one live test that actually loads the weights. - A
Makefilewhose baremakeprints the targets.
Prerequisites
- macOS on Apple Silicon. Written and validated on macOS 26.6.2, arm64. Metal, Apple’s GPU programming API, is compiled in automatically; you do not pass a flag for it.
- The Xcode Command Line Tools. Installing the package compiles C++, so a
working toolchain is required —
xcode-select -pshould print a path. If it does not, runxcode-select --install. - uv 0.11.26 or newer —
uv --version. - A GGUF model. A GGUF is the single-file format llama.cpp loads weights
from. This article uses
ggml-org/Qwen3.5-0.8B-GGUF:Q8_0, which is 795 MiB. You do not need to download it separately (Step 4 pulls it), but if you followed Getting Started with llama.cpp on macOS it is already cached and will be reused. - Optional, for the comparison: the client from Serve a Local OpenAI-Compatible Endpoint with llama.cpp on macOS. This article rewrites that article’s client, so having it open makes the diff concrete. Nothing here depends on it.
Step 1: Create the project and its .gitignore
Start with the ignore file, before there is anything to ignore. A local-model project accumulates two things git should never see: multi-hundred-megabyte weight files, and a virtual environment holding a compiled copy of llama.cpp. Writing the rules first means neither ever reaches the index.
Create the file
mkdir -p ~/projects/local-llm-direct
cd ~/projects/local-llm-direct
touch .gitignore
Add the code: .gitignore
# Python
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
# Model weights — never commit these
*.gguf
models/
# Local overrides
.env
Detailed breakdown
*.ggufandmodels/are the entries that matter most here. The model in this article is 795 MiB, and git keeps a committed blob forever even after a later commit deletes it. The default cache lives outside the project, but amodel_path=experiment that copies weights alongside the code is an easy mistake to make once..venv/matters here for a reason specific to this package. In most projects the virtual environment is just downloaded wheels; here it also contains the llama.cpp shared libraries built during install. It is regenerated from the lockfile, so it carries nothing worth sharing, but it is considerably larger than usual..envis habit rather than necessity. There is no API key in this project (that is rather the point), but the environment overrides in Step 5 are the kind of thing that ends up in a.envlater.
Step 2: Initialize the project with uv
With the ignore rules in place, create the package skeleton. uv init --package
produces a src/ layout and a pyproject.toml, which is what lets the tests
import the module by name rather than by relative path.
Create the file
cd ~/projects/local-llm-direct
uv init --package --name local-llm-direct
uv python pin 3.12
Detailed breakdown
uv init --packagecreatessrc/local_llm_direct/__init__.pyand apyproject.tomldeclaring the project. The hyphenated project name becomes an underscored module name.uv python pin 3.12writes a.python-versionfile. This is not ceremony:llama-cpp-pythoncompiles against the Python it is installed for, and pinning means a lateruv syncon a machine with a newer system Python rebuilds against the same target instead of silently choosing another.
Step 3: Install the bindings, and watch it compile
This is the step that behaves unlike a normal dependency, so it gets its own place in the article rather than a mention in the prerequisites.
PyPI ships llama-cpp-python as a source distribution only — a .tar.gz of
source code rather than a prebuilt .whl binary. There is no wheel to download
for any platform. Installing it therefore runs CMake and compiles llama.cpp on
your machine the first time you ask for it, which is why the Xcode Command Line
Tools are a prerequisite and why that first install takes visibly longer than
the ones around it.
Create the file
cd ~/projects/local-llm-direct
uv add llama-cpp-python huggingface-hub
uv add --dev pytest
Detailed breakdown
- The compile is quick, despite the reputation. On the test machine the
first
uv add llama-cpp-pythontook 23.94 seconds of wall time at 974% CPU — CMake parallelizes across cores. Expect longer on fewer cores, but not the ordeal that “compiles from source” usually implies. - You pay that once per uv cache, not once per project. uv keeps the wheel it builds locally (distinct from the published wheel, which does not exist), so a second project asking for the same version reuses it. Installing this same version into a second project on the test machine resolved in 0.21 seconds, with no compiler involved. If you see a long build a second time, the version changed, the cache was cleared, or you are a different user on the same machine.
- You do not need
CMAKE_ARGSfor Metal. Guides written for other platforms tell you to pass-DGGML_METAL=on. On Apple Silicon it is already the default; the build picks up Metal, and the loaded library reportsMTL : EMBED_LIBRARY = 1. Setting the flag by hand changes nothing. huggingface-hubis a second, separate dependency. It is not pulled in automatically, and it is whatLlama.from_pretrained()in Step 4 needs to resolve a repo id. Install it now and that method works; skip it and you get aModuleNotFoundErrorat the point of use.- The compiled libraries land in
site-packages/lib/as well as insite-packages/llama_cpp/lib/—libllama.dylib,libggml-metal.dyliband friends, installed to both places as separate copies. Useful when working out which llama.cpp a given environment is actually running.
What version of llama.cpp did you just build?
The package vendors its own copy of llama.cpp, pinned to whatever the release
was cut against. That copy is independent of any llama binary Homebrew
installed, and it is usually older. Check that the vendored build recognizes
the model you intend to load, because an unsupported architecture fails at load
time with an unhelpful message.
Create the file
cd ~/projects/local-llm-direct
strings .venv/lib/python3.12/site-packages/lib/libllama.dylib | grep -cx qwen35
Detailed breakdown
- The command prints
1, meaning this build registersqwen35— the architecture name the Qwen3.5 family declares in its GGUF metadata. The model in this article will load. - Substitute your own architecture if you are pointing this at different
weights. The value comes from the GGUF’s
general.architecturekey, and a0here means the vendored llama.cpp predates support for that family. The fix is a newerllama-cpp-python, not a newer Homebrewllama, because the two do not share a library. grep -cxmatches the whole line exactly and counts, so a substring likeqwen35moedoes not produce a false positive.
Step 4: Swap the constructor
Here is the actual subject of the article. The OpenAI client is constructed
around a network location; the Llama object is constructed around a file.
Everything else in this step follows from that one difference.
The client from Serve a Local OpenAI-Compatible
Endpoint connects like this, with a
llama serve process already running in another terminal:
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8080/v1",
api_key="no-key-required",
)
The in-process equivalent takes no URL and no key, because there is nothing to authenticate to:
Create the file
cd ~/projects/local-llm-direct
touch first_run.py
Add the code: first_run.py
"""Smallest possible in-process generation, to prove the install works."""
from llama_cpp import Llama
llm = Llama.from_pretrained(
repo_id="ggml-org/Qwen3.5-0.8B-GGUF",
filename="Qwen3.5-0.8B-Q8_0.gguf",
n_ctx=2048,
n_gpu_layers=-1,
verbose=False,
)
out = llm.create_chat_completion(
messages=[{"role": "user", "content": "Name the capital of France. One word."}],
max_tokens=32,
temperature=0.0,
)
print(repr(out["choices"][0]["message"]["content"]))
Detailed breakdown
from_pretrainedtakes the place ofbase_url. It resolves a Hugging Face repo id and filename to a local file, downloading it once and caching it. It reuses the same cachellama download -hffills, so if you followed the getting-started article the 795 MiB is already on disk and nothing is re-fetched. Measured on the test machine, the firstfrom_pretrainedin a process takes 0.62 seconds and later ones 0.25 seconds, the difference being the Hugging Face metadata round trip; the cache stayed at a single copy throughout.n_gpu_layers=-1means “offload every layer to the GPU.” This is the in-process spelling of llama.cpp’s-nglflag. The default is0, which is CPU-only and much slower, so this is the one argument you should not leave out on Apple Silicon.n_ctxsets the context window: the number of tokens of prompt plus reply the model can hold at once. Unlike the server’s-cit is not a pool divided between parallel slots. There is one context here, and it is yours. The server article’s warning that-c 8192 -np 2gives each request 4096 has no counterpart in this route.verbose=Falsesilences llama.cpp’s own logging, and thoroughly: with it set, llama.cpp writes zero bytes to stderr during a load, as does a bareimport llama_cpp. It does not muzzle other libraries, which is why thehuggingface_hubwarning in the run below still gets through. Set it toTruewhen you want the Metal device report and the per-tensor offload table.- The reply is a plain dictionary.
out["choices"][0]["message"]["content"]is the direct translation of the SDK’sresp.choices[0].message.content. This is the change most likely to break ported code, because attribute access on a dict raisesAttributeErrorsomewhere well downstream of the call that produced it.
Run it:
cd ~/projects/local-llm-direct
uv run python first_run.py
.../huggingface_hub/utils/_validators.py:205: UserWarning: The `local_dir_use_symlinks`
argument is deprecated and ignored in `hf_hub_download`. Downloading to a local
directory does not use symlinks anymore.
warnings.warn(
'Paris'
The UserWarning comes from inside huggingface_hub, not from anything you
wrote — from_pretrained passes a deprecated argument on its way to the
cache. It is noise, it appears on every run that resolves a repo id, and
Step 5’s model_path route avoids it entirely.
That is the whole swap. A model loaded from disk, a chat completion, and an answer, with no server process anywhere.
What did not carry over
Three details from the OpenAI route have no equivalent here, and expecting them is the main way a port goes wrong.
- There is no
model=argument. The SDK requires one, and allama serveprocess has nothing to select with it because it serves the single model it was started with. Here the model is the object you called the method on, so the parameter does not exist. - There is no
reasoning_content. This is the interesting one. The server article’s headline finding is that a reasoning model can answer withcontentset to the empty string and the real text in a separatereasoning_contentfield, handing an SDK client an empty string and no error. That does not happen in-process. Asked a question written to induce step-by-step working, this route returned a message with the keys['content', 'role']and nothing else, with all 291 tokens of working insidecontent. A chat template is the formatting rule a model file carries for turning a list of messages into one token sequence; the binding renders the GGUF’s own in Python (llm.chat_formatreportschat_template.default) and never separates a reasoning channel out. - Errors arrive as exceptions, not status codes. There is no connection to
refuse and no
APIConnectionErrorto catch. A bad path raises at construction time instead.
Step 5: Write the module
The one-file version proves the install. This step turns it into something worth importing: a loader that takes either a path or a repo id, and a function that returns a small typed value instead of a raw dictionary.
The Reply dataclass exists to solve the dictionary problem once. Ported code
that reaches into out["choices"][0] in a dozen places will break in a dozen
places the first time a response shape changes; converting at the boundary means
only one function knows what the response looks like.
Create the file
cd ~/projects/local-llm-direct
touch src/local_llm_direct/client.py
Add the code: src/local_llm_direct/client.py
"""Run a local GGUF model in this process, with no server in the way.
The OpenAI SDK builds a client around a URL; this module builds one around a
file. The only shape worth remembering is that llama-cpp-python returns plain
dictionaries where the SDK returns objects, so `ask` converts once, here, and
callers never index into a response.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from llama_cpp import Llama
DEFAULT_REPO_ID = "ggml-org/Qwen3.5-0.8B-GGUF"
DEFAULT_FILENAME = "Qwen3.5-0.8B-Q8_0.gguf"
@dataclass(frozen=True)
class Reply:
"""One assistant turn, converted out of the response dictionary."""
text: str
finish_reason: str
@property
def truncated(self) -> bool:
"""True when generation stopped at the token cap rather than finishing."""
return self.finish_reason == "length"
def load(
*,
model_path: str | None = None,
n_ctx: int = 2048,
n_gpu_layers: int = -1,
verbose: bool = False,
) -> Llama:
"""Load a model, preferring an explicit path over a Hugging Face repo id.
`LLAMA_MODEL_PATH` selects a local file and works offline. Without it, the
model is resolved from `LLAMA_REPO_ID` and `LLAMA_FILENAME`, which needs
network access even when the file is already cached.
"""
path = model_path or os.environ.get("LLAMA_MODEL_PATH")
if path:
return Llama(
model_path=path,
n_ctx=n_ctx,
n_gpu_layers=n_gpu_layers,
verbose=verbose,
)
return Llama.from_pretrained(
repo_id=os.environ.get("LLAMA_REPO_ID", DEFAULT_REPO_ID),
filename=os.environ.get("LLAMA_FILENAME", DEFAULT_FILENAME),
n_ctx=n_ctx,
n_gpu_layers=n_gpu_layers,
verbose=verbose,
)
def ask(llm: Llama, prompt: str, *, max_tokens: int = 256) -> Reply:
"""Send one user turn and convert the response dictionary into a `Reply`."""
out = llm.create_chat_completion(
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
temperature=0.0,
)
choice = out["choices"][0]
return Reply(
text=choice["message"]["content"] or "",
finish_reason=choice["finish_reason"],
)
Detailed breakdown
load()prefersmodel_pathfor a concrete reason.from_pretrainedcontacts the Hugging Face API to resolve repo metadata before it checks the cache, so it fails withhuggingface_hub.errors.OfflineModeIsEnabledunderHF_HUB_OFFLINE=1even when the weights are sitting on disk.Llama(model_path=...)never touches the network. Offer both and the caller picks.ask()takes theLlamaas an argument rather than building one, because loading is the expensive part. The model stays in memory between calls; a function that constructed its own would pay the load on every question and hold two copies of the weights if called twice.choice["message"]["content"] or ""guards a real case. The content key can holdNonerather than a string when a turn produces no text, andor ""keepsReply.texttyped asstrso callers are not forced to handleNone.truncatedreadsfinish_reason, which carries the same values as the OpenAI SDK:"stop"for a natural ending,"length"whenmax_tokenscut it off. This is one of the few pieces of the response shape that ports unchanged.- The environment variables mean the same code runs against different weights without an edit, which is what makes the live test in Step 6 skippable on a machine that has no model.
Step 6: Test it without loading a model
Most of what a port can get wrong is in the conversion, not the inference — and
the conversion can be tested in milliseconds. A stub object with a
create_chat_completion method satisfies everything ask() touches, so the
suite runs with no weights, no GPU, and no network.
One test does load the real model, because a suite that only ever sees a stub cannot tell you the response shape is still what you assumed. It skips itself when the weights are not available.
Create the file
cd ~/projects/local-llm-direct
mkdir -p tests
touch tests/test_client.py
Add the code: tests/test_client.py
"""Tests for the response conversion, with one live check against real weights."""
from __future__ import annotations
import os
import pytest
from local_llm_direct.client import Reply, ask, load
class StubLlama:
"""Stands in for `Llama`, recording the call and returning a fixed response."""
def __init__(self, content, finish_reason="stop"):
self._content = content
self._finish_reason = finish_reason
self.calls = []
def create_chat_completion(self, **kwargs):
self.calls.append(kwargs)
return {
"choices": [
{
"message": {"role": "assistant", "content": self._content},
"finish_reason": self._finish_reason,
}
]
}
def test_ask_extracts_text():
reply = ask(StubLlama("Paris"), "Capital of France?")
assert reply.text == "Paris"
assert reply.finish_reason == "stop"
def test_ask_converts_none_content_to_empty_string():
assert ask(StubLlama(None), "anything").text == ""
def test_ask_sends_one_user_message():
stub = StubLlama("ok")
ask(stub, "hello", max_tokens=17)
sent = stub.calls[0]
assert sent["messages"] == [{"role": "user", "content": "hello"}]
assert sent["max_tokens"] == 17
def test_reply_is_truncated_at_the_token_cap():
assert Reply(text="...", finish_reason="length").truncated is True
def test_reply_is_not_truncated_on_a_natural_stop():
assert Reply(text="done", finish_reason="stop").truncated is False
@pytest.mark.skipif(
not os.environ.get("LLAMA_LIVE"),
reason="set LLAMA_LIVE=1 to load the real model",
)
def test_live_model_answers():
llm = load()
reply = ask(llm, "Name the capital of France. One word.", max_tokens=32)
assert "paris" in reply.text.lower()
Detailed breakdown
StubLlamaduck-types the one methodask()calls. No mocking library and no subclassing ofLlama, which would load a model just to construct. This is the payoff of havingask()accept the model rather than create it.test_ask_converts_none_content_to_empty_stringis the test that would have caught theNonecase in review rather than in production. It is the kind of thing a stub tests well and a live model tests badly, because you cannot reliably make a model return nothing.test_ask_sends_one_user_messageasserts on what was sent, not what came back. It fails if someone changes the message shape while porting, which is the other half of the OpenAI-to-bindings translation.- The live test is opt-in via
LLAMA_LIVE, somake teststays fast and works on a machine with no weights. Run it deliberately when you want to confirm the real response shape.
Run the fast suite:
cd ~/projects/local-llm-direct
uv run pytest -q
.....s [100%]
5 passed, 1 skipped in 0.04s
Then run the live one, which loads the weights:
LLAMA_LIVE=1 uv run pytest -q
tests/test_client.py::test_live_model_answers
.../huggingface_hub/utils/_validators.py:205: UserWarning: The
`local_dir_use_symlinks` argument is deprecated and ignored in `hf_hub_download`.
warnings.warn(
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
6 passed, 1 warning in 0.68s
Six passed rather than five and a skip, and this run took 0.68 seconds, nearly
all of it the single live load; 795 MiB of weights map in fast once the
operating system has the file cached in memory. Treat the timings in both
transcripts as captured examples rather than fixed values — repeat runs on the
same machine landed between 0.04 and 0.10 seconds for the fast suite and 0.68
and 0.90 for the live one. The warning is the same huggingface_hub deprecation from Step 4;
pytest collects warnings and reports them at the end rather than inline.
Step 7: Add a Makefile
The commands so far are short but easy to misremember — particularly the
LLAMA_LIVE=1 prefix. A Makefile records them, and a bare make lists what is
available rather than doing something unexpected.
Create the file
cd ~/projects/local-llm-direct
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help install test test-live run clean
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
install: ## Sync dependencies (compiles llama.cpp on first run)
uv sync
test: ## Run the fast suite (no model loaded)
uv run pytest -q
test-live: ## Run every test, including the one that loads real weights
LLAMA_LIVE=1 uv run pytest -q
run: ## Ask the model one question
uv run python first_run.py
clean: ## Remove caches (keeps the venv and its compiled libraries)
rm -rf .pytest_cache
find . -type d -name __pycache__ -prune -exec rm -rf {} +
Detailed breakdown
.DEFAULT_GOAL := helpmakes a baremakeprint the target list. Without it,makeruns the first target in the file, which is rarely what someone exploring the project wants.- The
helptarget parses its own Makefile. Each target’s description comes from the##comment on its line, so adding a target documents it automatically and a target without a comment stays hidden. cleandeliberately leaves.venv/alone. Deleting it here would throw away the compiled llama.cpp and make the nextmake installrecompile. That is a 24-second penalty for no benefit, which is exactly the kind of thing acleantarget should not do by surprise.
Confirm it:
cd ~/projects/local-llm-direct
make
help Show this help
install Sync dependencies (compiles llama.cpp on first run)
test Run the fast suite (no model loaded)
test-live Run every test, including the one that loads real weights
run Ask the model one question
clean Remove caches (keeps the venv and its compiled libraries)
Troubleshooting
ModuleNotFoundError: No module named 'huggingface_hub' — from_pretrained
needs it and llama-cpp-python does not depend on it. Run
uv add huggingface-hub, or switch to Llama(model_path=...), which has no
such requirement.
OfflineModeIsEnabled: Cannot reach https://huggingface.co/... — you set
HF_HUB_OFFLINE=1. from_pretrained resolves repo metadata before it looks in
the cache, so it needs the network even for a model you already have. Use
LLAMA_MODEL_PATH to point at the file directly.
UserWarning: The 'local_dir_use_symlinks' argument is deprecated — raised
inside huggingface_hub by from_pretrained, not by your code, and safe to
ignore. Loading with model_path= does not go through that helper and does not
warn.
Warning: You are sending unauthenticated requests to the HF Hub — harmless
for a public repo. Set HF_TOKEN to raise the rate limit if you are resolving
models frequently.
The load is slow and generation is slower still — n_gpu_layers defaulted
to 0, leaving everything on the CPU. Pass n_gpu_layers=-1.
A ResourceWarning about an unclosed /dev/null — this comes from the
verbose=False suppression path inside the package. It is cosmetic, but it
surfaces under pytest -W error and in strict warning configurations.
The model loads but answers badly — a model problem, not a port problem. The 0.8B
model here is small enough to fumble simple instructions; asked to “Count: one
two three” it replied “I’m ready to help! What would you like to count or do?”
That is the model, not the bindings, and pointing LLAMA_REPO_ID at larger
weights is the fix.
Recap
You ported a client from the OpenAI SDK to the llama.cpp bindings, and the change came down to four things:
OpenAI(base_url=..., api_key=...)becameLlama.from_pretrained(...)orLlama(model_path=...). A client built around a URL became one built around a file.client.chat.completions.create(...)becamellm.create_chat_completion(...), and themodel=argument disappeared, because the model is the object.- The response became a plain dictionary.
resp.choices[0].message.contentbecameout["choices"][0]["message"]["content"], which is whyask()converts once at the boundary and hands back aReply. - The server went away entirely — with it the port, the API key, the
connection errors, and the
reasoning_contentfield that can hand an SDK client an empty string.
What you gained is a program with no moving parts outside itself. What you gave
up is the thing a server is for: one loaded copy of the weights serving many
clients, in many languages, possibly on another machine. A script, a test suite,
or a batch job is a good fit for the bindings. Several processes that each want
the same model are still a job for
llama serve.