This project provides a custom inference container for running the Gemma 4 model (OpenVINO/gemma-4-E4B-it-int8-ov) using OpenVINO and exposing an OpenAI-compatible API.
The official openvino/model_server:weekly (version 2026.2.0) does not support Gemma 4 yet. Attempting to load the model into OVMS results in the following error:
[serving][error][servable_initializer.cpp:214] Error during llm node initialization for models_path: /models/OpenVINO/gemma-4-E4B-it-int8-ov/./ exception: Exception from ../../../../../repos/openvino.genai/src/cpp/src/visual_language/vlm_config.cpp:34:
Unsupported 'gemma4' VLM model type
Specifically, Gemma 4 is a Visual Causal LM that requires a custom branch of optimum-intel (support_gemma_4) and transformers==5.5.0 which are not yet integrated into the standard OVMS pipeline.
The following environment was used for development and benchmarking:
- CPU: Intel(R) Xeon(R) CPU E5-2643 v2 @ 3.50GHz (2 Sockets, 6 Cores/Socket, 2 Threads/Core = 24 vCPUs)
- RAM: 122 GiB
- OS: Ubuntu 22.04 (in a Docker container)
- Docker: 29.2.1
- Python: 3.10.x (inside container)
- OpenVINO: 2026.1.0
- Model ID:
OpenVINO/gemma-4-E4B-it-int8-ov - Format: OpenVINO IR (INT8 Quantized)
- Size on disk: 7.8 GB
- Memory usage: Requires ~27 GB RAM for stable loading and inference.
User → OpenAI Client → FastAPI Server (port 8000) → OVModelForVisualCausalLM → OpenVINO Runtime → Model files
The server is built with FastAPI and Uvicorn, using a ThreadPoolExecutor to handle blocking inference calls without stalling the event loop.
GET /health
Returns {"status": "ok", "model": "OpenVINO/gemma-4-E4B-it-int8-ov"}
GET /v1/models
Returns the model metadata in OpenAI format.
POST /v1/chat/completions
Supports both streaming and non-streaming requests.
Example Request:
curl -s http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "OpenVINO/gemma-4-E4B-it-int8-ov",
"messages": [{"role": "user", "content": "Hello!"}],
"max_tokens": 50
}'Pass "enable_thinking": true in the request body to activate the model's
reasoning step. When enabled, the model's internal chain of thought is included
in the response wrapped in <think>...</think> tags, prepended to the final
answer inside the standard content field.
Non-streaming example response content:
<think>
The user is asking about X. Let me reason through...
</think>
The answer is Y.
Streaming: thinking tokens are streamed inline as delta.content chunks,
starting with a <think>\n chunk and ending with \n</think>\n\n before the
answer begins. Clients that render <think> blocks (OpenWebUI, etc.) will
display the reasoning step automatically.
The enable_thinking field is silently ignored if THINKING_SUPPORTED is
false at startup (probe result logged on container start).
| Test Case | Latency | Input Tokens | Output Tokens | Throughput |
|---|---|---|---|---|
| Short response | 1.61s | 12 | 2 | 1.2 tok/s |
| Medium response | 34.46s | 19 | 78 | 2.3 tok/s |
| Long response | 47.17s | 16 | 110 | 2.3 tok/s |
- Mean Throughput: ~1.9 tokens/sec
- Resource Usage: ~28 GB RAM, ~100% CPU (scaled by thread count) during inference.
- Thread Tuning: Setting
OV_CPU_BACKEND_NUM_THREADSto the physical core count (12) provided the best balance. Setting it to 24 (all vCPUs) slightly decreased throughput due to context switching. - Async Execution: Using a dedicated
ThreadPoolExecutorin the FastAPI server ensures that multiple concurrent requests don't block the API, although actual inference is sequential on a single CPU device.
The previous version used "fake streaming" by splitting a fully generated response into words. This resulted in high TTFT (Time To First Token) and a poor user experience as the client received nothing until the entire generation was complete.
Implemented true token streaming using the transformers.TextIteratorStreamer class.
- Background Thread: The
model.generatecall runs in a dedicatedthreading.Thread, pushing tokens into the streamer's queue. - Asynchronous Yielding: The FastAPI server iterates over the streamer and yields tokens to the client as they arrive, significantly reducing TTFT.
- Special Tokens: Set
skip_special_tokens=Falseto ensure thinking tags (<|channel>thought) are captured and processed.
Gemma 4 Instruct's native reasoning mode was enabled and exposed.
- Activation: Passing
enable_thinking=Truetoapply_chat_templateinserts the<|think|>token into the system prompt. - Parsing: Added a robust
parse_thinkingfunction that identifies the<|channel>thoughtstart tag and the<channel|>end tag. - API Surface: Thinking blocks are embedded in the
contentfield wrapped in<think>...</think>tags for both streaming and non-streaming responses.
- Auto-detection: Added logic using
openvino.Core().available_devicesto automatically targetGPUif available, falling back toCPU. - Override: The
DEVICEenvironment variable can be used to force a specific device. - Precision: Enabled
INFERENCE_PRECISION_HINT: f32in theov_configto ensure high-quality output on varied hardware.
- Available devices:
['CPU'] - Using device:
CPU - Performance: Mean throughput remained stable at ~2.3 tok/s with
f32precision hint and real streaming enabled. - Memory: Increased Docker memory allocation to 64GB to handle the increased overhead of the reasoning blocks and precision hint.
-
Clone and Setup:
mkdir -p /root/gemma4-ovms cd /root/gemma4-ovms -
Download Model:
pip3 install huggingface_hub python3 -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='OpenVINO/gemma-4-E4B-it-int8-ov', local_dir='/opt/models/gemma-4-E4B-it-int8-ov')" -
Build Image:
docker build -t gemma4-ovms:latest . -
Run Server:
docker run -d \ --name gemma4-api \ -p 8000:8000 \ -v /opt/models/gemma-4-E4B-it-int8-ov:/models:ro \ --memory="64g" \ -e OV_CPU_BACKEND_NUM_THREADS=12 \ gemma4-ovms:latest -
Verify:
curl http://localhost:8000/health
- Bugs fixed —
temperaturewas silently ignored — all responses were greedy regardless of the parameter. We fixed this by correctly threadingtemperaturewhendo_sampleis active. Replaced the deprecatedget_event_loop()withget_running_loop(). Fixed a critical issue wherethread.join()was blocking the async event loop during streaming. Fixed streamfinish_reasonto correctly report"length"whenmax_tokensis hit, and"stop"otherwise. Improvedparse_thinkingto ensure a robust non-greedy match on reasoning blocks. Fixed_queue_counterto appropriately release state dynamically upon stream termination. - Concurrency protection — Added a strict module-level semaphore (
MAX_CONCURRENT_REQUESTS=1) initialized via anasynccontextmanagerlifespan. To prevent indefinite waiting, a queuing boundary (MAX_QUEUED_REQUESTS=4) intercepts new queries. Excess requests cleanly return a 503 response envelope:{"error": {"message": "Server at capacity, try again later.", "type": "server_error", "code": "503"}} - Streaming timing proof — Token streams arrive individually with discernible delays without blocking the event loop:
[1778508383.538] data: {"id": "chatcmpl-fef94c4d86a3", "object": "chat.completion.chunk", "created": 1778508383, "model": "OpenVINO/gemma-4-E4B-it-int8-ov", "choices": [{"index": 0, "delta": {"content": "2,"}, "finish_reason": null}]} [1778508384.756] data: {"id": "chatcmpl-fef94c4d86a3", "object": "chat.completion.chunk", "created": 1778508383, "model": "OpenVINO/gemma-4-E4B-it-int8-ov", "choices": [{"index": 0, "delta": {"content": "3,"}, "finish_reason": null}]} [1778508385.989] data: {"id": "chatcmpl-fef94c4d86a3", "object": "chat.completion.chunk", "created": 1778508383, "model": "OpenVINO/gemma-4-E4B-it-int8-ov", "choices": [{"index": 0, "delta": {"content": "4,"}, "finish_reason": null}]} [1778508387.224] data: {"id": "chatcmpl-fef94c4d86a3", "object": "chat.completion.chunk", "created": 1778508383, "model": "OpenVINO/gemma-4-E4B-it-int8-ov", "choices": [{"index": 0, "delta": {"content": "5,"}, "finish_reason": null}]} [1778508388.460] data: {"id": "chatcmpl-fef94c4d86a3", "object": "chat.completion.chunk", "created": 1778508383, "model": "OpenVINO/gemma-4-E4B-it-int8-ov", "choices": [{"index": 0, "delta": {"content": "6,"}, "finish_reason": null}]} - New parameters — Added
top_p,top_k, andrepetition_penalty— which are fully passed down into the OpenVINO layer and applied dynamically. Addedstopsequence functionality, implemented manually as best-effort post-hoc truncation applied immediately across text iterations. Rejection gating is active forn != 1. - Metrics — The
/metricsendpoint is instrumented viaprometheus-fastapi-instrumentator.# HELP python_gc_objects_collected_total Objects collected during gc # TYPE python_gc_objects_collected_total counter python_gc_objects_collected_total{generation="0"} 13565.0 python_gc_objects_collected_total{generation="1"} 2333.0 python_gc_objects_collected_total{generation="2"} 257.0 - Dockerfile pin — We pinned the optimum branch pointer on
2026-05-11. The explicit SHA utilized iseac389347523177511abe37908090d9e5c12e714protecting downstream containers from unexpected upstream logic transitions. - What was not changed —
context_lengthdynamically checksmodel_max_lengthbut applies a 128k safety barrier in instances where the default configurations contain placeholders (e.g.1000000000000000019884624838656) avoiding unexpected behavior limits across API consumers.
-
Configuration The following configuration block works natively with Hermes using the
customprovider.# ~/.hermes/config.yaml default_provider: local-ovms default_model: "OpenVINO/gemma-4-E4B-it-int8-ov" providers: local-ovms: type: openai_compatible base_url: "http://localhost:8000/v1" api_key: "not-needed" models: - id: "OpenVINO/gemma-4-E4B-it-int8-ov" context_length: 131072 display: show_cost: false auxiliary: compression: model: "OpenVINO/gemma-4-E4B-it-int8-ov"
-
Feature compatibility table
| Feature | Status | Notes |
|---|---|---|
Single query (-q) |
✅ | Fully operational |
| Streaming | ✅ | Timings verified; progressive token delivery |
| Token count in status bar | ✅ | Supported via stream_options.include_usage |
/reasoning high (thinking) |
✅ | Triggers <think> block generation |
/personality |
✅ | Supported |
| Multi-turn context | ✅ | Supported |
/usage |
✅ | Usage stats successfully report |
Session resume (-c, -r) |
✅ | Operational |
/background |
✅ | Background tasks successfully return |
| Image input (vision) | ✅ | Data URI base64 images properly decoded |
/compress |
✅ | Context compression fully operational |
-
Server.py changes made
- Mapped
reasoning_effort="high"to triggerenable_thinking=Truefor reasoning support. - Added
_decode_image_urlhelper in_prepare_inputsto interceptimage_urlpayloads (data URI format) and seamlessly convert them into PIL format for OpenVINO inputs. - Set
model_config = ConfigDict(extra='ignore')inside Pydantic schemas (e.g.ChatCompletionRequest) to prevent standard Hermes client headers (presence_penalty,seed,user) from throwing422 Unprocessable Entity. - Included
ResponseFormatschema fallback handler defaulting un-supported payloads to raw text logic. - Verified
/v1/modelsstrictly outputscontext_lengthmetric accurately readable by Hermes context boundaries.
- Mapped
-
Known limitations
- The OpenVINO server triggers a
RuntimeError: Infer Request is busyif thegen_threaddoes not successfully process early thread disconnects. A rigidfinallycatch prevents the API from totally halting, yet aggressive retries from Hermes can periodically provoke it.
- The OpenVINO server triggers a
-
Quick start
# Add the config locally hermes config set model.provider custom hermes config set model.default OpenVINO/gemma-4-E4B-it-int8-ov hermes config set model.base_url http://localhost:8000/v1 # Confirm operational integrity hermes chat -q "Say exactly: OK"
-
Bugs fixed
_decode_image_url: Hermes sends images as data URIs; the server previously crashed, so we added base64 to PIL decoding.reasoning_effort: Hermes uses this field for/reasoning high; we added it to the schema and mapped it toenable_thinking.ResponseFormat: Hermes occasionally requests{"type": "text"}; added a schema to accept it and warn if JSON mode is asked for.ConfigDict extra ignore: Hermes sends headers likepresence_penaltyanduser; explicitly ignoring extra fields prevents HTTP 422 errors.DEBUG_LOG_REQUESTS: Debug middleware flooded logs with base64 image data; it is now gated behind an environment variable._model_lock: The extra lock falsely implied multi-threading protection when the semaphore already guarantees it; it was removed.finally: pass: An empty, useless block wrapping the generator was removed for code cleanliness.system_fingerprint&max_context_length: The/v1/modelsendpoint lacked these; we added them for Hermes to accurately gauge token capacity.
-
Corrected Feature compatibility table
| Feature | Status | Notes |
|---|---|---|
Single query (-q) |
✅ | Fully operational. |
| Streaming | ✅ | Timings verified; progressive token delivery (no batching). |
| Token count in status bar | ✅ | Supported via stream_options.include_usage. |
/reasoning high (thinking) |
✅ | Triggers <think> block generation natively. |
/personality |
✅ | Supported. |
| Multi-turn context | ✅ | Supported. |
/usage |
✅ | Usage stats successfully reported. |
Session resume (-c, -r) |
✅ | Operational. |
/background |
✅ | Background tasks successfully return. |
| Image input (vision) | ✅ | Data URI base64 images properly decoded. |
/compress |
✅ | Context compression fully operational. |
-
Image Input Testing Image input successfully identified the color. Example response:
'The color of the square is blue.' -
Reasoning Mode Testing
/reasoning highproduced a visible<think>block. Example first 50 characters:<think>\nThe user wants to know the product of 13
-
Discovery endpoints added All five probed endpoints now return HTTP 200:
GET /versionGET /api/tagsGET /api/v1/modelsGET /v1/propsGET /props
-
Streaming diagnosis result Verification via
curlshowed progressive token delivery at the HTTP level (average gap ~800ms post-TTFT). TTFT on a warm model is ~40ms. CPU prompt processing remains the primary latency factor for large contexts. -
Streaming improvements
- Added
Cache-Control: no-cache, no-transformandX-Accel-Buffering: noheaders toStreamingResponseto prevent upstream buffering. - Implemented an explicit
: stream-startSSE comment immediately upon connection to force an initial TCP flush. - Added
FORCE_WORD_STREAMenvironment variable support to allow manual word-by-word fallback if native streamer batching is ever detected.
- Added
-
Hermes compatibility
- Multi-turn context retained after discovery endpoint additions.
- Image input (vision) remains fully functional.
/usageand reasoning modes confirmed working.- Zero tracebacks or 404s recorded in server logs during full Hermes regression suite.
- Thinking Boundary Protection (Fix 1): Added
_THINKING_BOUNDARY_TAGSguard to ensure thinking tags (<think>, etc.) are never stripped by the automated EOS cleanup, preserving reasoning mode integrity. - Usage Chunk Cleanup (Fix 2): Removed duplicate top-level
prompt_tokensandcompletion_tokensfrom the streaming usage payload, strictly adhering to the standardusageobject nesting. - Temperature-Zero Logic (Fix 3): Corrected
effective_do_sampleto ensuretemperature=0.0ortemperature=Nonestrictly triggers greedy decoding, preventing random sampling errors. - Dead Code Purge (Fix 4+5): Removed the unused
_thread_waiterfunction and the redundantAsyncIteratorimport. - Semaphore Safety (Fix 6): Implemented a robust
_semaphore_acquiredflag in the non-streaming path to guarantee semaphore release even if a request is cancelled immediately after acquisition. - Linting & Spacing (Fix 7): Added standard PEP 8 blank lines before the main completion endpoint.
- Word-Stream Kwarg Fix (Fix 8): Fixed a
TypeErrorin the_generate_word_chunksfallback wheredo_sampleandmax_new_tokenswere being passed as duplicate keyword arguments.
- EOS Strip List:
['<end_of_turn>', '<bos>', '<eos>'](Verified: thinking tags successfully excluded). - Determinism:
temperature=0.0verified deterministic across multiple runs. - SDK Compatibility: Fully verified with the standard OpenAI Python SDK for both streaming and non-streaming modes.