A wrapper around an embedded llama.cpp checkout that serves Qwen 3.8 27B multimodal GGUF models via llama-server on an NVIDIA GPU, with a Cloudflare Tunnel + SSE keepalive proxy that eliminates VS Code GitHub Copilot timeouts during long LLM inference.
The headline problem this solves: when you route VS Code Copilot (or any OpenAI-compatible client) through Cloudflare to a local llama-server, Cloudflare's ~120-second proxy read timeout kills any request whose first byte takes longer than that — which is common with 27B models, long prompts, and tool-calling workloads. This repo ships a tiny local timeout proxy that emits early SSE keepalive bytes so Cloudflare never sees a stalled connection.
Production proven. This exact setup is proven to work great in production for commercial work. It's sometimes used directly as the model backend for VS Code GitHub Copilot — see Using with VS Code GitHub Copilot.
Read the write-up. A 27B Coding Agent That Runs on 16 GB VRAM — a blog post walking through this setup end to end: the architecture, the Qwen 3.8 reliability tweaks, the 16 GB / 32 GB profiles, and how it's wired into VS Code Copilot.
Platform support. This setup works well on Linux (native) and on WSL2 (Windows Subsystem for Linux). On WSL2 the NVIDIA driver is supplied by the Windows host — see Prerequisites and the WSL2 provisioning steps in Quick start.
Internet
│
▼
Cloudflare Edge (your custom hostname)
│
▼
Cloudflare Tunnel (cloudflared, Docker container)
│
▼
localhost:8080 ──► cloudflare-timeout-proxy.py
│ (SSE keepalives, sampling clamp,
│ tool-result nudge, request capture)
│
▼
localhost:8081 ──► llama-server (llama.cpp, CUDA)
cloudflared(Docker) — remotely-managed Cloudflare Tunnel connector, started/stopped byrun-paq-llamacpp-server.shcloudflare-timeout-proxy.py— Python stdlib-only proxy that sits between Copilot andllama-server. Sends 15-second SSE keepalive pings while waiting for upstream response headers, clamps sampling parameters for stable tool-call JSON, and injects a nudge after tool-result turns to prevent premature stopping.llama-server— the actual inference engine, built from the embeddedllama.cppcheckout with CUDA.
- launches
llama-serverfrom the localllama.cppbuild - serves a local multimodal Qwen 3.8 27B model via
--mmproj - loads runtime overrides from a root
.envfile - supports optional API-key auth and TLS certificates
- exposes OpenAI-style endpoints (
/v1/chat/completions,/v1/responses, etc.) - can optionally start a local
cloudflaredconnector container for a remotely-managed Cloudflare Tunnel - can optionally front
llama-serverwith a tiny local timeout proxy that emits early bytes/heartbeats for long Cloudflare-routed inference requests - can be installed as a host-wide
systemdservice that starts at boot and restarts on failure - keeps the large model files, toolchains, caches, and secrets out of Git
- includes host-side helpers for
systemdstartup and NVIDIA power limiting - includes a lightweight cost dashboard (
cost_dashboard.py) that reads the request/cost CSV logs and renders a self-contained web page with total cost, token statistics, cache hit ratio, and a daily cost chart - includes a self-contained cost calculator (
cost_calculator.html) served at/cost-calculatorand published on GitHub Pages for estimating per-1M-token, per-request, daily, and monthly electricity costs, with a GPT 5.6 Luna API comparison - is proven in production for commercial work and can be used directly as the VS Code GitHub Copilot model backend
cost_dashboard.py is a dependency-free (Python stdlib only) web app that turns the per-request cost CSV logs into a live dashboard. It reports total/today/7-day/30-day cost, input/cached/output token counts, cache hit ratio, current pricing settings, and a daily cost chart.
cost_calculator.html is a single self-contained HTML file (no external dependencies) served by the timeout proxy at /cost-calculator, and also published on GitHub Pages. It's tuned for Qwen 3.8 27B — the closest local match to GPT 5.6 Luna in intelligence — so its presets and API comparison are calibrated for that model.
Inputs: energy cost ($/kWh), PC power draw (W), input speed (tok/s), output speed (tok/s), input per prompt (tokens), output per prompt (tokens), cache hit rate (%), and requests per day.
Outputs:
- cost per hour (continuous operation)
- cost per 1M input in three flavours — no cache, all cached (100× faster), and realistic (your cache hit rate)
- cost per 1M output
- cost per request (with time), daily cost, and monthly cost
- a realistic scenario (60k input / 50k cached / 2k output, 90% uptime)
- power efficiency in input/output tokens per kWh
- a GPT 5.6 Luna comparison — per-request and monthly cost for three whole-PC presets (RTX 4070 Ti Super, RTX 5090, RTX 5090 eco-mode) plus your live config, with a bar chart and savings breakdown
A link to the calculator is available from the cost dashboard subtitle.
This setup is proven to work great in production for commercial work, and is sometimes used directly as the model backend for VS Code GitHub Copilot. You register the server as a custom OpenAI Compatible model in GitHub's AI Controls, and it then shows up in the VS Code Copilot model picker alongside the built-in models — usable for both chat and agent mode.
- Open Settings → AI Controls → Copilot → Configure custom models.
- Add an API key with provider OpenAI Compatible: set the name, your API key, and the Base API URL (your public hostname ending in
/v1, e.g.https://your-llm-host.example/v1). - Add the model (e.g.
Qwen3.8-27B) and configure its capabilities (tool calling, streaming, vision, thinking) and token limits.
Once registered, the model appears in the Copilot model picker in VS Code, ready to use:
And in action — the custom model selected in Agent mode, with the session context panel on the right:
A key reason this setup works reliably in agent mode is a small "trick" built into the timeout proxy. After a successful tool result, Qwen 3.8 can intermittently stop prematurely in the reasoning/thinking channel — it emits EOS with no visible content and no tool_call. Because Copilot hides reasoning and has no tool to execute, the agent then appears to halt mid-task.
To prevent this, the proxy appends a hidden user-role nudge message to the request whenever the last message is a tool result. The nudge forces the model to continue — emitting either a valid tool_call or visible assistant content — instead of ending its turn inside the reasoning channel. It mirrors the context refresh Copilot sends when it later recovers, and it does not alter the tool output itself.
-
Enabled by default in the checked-in profiles (
LLAMA_PROXY_NUDGE_AFTER_TOOL_RESULT=on). -
The nudge text is configurable via
LLAMA_PROXY_TOOL_RESULT_NUDGE_TEXT. The built-in default is:Continue after the latest tool result. Do not stop in the reasoning/thinking channel. Before finishing, you must emit either a valid tool_call or visible assistant content. If any planned work remains, call the next appropriate tool now. Do not merely describe the next step in reasoning.
Disable it with LLAMA_PROXY_NUDGE_AFTER_TOOL_RESULT=off if you are not driving the server from VS Code Copilot and do not need the continuation behavior.
An example VS Code chatenginemodels.json with both variants (16 GB / RTX 4070 and 32 GB / RTX 5090) based on this setup (also checked in as chatenginemodels.json):
[
{
"name": "16GBVRAM",
"vendor": "customendpoint",
"apiType": "chat-completions",
"models": [
{
"id": "RERND",
"name": "Qwen 3.8 27b (RTX4070)",
"url": "https://your-llm-host.example/v1",
"streaming": true,
"toolCalling": true,
"vision": true,
"maxInputTokens": 70000,
"maxOutputTokens": 30000,
"contextWindow": 100000
}
],
"apiKey": "${input:chat.lm.secret.-64847427}"
},
{
"name": "32GBVRAM",
"vendor": "customendpoint",
"apiKey": "${input:chat.lm.secret.-64847427}",
"apiType": "chat-completions",
"models": [
{
"id": "RERND",
"name": "Qwen 3.8 27b (RTX5090)",
"url": "https://your-llm-host.example/v1",
"streaming": true,
"toolCalling": true,
"vision": true,
"maxInputTokens": 150000,
"maxOutputTokens": 50000,
"contextWindow": 200000
}
]
}
]Two pre-configured profiles are checked in. Both target Qwen 3.8 27B (multimodal, MTP speculative decoding, vision via mmproj).
| Profile | Quantization | Context | VRAM | Profile file | Switch command |
|---|---|---|---|---|---|
qwen38-16gb |
Q3_K_XL | 100K | ~16 GB | dot.env.qwen38-27b-q3kxl-100k-16gb |
sudo bash scripts/switch-model.sh qwen38-16gb |
qwen38-32gb |
Q5_K_XL | 200K | ~32 GB | dot.env.qwen38-27b-q5kxl-200k-32gb |
sudo bash scripts/switch-model.sh qwen38-32gb |
Models are NOT included in this repo. Download them from Hugging Face:
- unsloth/Qwen3.8-27B-GGUF — pick the Q3_K_XL or Q5_K_XL variant
- Place the model file in
models/and the matching mmproj file alongside it
Both profiles use the froggeric v22.1 chat template (chat_templates/chat_template.jinja) with reasoning_effort: medium by default (clients can override per request).
Note: Since Qwen 3.8 27B, the custom chat template fix is no longer strictly needed — the model's built-in template handles tool calls correctly. The template file is kept in the repo "just in case" and can be removed if you prefer the stock template.
Tuned for occasional vision analysis. These profiles are tuned for rare / occasional vision (image) analysis, not for heavy continuous multimodal workloads. The vision projector (
mmproj) is not GPU-offloaded — it runs on the CPU — so image-heavy requests are slower than pure-text inference. For the intended use (mostly text, with the occasional screenshot or image), this is a good trade-off: it keeps the full GPU dedicated to the LLM weights and KV cache. If you need fast, frequent vision, look into offloading the mmproj to the GPU and re-tuning the profile.
Thinking loops? If the model gets stuck in repetitive reasoning loops, try the Bartowski quantization variants — see QWEN38_RELIABILITY_GUIDE.md §10.
On a desktop with both an integrated GPU (Intel/AMD iGPU) and a dedicated NVIDIA GPU (dGPU), the display output can be routed through the iGPU so the dGPU is used purely for compute. This frees 100% of the dGPU's VRAM for the LLM (weights + KV cache), because the dGPU no longer has to reserve memory for the framebuffer/display.
- On WSL2 this is already the default situation: Windows owns the display, and the NVIDIA GPU inside the WSL2 distro is a compute-only device. So all of its VRAM is already available to
llama-server— nothing extra to configure. - On native Linux you typically route the monitor through the iGPU and let the dGPU do compute only. Common approaches:
- PRIME render-offload — set the iGPU as the display/output provider and the NVIDIA dGPU as the render provider (or vice-versa, depending on your goal). On X11 this is often done with
xrandr --setprovideroutputsource/xrandr --output <MONITOR> --autoafter selecting the iGPU provider. - Configure your compositor/WM to output through the iGPU (e.g. in
xorg.confvia thenvidia+intel/amdgpuPRIME setup, or in Wayland compositors by choosing the iGPU as the output device). - Verify with
nvidia-smithat the dGPU shows no display clients and that its full VRAM is free for compute.
- PRIME render-offload — set the iGPU as the display/output provider and the NVIDIA dGPU as the render provider (or vice-versa, depending on your goal). On X11 this is often done with
The exact steps depend on your hardware and desktop environment, so treat this as a starting point and adapt to your setup.
.
├── .env # local runtime overrides (not committed)
├── .env.example # documented template for .env
├── .cloudflared/ # generated local tunnel token file(s) (ignored)
├── .gitignore # ignores models, caches, secrets, envs, etc.
├── README.md # this file
├── chatenginemodels.json # example VS Code Copilot custom-model config
├── LICENSE # Apache License 2.0
├── NOTICE # attribution notices
├── api-keys.txt # optional llama.cpp API key file (ignored)
├── install-systemd-service.sh # installs/enables the host-wide model service
├── paq-llamacpp-server.service # systemd unit for the main launcher
├── certs/
│ └── openssl-san.cnf # OpenSSL SAN config for local TLS
├── chat_templates/
│ └── chat_template.jinja # froggeric v22.1 chat template
├── cuda-env # ignored symlink to /usr/local/cuda-* (not vendored)
├── hf-cache/ # Hugging Face cache (ignored)
├── llama.cpp/ # embedded upstream repo (ignored by this root repo)
├── models/ # model + mmproj files (ignored)
├── paq-llamacpp-server-base-meta/ # model metadata (config.json, tokenizer, etc.)
├── cloudflared.compose.yaml # Cloudflare Tunnel connector container definition
├── cloudflare-timeout-proxy.py # SSE keepalive timeout proxy (Python stdlib)
├── cost_calculator.html # self-contained cost calculator (served at /cost-calculator)
├── cost_dashboard.py # cost dashboard (served at /dashboard)
├── run-paq-llamacpp-server.sh # main launcher
├── stop-paq-llamacpp-server.sh # stop the local server and optional tunnel connector
├── set-gpu-power-limit.sh # GPU power cap helper
├── nvidia-power-limit.service # systemd unit for the GPU power cap helper
├── dot.env.qwen38-27b-q3kxl-100k-16gb # 16 GB profile
├── dot.env.qwen38-27b-q5kxl-200k-32gb # 32 GB profile
├── scripts/
│ ├── provision-wsl2-ubuntu.sh # WSL2 provisioning + llama.cpp build
│ ├── switch-model.sh # switch between model profiles via systemd drop-in
│ └── toolcall-stress.py # tool-call reliability stress test
└── docs/
├── SETUP.md # full setup guide
├── QWEN38_RELIABILITY_GUIDE.md # Qwen-specific operational hardening
├── UPDATING_CUDA.md # CUDA toolkit upgrade guide
├── TUNING_REPORT.md # batch/ubatch tuning benchmarks
└── FIXME_QWEN_TOOL_PARSING.md # tool-call parsing investigation notes
You will need:
- Linux (native or WSL2)
- an NVIDIA GPU (tested on RTX 5090 32 GB; 16 GB profiles work on 16 GB cards)
nvidia-smi- a CUDA-enabled
llama.cppbuild atllama.cpp/build/bin/llama-server - a CUDA toolkit installed under
/usr/local/cuda-*;cuda-envis the local symlink that gives builds and scripts a stable path to that toolkit - the model and mmproj files in
models/ - Docker (for the optional Cloudflare Tunnel connector)
On WSL2, the NVIDIA driver is supplied by the Windows host. Do not install a separate Linux NVIDIA driver in the distribution. The launcher automatically prefers the host-matched WSL driver directory for CUDA's PTX JIT library.
The launcher validates several of these assumptions before starting.
Run from inside the Ubuntu WSL2 distribution after the Windows NVIDIA driver and WSL have been updated:
bash scripts/provision-wsl2-ubuntu.shThe provisioning script is safe to rerun. It:
- installs ordinary Ubuntu build prerequisites (
cmake, compilers, Python, Git) - verifies the Windows-provided WSL CUDA bridge,
nvidia-smi, and host-matched driver tree - selects the newest
/usr/local/cuda-*toolkit, creates/repairs thecuda-envsymlink - configures and builds
llama-serverwith CUDA, all-quant Flash Attention - runs
llama-server --versionwith the corrected WSL library ordering
This checkout targets an RTX 5090 (sm_120a). For another GPU, override the architecture:
bash scripts/provision-wsl2-ubuntu.sh --arch 86Updating the CUDA toolkit? Use the dedicated
docs/UPDATING_CUDA.mdguide.
Download from Hugging Face and place in models/:
models/Qwen3.8-27B-UD-Q5_K_XL.gguf(32 GB profile) ormodels/Qwen3.8-27B-UD-Q3_K_XL.gguf(16 GB profile)models/mmproj-qwen38-27b-F16.gguf(vision projector)
Copy the template and adjust:
cp .env.example .envThe .env.example defaults to the 32 GB Q5_K_XL profile. For the 16 GB profile, either edit .env or use the profile overlay:
PAQ_LLAMACPP_SERVER_ENV_FILE=dot.env.qwen38-27b-q3kxl-100k-16gb ./run-paq-llamacpp-server.sh./run-paq-llamacpp-server.shBy default the server binds to 0.0.0.0:8080. If CLOUDFLARED_TUNNEL_TOKEN is set, the cloudflared connector container is also started. If CLOUDFLARE_TIMEOUT_PROXY_MODE is enabled, the timeout proxy listens on 8080 and llama-server moves to 8081.
./stop-paq-llamacpp-server.shTo run this stack as a Copilot-compatible model endpoint, keep reasoning enabled and normalize outgoing sampling for tool-call stability.
Recommended .env block:
REASONING=auto
REASONING_BUDGET=
REASONING_BUDGET_MESSAGE=
CLOUDFLARE_TIMEOUT_PROXY_MODE=stream
LLAMA_PROXY_CLAMP_TEMPERATURE=0.6
LLAMA_PROXY_CLAMP_TOP_P=0.95
LLAMA_PROXY_SET_TOP_K=20
LLAMA_PROXY_MAX_TOKENS_CTX_PCT=50Notes:
- Use OpenAI-compatible base URL pointing at this launcher (
https://<host>:<port>/v1for direct access, or your Cloudflare hostname ending in/v1when tunneled). - Use
MODEL_ALIASfrom your.envas the model name in Copilot configuration. - Real VS Code Copilot traffic commonly sends
temperature=1,top_p=1, and no explicitmax_tokens; the clamp settings above bring sampling back to Qwen-friendly values for stable tool-call JSON. - For remote Copilot usage, set
CLOUDFLARED_TUNNEL_TOKENand keepCLOUDFLARED_ENABLED=autoso the connector container is managed byrun-paq-llamacpp-server.sh.
The timeout proxy is separate from the Cloudflare Tunnel connector:
CLOUDFLARED_*decides whether a localcloudflaredcontainer publishes your hostname through Cloudflare Tunnel.CLOUDFLARE_TIMEOUT_PROXY_*decides whether a small local front proxy sits in front ofllama-serverto emit early bytes before the backend is ready.
When enabled, the timeout proxy keeps the public HOST / PORT for clients and moves the actual llama-server backend to CLOUDFLARE_TIMEOUT_PROXY_BACKEND_HOST / CLOUDFLARE_TIMEOUT_PROXY_BACKEND_PORT (default: PORT + 1).
For the usual PORT=8080 setup:
8080is the public listener and the port clients should use8081is the privatellama-serverbackend when the proxy is enabled- with
CLOUDFLARE_TIMEOUT_PROXY_MODE=off,llama-serveritself listens on8080
Use off when:
- you are calling the server locally or over a direct/non-proxied path
- your first byte already arrives comfortably inside Cloudflare's timeout window
- or your client is very strict about raw upstream behavior
Use stream when:
- your client sends
"stream": true - you want Cloudflare protection against slow first byte
- you want non-stream requests to continue behaving as strict pass-through
In stream mode, only JSON inference requests with "stream": true get early SSE keep-alives. Non-stream JSON requests are forwarded normally.
Use optimistic when:
- your client needs non-stream JSON responses
- Cloudflare is in front
- slow prompt prefill can exceed the first-byte timeout
In optimistic mode, the proxy immediately starts a chunked 200 OK response and emits whitespace heartbeats until the upstream response is ready. The trade-off: if the upstream later fails with HTTP 4xx/5xx, the proxy can return a valid error body but cannot change the already-started 200 OK status line.
This repo includes a host-wide systemd unit that runs run-paq-llamacpp-server.sh as the owner of the current checkout:
- starts automatically after host reboot
- restarts the launcher if it exits unexpectedly
- keeps
.envloading and optional timeout-proxy /cloudflaredorchestration insiderun-paq-llamacpp-server.sh
Install and enable it with:
sudo ./install-systemd-service.shUseful service commands:
sudo systemctl status paq-llamacpp-server.service
sudo journalctl -u paq-llamacpp-server.service -f
sudo systemctl restart paq-llamacpp-server.service
sudo systemctl stop paq-llamacpp-server.servicesudo bash scripts/switch-model.sh qwen38-16gb
sudo bash scripts/switch-model.sh qwen38-32gbThis writes a systemd drop-in override for PAQ_LLAMACPP_SERVER_ENV_FILE, reloads systemd, and restarts the service.
| Variable | Default | Purpose |
|---|---|---|
HOST |
0.0.0.0 |
bind address |
PORT |
8080 |
server port |
MODEL |
(profile-specific) | path to the GGUF model file |
MMPROJ |
(profile-specific) | path to the mmproj vision file |
MODEL_ALIAS |
PAQ_LLAMACPP_SERVER |
alias shown by the server |
CTX_SIZE |
(profile-specific) | runtime context window |
BATCH_SIZE |
(profile-specific) | logical batch size |
UBATCH_SIZE |
(profile-specific) | micro-batch size |
THREADS |
8 |
inference threads |
THREADS_BATCH |
16 |
batch threads |
THREADS_HTTP |
4 |
HTTP worker threads |
PARALLEL |
1 |
number of server slots; >1 is experimental |
KV_UNIFIED |
0 |
unified KV buffer; prefer 0 for correctness |
FLASH_ATTN |
on |
flash attention toggle |
CACHE_TYPE_K |
(profile-specific) | K cache precision |
CACHE_TYPE_V |
(profile-specific) | V cache precision |
PROMPT_CACHE |
1 |
enable prompt cache |
CTX_CHECKPOINTS |
(profile-specific) | max context checkpoints per slot |
CACHE_RAM_MIB |
8192 |
prompt-cache RAM limit in MiB |
CACHE_IDLE_SLOTS |
0 |
save/clear idle slots |
WARMUP |
0 |
warm model on startup |
REASONING |
auto |
enable reasoning mode |
REASONING_BUDGET |
empty | optional cap on reasoning tokens |
SPEC_TYPE |
draft-mtp |
speculative decoding type |
SPEC_DRAFT_N_MAX |
(profile-specific) | max draft tokens for MTP |
LLAMA_SERVER_API_KEY_FILE |
empty | optional API key file |
LLAMA_SERVER_SSL_KEY_FILE |
empty | optional TLS private key |
LLAMA_SERVER_SSL_CERT_FILE |
empty | optional TLS certificate |
PAQ_LLAMACPP_SERVER_ENV_FILE |
empty | optional overlay env file loaded on top of .env |
CLOUDFLARED_TUNNEL_TOKEN |
empty | optional Cloudflare Tunnel token |
CLOUDFLARED_ENABLED |
auto |
auto starts connector when token present; off to disable |
CLOUDFLARED_IMAGE |
cloudflare/cloudflared:2026.2.0 |
cloudflared Docker image |
CLOUDFLARE_TIMEOUT_PROXY_MODE |
off |
off / stream / optimistic |
CLOUDFLARE_TIMEOUT_PROXY_BACKEND_HOST |
127.0.0.1 |
private host where llama-server binds |
CLOUDFLARE_TIMEOUT_PROXY_BACKEND_PORT |
PORT + 1 |
private backend port |
CLOUDFLARE_TIMEOUT_PROXY_HEARTBEAT_SECONDS |
15 |
seconds between keep-alive pings |
LLAMA_PROXY_CLAMP_TEMPERATURE |
empty | proxy-side cap for outgoing temperature |
LLAMA_PROXY_CLAMP_TOP_P |
empty | proxy-side cap for outgoing top_p |
LLAMA_PROXY_SET_TOP_K |
empty | proxy-side set/cap for outgoing top_k |
LLAMA_PROXY_MIN_MAX_TOKENS |
empty | optional floor for existing max_tokens caps |
LLAMA_PROXY_MAX_TOKENS_CTX_PCT |
50 |
output token ceiling as % of CTX_SIZE |
LLAMA_PROXY_STREAM_KEEPALIVE_MODE |
comment |
comment (SSE) or data (empty-delta chunk) |
LLAMA_PROXY_NUDGE_AFTER_TOOL_RESULT |
off |
append nudge after tool-result turns |
LLAMA_PROXY_CAPTURE_ENABLED |
off |
toggle raw inference-request capture |
LLAMA_PROXY_CAPTURE_DIR |
empty | directory for captured request payloads |
Before starting, run-paq-llamacpp-server.sh checks:
- the server binary exists
- the CUDA build cache exists
- the model and mmproj files exist
- the
llama.cppbuild is CUDA-enabled - mixed KV cache types are only used when the build supports them with CUDA Flash Attention
- API key and TLS files exist when configured
- TLS key and cert are supplied together
- a startup warning is printed when
PARALLEL>1and/orKV_UNIFIED=1
The public listener exposes the same API surface as the underlying llama-server instance:
- OpenAI-style endpoints such as
/v1/chat/completionsand/v1/responses
When CLOUDFLARE_TIMEOUT_PROXY_MODE is enabled, the timeout proxy keeps inference requests alive the same way it does for OpenAI-style inference routes.
When enabled, API key auth is passed through to llama-server using --api-key-file.
When TLS is enabled, both LLAMA_SERVER_SSL_KEY_FILE and LLAMA_SERVER_SSL_CERT_FILE must be set together. The certs/openssl-san.cnf file is included as a helper for generating local certificates.
Cloudflare Tunnel is useful even when the machine running llama-server is
not exposed directly to the Internet. In particular, it is a good fit when:
- your ISP does not provide a public IPv4 address
- your server is behind carrier-grade NAT, a home router, or a restrictive firewall
- you do not want to open or forward an inbound port to the GPU machine
- you want a stable HTTPS hostname instead of a changing home or office IP
- you want Cloudflare's edge, Zero Trust, and security controls in front of an otherwise private inference service
The connector creates an outbound connection from the host to Cloudflare. A request can therefore reach the local service without port forwarding or a publicly routable origin IP. The origin can remain bound to the local host (or to Docker Desktop's host gateway), while the public hostname is handled at the Cloudflare edge.
Cloudflare also gives you a useful security boundary for a powerful API endpoint:
- Cloudflare Access / Zero Trust policies can require identity, a specific email domain, group membership, a service token, or mTLS before a request is allowed through.
- WAF and managed rules can block common HTTP attack patterns before they reach the inference host.
- Rate limiting can reduce accidental or abusive GPU consumption and help protect API-key credentials from brute-force traffic.
- IP and country rules, firewall policies, and hostname-specific rules can narrow who can reach the endpoint.
- TLS at the edge, DNS management, DDoS protection, and request analytics provide operational visibility without exposing the origin address.
For an automated client such as VS Code Copilot, use an authentication method
that the client supports: the server's API key, a Cloudflare Access service
token, or both. Do not assume that an interactive browser login will work for
an OpenAI-compatible client. Keep llama-server API-key authentication enabled
even when Cloudflare Access is configured, so the origin remains protected if
it is ever reached through another path.
At a minimum, configure the Cloudflare hostname so that it is proxied through
Cloudflare, create a Tunnel with a remotely-managed public hostname, and point
that hostname at the local timeout proxy (8080) rather than directly at the
backend (8081). Then apply an Access policy or an equivalent firewall/rate
limit policy before sharing the /v1 URL with a client. Cloudflare is an
additional security layer, not a replacement for API-key authentication,
least-privilege firewall rules, or keeping secrets out of Git.
When CLOUDFLARED_TUNNEL_TOKEN is present, run-paq-llamacpp-server.sh will start the cloudflared connector defined in cloudflared.compose.yaml, using Docker and the token file generated under .cloudflared/.
This repo uses a remotely-managed Cloudflare Tunnel, so the public hostname and origin service are configured in the Cloudflare dashboard rather than in a local cloudflared YAML config.
In the Cloudflare Zero Trust dashboard (Networks → Tunnels → your tunnel → Public Hostname), configure the origin Service depending on whether local TLS is enabled.
On native Linux (Docker Engine running directly on the host):
| TLS | Origin Service |
|---|---|
| Off (default) | http://127.0.0.1:8080 |
| On (SSL vars set) | https://127.0.0.1:8080 + No TLS Verify for self-signed certs |
On WSL2 with Docker Desktop (Docker runs in a separate docker-desktop VM):
| TLS | Origin Service |
|---|---|
| Off (default) | http://host.docker.internal:8080 |
| On (SSL vars set) | https://host.docker.internal:8080 + No TLS Verify for self-signed certs |
Why
host.docker.internalon WSL2? Docker Desktop on Windows/WSL2 runs the Docker engine in its owndocker-desktopVM, separate from your Ubuntu WSL2 distribution.network_mode: hostshares the Docker VM's network namespace — not your distro's — so127.0.0.1inside a container points to the wrong machine.host.docker.internalis Docker Desktop's special DNS name that always resolves to the WSL2 host where your services actually run.
Also avoid
localhost— it resolves to[::1](IPv6) first in some containers, which fails because the timeout proxy andllama-serverbind IPv4-only. Use127.0.0.1(native Linux) orhost.docker.internal(Docker Desktop).
Cloudflare Tunnel origin parameters such as keep-alives and connect timeouts do not raise the proxied 524 read timeout. If a request can take more than ~120 seconds before the first response bytes are available, you need one of these strategies:
- reduce first-byte latency below the Cloudflare limit
- enable the local timeout proxy described above so the origin emits early bytes
- move the long-running path off the proxied hostname
- use a Cloudflare Enterprise feature that increases the proxy read timeout
This repo includes:
set-gpu-power-limit.shnvidia-power-limit.service
The script queries the GPU power min/max limits, computes a target wattage from POWER_PERCENT, clamps the result to the valid range, and applies it using nvidia-smi -pl.
Defaults: GPU_INDEX=0, POWER_PERCENT=70.
Install it separately if you want the GPU power cap to come up automatically alongside the model service:
sudo ./install-systemd-service.sh --unit nvidia-power-limit.serviceOn this stack, the following settings are currently experimental:
PARALLEL>1KV_UNIFIED=1- especially the combination of both, and even more so when prompt/cache reuse is also enabled
Observed failure modes have included repeated or stale-looking responses, confusing slot selection, and instability under longer prompt-processing runs.
If correctness matters more than throughput, start from this safer profile in .env:
PARALLEL=1
KV_UNIFIED=0
PROMPT_CACHE=0
CTX_CHECKPOINTS=0
CACHE_RAM_MIB=0
CACHE_IDLE_SLOTS=0This project is designed for AI coding agents (GitHub Copilot in VS Code + CLI) via OpenAI-compatible endpoints. The llama.cpp web UI is not a supported client.
If you access the server through a browser, you may see 401/403 errors on service worker and tools endpoints. These are harmless and expected.
If different prompts appear to return the same answer, first disable cross-request reuse:
PARALLEL=1
KV_UNIFIED=0
PROMPT_CACHE=0
CTX_CHECKPOINTS=0
CACHE_RAM_MIB=0
CACHE_IDLE_SLOTS=0Then verify the API response shows usage.prompt_tokens_details.cached_tokens = 0.
Reconfigure and rebuild llama.cpp with CUDA enabled, then verify llama.cpp/build/CMakeCache.txt contains:
GGML_CUDA:BOOL=ON
If the process aborts with munmap_chunk(): invalid pointer under WSL2, suspect mixed driver libraries. Check the bridge and repair the runtime ordering with:
bash scripts/provision-wsl2-ubuntu.sh --no-buildDo not install a separate Linux NVIDIA driver in WSL2.
If you use Flash Attention with mixed KV cache types, the build must support GGML_CUDA_FA_ALL_QUANTS:BOOL=ON. Otherwise, use matching cache types such as f16/f16, bf16/bf16, or q8_0/q8_0.
Make sure both TLS env vars are set, both files exist, and the llama-server binary was built with SSL support.
Known failure mode: if the API key is kept as plain text in an old or stale model configuration, VS Code can send only the Authorization: Bearer scheme without the key value. Fix it from Chat: Manage Language Models by re-entering the endpoint key in VS Code's secure key prompt, saving the provider, and reloading VS Code.
This means Cloudflare connected to the origin but did not receive response bytes within its default ~120 second proxy read timeout. Practical fixes, in order of least drama:
- shrink cold prompt-prefill latency (smaller model, shorter prompt, prompt-cache reuse, fewer tools/history)
- enable
CLOUDFLARE_TIMEOUT_PROXY_MODE=streamif your client usesstream=true - enable
CLOUDFLARE_TIMEOUT_PROXY_MODE=optimisticif your client needs non-stream JSON - publish a non-proxied/direct path for very long requests
- or raise the Cloudflare proxy read timeout on an Enterprise zone
This project is licensed under the Apache License 2.0.
The embedded llama.cpp/ directory is its own upstream project and retains its own license, history, and Git metadata. See NOTICE for attribution.






