Your own GPU
Open models on your hardware as the runtime's vendors: no cloud in the call.
The runtime names no vendor in code: every livekit plugin it carries is a vendor, and the providers row says which ones a call runs on. So open models on your own NVIDIA GPU can be the runtime's vendors with nothing but data: three model servers, and a row that points at them. No cloud vendor hears, thinks or speaks in the call, and no vendor bills for it.
How it works#
Every server speaks the OpenAI API, so the runtime reaches all of them through the openai
plugin it already has, with a base_url per stage.
| stage | model | server | how the runtime reaches it |
|---|---|---|---|
| ears | Whisper large-v3-turbo (99 languages) | Speaches | the openai plugin, base_url in the row |
| the end of a turn | Smart Turn v3 (8 MB, 23 languages) | the worker's own CPU | turn_model in the row |
| thinking | Google Gemma 4 12B | Ollama | the openai plugin, base_url in the row |
| voice | Kokoro-82M | Kokoro-FastAPI | the openai plugin, base_url in the row |
| memory and knowledge | bge-m3 (1024 wide) | Ollama | the row's embedding |
The servers run with Docker Compose on the machine that holds the card, outside the cluster. The
cluster's pods reach them over a private network: the workers for a call's ears, model and voice,
and the gateways for the embedder and the hang-up judges. Both files are in the runtime's
infra/models/.
What you need#
- A runtime, on Kubernetes or
on your laptop, and its operator key (
PINECALL_OPS_KEY; on a cluster the secretpinecall-<env>-ops-keyin Secret Manager). - An NVIDIA card with 12 GB of memory or more. Everything loaded takes about 10 GB: Gemma about 8.5, the ears and Kokoro about 1 each. It was measured on 24 GB (an RTX 3090).
- About 25 GB of disk for the images and the models.
- The NVIDIA driver, Docker with the compose plugin and NVIDIA's container toolkit on that
machine.
nvidia-smishows the card, anddocker run --rm --gpus all ubuntu nvidia-smishows it from a container. - A private route from the cluster to the machine on ports 11434, 8880 and 8000. The repository's Terraform makes neither the GPU machine nor a firewall rule for it: make both the way your network is run, and admit the cluster's pod and node ranges alone. Never a public address: the servers ask for no key, so anyone who reaches them uses your GPU.
Set it up#
Start the servers#
On the machine with the card, with MODELS_BIND the private address the cluster reaches it at:
mkdir ~/pinecall-models && cd ~/pinecall-models
curl -fsSLO https://raw.githubusercontent.com/pinecall/runtime/main/infra/models/compose.yaml
curl -fsSLO https://raw.githubusercontent.com/pinecall/runtime/main/infra/models/providers.json
MODELS_BIND=10.0.0.5 docker compose up -d
The first start is slow, and only the first: Ollama pulls Gemma 4 (7.6 GB) and bge-m3, and the
ears pull Whisper (1.6 GB). The volumes keep them. docker compose ps -a shows ollama-models
and whisper-model exited (0) once the models are in.
Check each server#
H=10.0.0.5
curl -s http://$H:11434/v1/chat/completions -H 'Content-Type: application/json' \
-d '{"model":"gemma4:12b","reasoning_effort":"none","messages":[{"role":"user","content":"Di hola"}]}'
curl -s http://$H:8880/v1/audio/speech -H 'Content-Type: application/json' \
-d '{"model":"kokoro","voice":"ef_dora","input":"Hola, ¿en qué te puedo ayudar?","response_format":"wav"}' -o hola.wav
curl -s http://$H:8000/v1/audio/transcriptions -F file=@hola.wav \
-F model=deepdml/faster-whisper-large-v3-turbo-ct2 -F language=es
curl -s http://$H:11434/v1/embeddings -H 'Content-Type: application/json' \
-d '{"model":"bge-m3","input":"hola"}' | head -c 120
A word back from the model, a WAV you can play, that WAV written back as text, and 1024 numbers.
Point the runtime at them#
Put the machine's address in the row: sed 's/MODELS_HOST/10.0.0.5/g' providers.json > row.json.
A runtime that serves calls already has a row, and PUT /v1/ops/providers replaces it whole, so
read it first (GET /v1/ops/providers), merge the open stack's defaults, voices, tuning,
rates, judge and embedding into it, and send the result:
curl -fsS -X PUT https://<your name>/v1/ops/providers \
-H "Authorization: Bearer $PINECALL_OPS_KEY" -H 'Content-Type: application/json' --data @row.json
A runtime with no row yet, such as a new laptop, takes the file as it is:
pinecall-runtime providers seed row.json.
Give the vendor a key#
The servers take no key, but the plugin refuses an empty one, so the runtime holds a placeholder
for openai:
curl -fsS -X PUT https://<your name>/v1/ops/provider-keys/openai \
-H "Authorization: Bearer $PINECALL_OPS_KEY" -H 'Content-Type: application/json' -d '{"key": "local"}'
An org runs on it where its quota lends openai (Limits and provider keys).
The row's
tuningsends everyopenaistage to your servers, and the placeholder replaces the runtime's own OpenAI key. A runtime that also runs OpenAI's models stops reaching them. And a newembeddingleaves the facts and chunks written under the old one findable by their words only, until they are written again (pinecall-runtime memory reembeddoes it for memory).
A call#
The smallest agent that uses all three stages is one class with its prompt in the docstring:
import { Agent } from "@pinecall/agents";
/**
* Eres la recepción de la peluquería Rulos, en Montevideo. Hablas de vos, con frases cortas.
* Todo lo que dices se lee en voz alta: sin listas, sin markdown, los números como se dicen.
* Abrimos de martes a sábado, de diez de la mañana a siete de la tarde. El corte cuesta ochocientos pesos.
*/
export default class Recepcion extends Agent {}
Run pinecall start in the project, open the agent in the console's sandbox and talk to it.
pinecall sessions show <call> then shows what the ears heard, what the model said and how long
each stage took.
What the row says#
| field | what it does |
|---|---|
stt/openai |
Whisper does not stream: the session's voice detector cuts each sentence and the whole sentence is transcribed at once. use_realtime: false keeps the plugin on that endpoint |
voices |
the voice per language, openai/<language>. Kokoro's Spanish voices are ef_dora, em_alex and em_santa; curl http://$H:8880/v1/audio/voices lists them all |
turn_model: smart-turn-v3 |
how the end of the caller's turn is read. Left out, it is livekit's own v1-mini. Measure both on your agents and keep the better |
reasoning_effort: none |
Gemma 4 thinks before it answers unless told not to; on a phone that is a second of silence |
rates at zero |
a call on your GPU costs nothing a vendor bills, and the console's cost columns read zero |
judge |
the hang-up judges run on the same Gemma |
When something does not come up#
| what you see | why | what to do |
|---|---|---|
could not select device driver "nvidia" |
the container toolkit is not configured for Docker | configure it for Docker (nvidia-ctk runtime configure --runtime=docker), then restart Docker |
the ears answer 404 for the model |
the model is not downloaded yet | wait for whisper-model to exit (0), or docker compose up whisper-model |
| the first turn of a call waits ~7 s | Speaches unloaded an idle Whisper | the compose keeps it (WHISPER__TTL=-1) and loads it at start |
| the first answer after a while takes ~20 s | Ollama unloaded the model | the compose keeps it loaded (OLLAMA_KEEP_ALIVE=-1) |
nvidia-smi shows Gemma partly on the CPU |
not enough GPU memory beside the ears | keep Ollama's context at 8192 (the compose does) and nothing else on the card |
What it measured#
An RTX 3090 holding the three servers, with the runtime on the same machine; a caller speaking five lines in Spanish, Smart Turn reading the end of each turn. Across a network, add its round trips.
| caller | what the ears heard | caller stops → agent speaks |
|---|---|---|
| Hola, buenas. ¿Abren el sábado? | Hola, buenas, abren el sábado. | 3.78 s (the first turn: Whisper was cold) |
| Bárbaro. ¿Y cuánto sale un corte? | Bárbaro y cuánto sale un corte. | 1.51 s |
| ¿Y el color, más o menos cuánto? | Y el color más o menos cuanto. | 1.57 s |
| ¿Puedo sacar un turno para el sábado a las once? | Puedo sacar un turno para el sábado a las 11. | 2.11 s |
| Dale, perfecto. Muchas gracias, chau. | Dale perfecto. Muchas gracias. Chao. | 1.64 s |
Of those ~1.6 s, the ears close the turn about 0.55 s after the caller's last word, Gemma 4's first token takes about 0.7 s, and the voice's first audio 0.1–0.3 s.
What it does not do yet#
- A tool the model must call. Ollama does not honour
tool_choice: "required". The spoken caller ofpinecall simulate --voicedemands one and fails on Gemma; so does each model judge, which then gives no verdict. An agent's own tools are called when the model chooses to. A server that constrains its output to the call (llama.cpp'sllama-server --jinja, or vLLM) fixes it; only the row'sbase_urlchanges. - Two calls at once share one Ollama, which answers one request at a time per model by default. For more than a demo, serve the model with vLLM, which also speaks the OpenAI API.