Ollama-compatible and OpenAI-compatible API proxy for local Intel NPU inference via OpenVINO.
NPU Proxy is a local, single-user developer-workstation tool. It binds to loopback by default, has no authentication by design, and should not be treated as a shared production proxy.
This repository currently ships:
- FastAPI app with OpenAI-style chat, embeddings, and model listing endpoints
- Ollama-style generate, chat, embeddings, pull, tags, version, and model-discovery endpoints
- Windows start scripts plus Linux packaging assets
- Optional real inference mode backed by local OpenVINO model directories
- Default CLI bind:
127.0.0.1:8080 - Default runtime device:
NPU - Default token limit:
1800 - Default workers:
1 - Default LLM timeout:
180seconds - Default mode: mock responses unless
NPU_PROXY_REAL_INFERENCE=1or--real-inferenceis set - OpenAI chat streaming: SSE (
text/event-stream) - Ollama streaming: newline-delimited JSON chunks (
application/x-ndjson) - Default LLM model path:
~/.cache/npu-proxy/models/tinyllama-1.1b-chat-int4-ov - Host-header allow-list: loopback and test clients by default (
localhost,127.0.0.1,::1,[::1],testserver,test)
Real inference now uses per-request routing for OpenAI and Ollama generation APIs. The context router classifies each prompt as NPU, GPU, or CPU, and the runtime acquires an engine for that routed device instead of silently reusing a single global engine.
X-NPU-Proxy-Routed-Devicereports the router's choice.X-NPU-Proxy-Execution-Deviceand the existingX-NPU-Proxy-Devicereport the device that actually executed.X-NPU-Proxy-Fallback-Reasonappears only when execution differs from the routed device.- Requests for the same
(model, device)are serialized by default; busy devices return503unless explicit busy fallback is enabled. /healthexposes the LLM engine pool, and Prometheus metrics count routed-vs-executed device pairs.- Device matching is accelerator-aware: a
GPUrequest resolves to OpenVINO's enumeratedGPU.0/GPU.1instead of silently falling back to another device. Per-request routing is certified on real Intel hardware forNPU,GPU, andCPU.
Think of the router as a train switch now: short prompts can stay on the NPU track while long prompts can move to the configured fallback track.
- NPU Proxy is documented as a local developer-workstation process, not a production gateway.
- Mock mode is the default; real inference must be explicitly enabled.
- Host-header allow-listing, path hardening, and sanitized errors are implemented.
/api/tagsis implemented and/api/showreturns registry-backed model metadata.- OpenAI and Ollama generation responses now expose stop/length reasons.
- Real generation requests route to per-device engine pools with truthful routing/execution headers.
- Embedding inputs are validated before engine execution, with a batch limit of 128.
- Python 3.10+
- Windows 11 host with current Intel NPU drivers for NPU-backed inference
- OpenVINO/OpenVINO GenAI packages from
requirements.txt
pip install -r requirements.txtReal LLM inference expects the default model directory to exist at:
~/.cache/npu-proxy/models/tinyllama-1.1b-chat-int4-ov
One way to populate it is:
pip install huggingface-hub
hf download OpenVINO/TinyLlama-1.1B-Chat-v1.0-int4-ov --local-dir ~/.cache/npu-proxy/models/tinyllama-1.1b-chat-int4-ov# CLI defaults to 127.0.0.1:8080 in mock mode
npu-proxy
# Real inference on the default local bind
npu-proxy --real-inference
# Explicit host/port
npu-proxy --host 127.0.0.1 --port 8080 --real-inference.\scripts\start-server.ps1That script defaults to loopback (127.0.0.1) and port 11435 unless NPU_PROXY_HOST or NPU_PROXY_PORT is already set. Binding all interfaces requires explicit opt-in:
.\scripts\start-server.ps1 -ListenAll
# or
$env:NPU_PROXY_LISTEN_ALL = "true"
.\scripts\start-server.ps1If you bind beyond loopback, also set NPU_PROXY_ALLOWED_HOSTS or --allowed-hosts so legitimate remote Host headers are allowed.
python -m uvicorn npu_proxy.main:app --host 127.0.0.1 --port 8080
$env:NPU_PROXY_REAL_INFERENCE = "1"
python -m uvicorn npu_proxy.main:app --host 127.0.0.1 --port 8080Invoke-RestMethod http://127.0.0.1:8080/healthIndustry-standard probes are also available:
Invoke-RestMethod http://127.0.0.1:8080/health/liveness
Invoke-RestMethod http://127.0.0.1:8080/health/readinessHealth contract:
/healthis an observational summary and does not auto-load models/health/livenessis the cheap process-up probe/health/readinessreports whether warmed runtime state is actually ready to serve traffic- when models are not loaded, health surfaces say that explicitly instead of hiding it behind probe-triggered initialization
Point Ollama-compatible clients at the local NPU Proxy server:
$env:OLLAMA_HOST = "http://127.0.0.1:8080"
ollama pull tinyllama
ollama run tinyllama "What is 2+2?"
ollama psIf you use scripts\start-server.ps1 without overriding its port, use http://127.0.0.1:11435 instead.
WSL 2, when the Windows host intentionally listens beyond loopback:
WINDOWS_HOST=$(ip route show | grep default | awk '{print $3}')
export OLLAMA_HOST="http://${WINDOWS_HOST}:11435"
ollama pull tinyllama
ollama run tinyllama "Hello"Windows:
.\scripts\ollama-launch-claude.ps1WSL 2:
./scripts/ollama-launch-claude.shBoth scripts set OLLAMA_HOST and check /health before launching claude --provider ollama.
from openai import OpenAI
client = OpenAI(
base_url="http://127.0.0.1:8080/v1",
api_key="not-needed",
)
response = client.chat.completions.create(
model="tinyllama-1.1b-chat-int4-ov",
messages=[{"role": "user", "content": "Say hello"}],
)
print(response.choices[0].message.content)
print(response.choices[0].finish_reason) # "stop" or "length"import OpenAI from "openai";
const client = new OpenAI({
baseURL: "http://127.0.0.1:8080/v1",
apiKey: "not-needed",
});| Method | Path | Notes |
|---|---|---|
| GET | /v1/models |
Lists built-in registry models plus scanned local models |
| POST | /v1/chat/completions |
OpenAI-style chat; streaming uses SSE |
| POST | /v1/embeddings |
OpenAI-style embeddings |
OpenAI chat responses set choices[].finish_reason to "stop" or "length". Streaming responses set finish_reason on the final delta chunk before data: [DONE].
| Method | Path | Notes |
|---|---|---|
| GET | /api/tags |
Lists locally available models in Ollama format |
| POST | /api/generate |
Non-streaming JSON or streaming NDJSON chunks |
| POST | /api/chat |
Non-streaming JSON or streaming NDJSON chunks |
| POST | /api/embed |
Current Ollama embeddings format |
| POST | /api/embeddings |
Legacy Ollama embeddings format |
| GET | /api/ps |
Running-model view |
| POST | /api/show |
Registry-backed model metadata, parameters, template, and modelfile |
| GET | /api/version |
Returns the NPU Proxy Ollama-compatible version string |
| POST | /api/pull |
Downloads model files from Hugging Face |
| GET | /api/search |
NPU Proxy extension for OpenVINO model search |
| GET | /api/models/known |
NPU Proxy extension listing short-name mappings |
Ollama generate/chat final responses and final NDJSON frames include done: true and done_reason ("stop" or "length").
| Method | Path |
|---|---|
| GET | /health |
| GET | /health/liveness |
| GET | /health/readiness |
| GET | /health/devices |
| GET | /metrics |
curl "http://127.0.0.1:8080/api/search?q=llama&sort=popular"
curl "http://127.0.0.1:8080/api/search?type=llm&quantization=int4"/api/search accepts:
qsort=popular|newest|downloads|likeslimitoffsettype=all|llm|embedding|visionquantizationmin_downloads
The vision filter exists on the search endpoint, but this repository does not document vision serving support.
curl -X POST http://127.0.0.1:8080/api/pull \
-H "Content-Type: application/json" \
-d '{"name": "tinyllama", "stream": false}'Known short-name mappings currently include:
- LLMs:
tinyllama,tinyllama:fp16,phi-2,phi-3,llama2,llama2:13b,llama3.2,mistral,qwen2,gemma - Embeddings:
bge-small,bge-base,bge-large,e5-small,e5-large,all-minilm,nomic-embed-text
Model downloads enforce Hugging Face allow-patterns and size limits; full-snapshot download is opt-in. Conversion work runs in a timeout-safe child process and publishes with an atomic directory rename so partially written model directories are not exposed as complete.
Current embedding behavior is request-aware:
- the service caches embedding engines by resolved model and device
NPU_PROXY_EMBEDDING_MODELandNPU_PROXY_EMBEDDING_DEVICEprovide the default embedding model and device- request
modelfields plus device overrides can select a different cached engine when a runtime-ready export exists - if the requested runtime-ready model is missing or unusable, the request fails by default instead of returning success-shaped fallback embeddings
- set
NPU_PROXY_EMBEDDING_FALLBACK_MODE=hashonly when you intentionally want explicit hash fallback for operator testing or wiring checks
Embedding inputs are validated before engine execution:
- empty input list: HTTP 400, code
empty_input - more than 128 inputs: HTTP 413, code
embedding_batch_too_large - whitespace-only or empty text: HTTP 400, code
empty_input - oversized text: HTTP 413, code
embedding_input_too_large - successful batches return exactly one finite, correctly dimensioned vector per input
Current workstation truth:
- the documented default embedding device remains
CPU all-minilm/sentence-transformers/all-MiniLM-L6-v2validated onNPUhere via a static-shape profilebge-smallonNPUdid not validate here because the Intel NPU plugin failed withcheck_sdpa_nodes(model)- download or registry support for an embedding model is not the same as validated NPU execution support
Recommended validated NPU setup:
python scripts\download_model.py download all-minilm
$env:NPU_PROXY_EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
$env:NPU_PROXY_EMBEDDING_DEVICE = "NPU"Then restart the server so it picks up the new embedding defaults.
NPU Proxy is intentionally a local, single-user tool:
- it binds to
127.0.0.1by default - it has no authentication or API key enforcement by design
- do not expose it as a shared production proxy
Current hardening:
- Host-header allow-list middleware rejects disallowed
Hostheaders with HTTP421and bodyHost header not allowed, mitigating DNS rebinding against the local service - the default allow-list is
localhost,127.0.0.1,::1,[::1],testserver, andtest - configure the allow-list with
NPU_PROXY_ALLOWED_HOSTSor--allowed-hosts(comma-separated) - binding to a non-loopback address logs a warning;
scripts\start-server.ps1requires explicit opt-in (-ListenAllorNPU_PROXY_LISTEN_ALL=true) before choosing0.0.0.0 - registry model names are slug-validated, resolved model paths are confined to the model directory, and tokenizer paths are guarded against traversal
- client-facing errors are sanitized so internal exception details and stack traces are not leaked
- OpenVINO remains the default LLM backend.
- OpenAI and Ollama chat prompt rendering attempts tokenizer chat templates when available and falls back to legacy formatting; set
NPU_PROXY_DISABLE_CHAT_TEMPLATES=1to force legacy formatting. - Compile cache controls exist through
NPU_PROXY_COMPILE_CACHE_DIR,NPU_PROXY_COMPILE_CACHE_MODE, andNPU_PROXY_PREFIX_CACHE_MODE. - An alpha
llama.cppGGUF path exists behindNPU_PROXY_LLM_BACKEND=llama_cppandNPU_PROXY_ENABLE_ALPHA_BACKENDS=1, but it is currently CPU-only, feature-gated, and source-install only becausellama-cpp-pythonis not part of the default packaged dependencies.
See:
| Flag | Default | Notes |
|---|---|---|
--host |
127.0.0.1 |
Bind address |
--port, -p |
8080 |
Bind port |
--workers, -w |
1 |
Uvicorn worker processes |
--reload |
off | Enable development auto-reload |
--allowed-hosts |
loopback/test clients | Comma-separated Host-header allow-list |
--device, -d |
AUTO CLI parser; effective runtime default NPU |
NPU, GPU, CPU, or AUTO; AUTO leaves the LLM runtime default in place |
--token-limit |
1800 |
Advisory context-routing threshold |
--compile-cache-dir |
unset | Optional OpenVINO compile cache directory |
--compile-cache-mode |
runtime default | OPTIMIZE_SIZE or OPTIMIZE_SPEED |
--prefix-cache-mode |
auto |
auto, on, or off |
--real-inference |
off | Enable real model inference; otherwise mock mode |
--log-level, -l |
info |
debug, info, warning, error, or critical |
--log-file |
unset | Log to file instead of stdout |
--version, -v |
n/a | Print version and exit |
| Variable | Default | Notes |
|---|---|---|
NPU_PROXY_HOST |
127.0.0.1 |
Bind address |
NPU_PROXY_PORT |
8080 |
Bind port |
NPU_PROXY_DEVICE |
NPU effective runtime default |
LLM device; CLI also accepts AUTO |
NPU_PROXY_TOKEN_LIMIT |
1800 |
Advisory context-routing threshold |
NPU_PROXY_REAL_INFERENCE |
0 |
Set 1 to enable real inference |
NPU_PROXY_INFERENCE_TIMEOUT |
180 |
LLM inference timeout seconds |
NPU_PROXY_MAX_PROMPT_LEN |
4096 |
LLM prompt limit |
NPU_PROXY_COMPILE_CACHE_DIR |
unset | Optional OpenVINO compile cache directory |
NPU_PROXY_COMPILE_CACHE_MODE |
runtime default | OPTIMIZE_SIZE or OPTIMIZE_SPEED |
NPU_PROXY_PREFIX_CACHE_MODE |
auto |
Prefix cache mode |
NPU_PROXY_LLM_BACKEND |
openvino |
openvino or alpha llama_cpp |
NPU_PROXY_ENABLE_ALPHA_BACKENDS |
0 |
Required for alpha backends |
NPU_PROXY_LLAMACPP_MODEL_PATH |
unset | Local .gguf path for alpha llama.cpp backend |
NPU_PROXY_ALLOWED_HOSTS |
loopback/test clients | Comma-separated Host-header allow-list |
NPU_PROXY_PREFERRED_DEVICE |
NPU |
Context-router preference for short prompts |
NPU_PROXY_FALLBACK_DEVICE |
auto | Context-router fallback override for long prompts |
NPU_PROXY_DEVICE_QUEUE_TIMEOUT |
30 |
Seconds to wait for an in-use (model, device) slot |
NPU_PROXY_FALLBACK_ON_BUSY |
0 |
Set 1 to try fallback devices when the routed device is busy |
NPU_PROXY_WARMUP_DEVICES |
unset | Optional comma-separated startup warmup devices, e.g. NPU,CPU |
Invalid TOKEN_LIMIT, PREFERRED_DEVICE, FALLBACK_DEVICE, warmup device, busy fallback, and queue-timeout values warn and fall back to defaults in lazy runtime paths; explicit startup validation can still fail for invalid bootstrap settings.
| Variable | Default | Notes |
|---|---|---|
NPU_PROXY_MODEL_DIR |
~/.cache/npu-proxy/models |
Model/tokenizer lookup root |
NPU_PROXY_DISABLE_CHAT_TEMPLATES |
unset | Truthy value forces legacy chat formatting |
NPU_PROXY_EMBEDDING_MODEL |
BAAI/bge-small-en-v1.5 |
Default embedding model |
NPU_PROXY_EMBEDDING_DEVICE |
CPU |
Default embedding device |
NPU_PROXY_LOAD_TIMEOUT |
300 |
Embedding model load timeout seconds |
NPU_PROXY_EMBED_TIMEOUT |
60 |
Embedding inference timeout seconds |
NPU_PROXY_EMBEDDING_CACHE_SIZE |
1024 |
Embedding cache size |
NPU_PROXY_EMBEDDING_FALLBACK_MODE |
disabled | Set hash only for explicit operator fallback tests |
NPU_PROXY_EMBEDDING_UNAVAILABLE_COOLDOWN |
30 |
Cooldown seconds after embedding load failures |
NPU_PROXY_LISTEN_ALL |
unset | scripts\start-server.ps1 opt-in for 0.0.0.0 |
The benchmark CLI currently documents one end-to-end workflow:
python scripts\benchmark.py run --model tinyllama --device NPU --iterations 5 --warmup 1 --output results.jsonSee docs/guides/BENCHMARKS.md for the current CLI surface.
python -m pytest -m "not slow and not e2e"Fast tests do not require real NPU/model hardware. Slow/e2e tests do.
uvicorn npu_proxy.main:app --reload --host 127.0.0.1 --port 8080
uvicorn npu_proxy.main:app --reload --host 127.0.0.1 --port 8080 --log-level debug- Real LLM inference needs a local OpenVINO model directory.
- Mock mode is the default unless real inference is explicitly enabled.
- OpenAI chat streaming and Ollama streaming use different wire formats.
- Per-request LLM device routing is implemented: each generation executes on the device the context router classifies for it (
NPU/GPU/CPU). The prompt-size classification is a heuristic threshold, not a guarantee of optimal placement. - Validated NPU embedding support is currently limited to the static-shape
all-minilmpath;bge-smallstill failed workstation validation on NPU. - The alpha GGUF backend is intentionally not documented as a packaged/default runtime path.
- NPU Proxy is documented for native host deployment; this repo does not document containerized NPU serving.