llamacpp-proxy is a small Rust HTTP proxy that lets coding-agent harnesses talk to any OpenAI Chat Completions-compatible backend — llama-server, Ollama, vLLM, or anything else that speaks /v1/chat/completions. It translates OpenAI Responses, Anthropic Messages, Gemini native APIs, and Ollama native APIs on the fly.
It listens locally, translates only the protocol pieces that the backend cannot parse natively, forwards to a single backend, and rewrites responses when the client expects a different wire format.
This crate is designed as a rigorous starting implementation with unit-tested protocol transforms. It includes:
- OpenAI Responses tool normalization for Codex-style non-function and namespace tools, with conservative flat namespace-call unprefixing by default.
- Anthropic Messages JSON Schema normalization for Claude Code tool schemas, with a conservative parser-compatible default and an opt-in semantic mode for verified backends.
- Gemini
generateContentandstreamGenerateContenttranslation to OpenAI Chat Completions, including strict-safe tool-call ID mapping for multi-turn function responses, synthetic call IDs for orphan tool results, part-order-preserving mixed content translation, content-based Gemini request detection, backend-model forwarding that ignores Gemini path model names, single-candidate request semantics, and HTTP-status-preserving Gemini error conversion. - Ollama native API translation for
/api/chat,/api/generate,/api/tags, and/api/show, plus synthetic model-lifecycle responses for/api/pulland/api/delete./api/showqueries backend/v1/modelsand reshapes the selected backend model record into Ollama show-detail JSON. Ollama chat and generate requests preserve Ollama's default streaming behavior by requesting OpenAI Chat Completions streaming unless the client sendsstream: false, converting backend SSE frames to Ollama NDJSON, parsing SSE frame boundaries over bytes so split UTF-8 survives intact, and converting backend JSON error bodies into Ollama-shaped NDJSON errors when a stream fails before SSE begins. - Health check with backend reachability and Ollama fallback probing.
- Concurrent async request handling through Tokio + Hyper.
- Streaming pass-through for ordinary paths, SSE translation for Gemini text/tool-call chunks, OpenAI Responses SSE metadata-preserving data rewrites, and Anthropic SSE response defaulting for Claude Code compatibility.
- Conservative request/response header forwarding that drops hop-by-hop headers and replaces inbound
Accept-Encodingwithidentityfor backend requests whose bodies may be translated. - Protocol-shaped error responses for malformed JSON on recognized protocol endpoints, backend failures, timeouts, and hard proxy limits; well-formed translation failures are logged with the raw request body and forwarded as-is best-effort.
cargo build --releaseFor a smaller binary, use:
cargo build --profile release-smallThe release profiles enable LTO, one codegen unit, panic = "abort", and symbol stripping.
./scripts/validate.shThe script runs formatting, Clippy, unit tests, release build, and a binary-size report.
llamacpp-proxy \
--listen 127.0.0.1:8081 \
--backend 127.0.0.1:8080 \
--backend-api-key llamacpp-local \
--backend-model local-model \
--gemini-listen 127.0.0.1:8082--backend accepts either ADDR:PORT or a full URL. If no scheme appears, the proxy assumes http://.
# Codex / OpenAI Responses API
export OPENAI_BASE_URL=http://127.0.0.1:8081/v1
# Claude Code / Anthropic Messages API
export ANTHROPIC_BASE_URL=http://127.0.0.1:8081
# Gemini CLI / Gemini native API
export GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:8082
# Ollama native API clients
export OLLAMA_HOST=http://127.0.0.1:8081Chat Completions clients such as aider and OpenCode can point at http://127.0.0.1:8081/v1 and will pass through unchanged.
llamacpp-proxy
Usage:
llamacpp-proxy [OPTIONS]
Options:
--listen <ADDR:PORT> Proxy listen address [default: 127.0.0.1:8081]
--backend <ADDR:PORT|URL> Backend server (llama-server, Ollama, vLLM, etc.) [default: 127.0.0.1:8080]
--backend-api-key <KEY> Backend API key [default: llamacpp-local]
--backend-model <MODEL> Backend model used for Gemini rewrites and synthetic protocol metadata [default: local-model]
--gemini-listen <ADDR:PORT> Optional second listener for GOOGLE_GEMINI_BASE_URL
--backend-timeout-secs <SECONDS> Backend timeout [default: 120]
--max-body-bytes <BYTES> Max request body [default: 67108864]
--no-gemini-hardcoded-classifier Forward Gemini flash-lite classifier requests instead of short-circuiting
--codex-namespace-response-mode <flat|experimental-wrapped>
Flat unprefixes namespaced calls [default: flat]; experimental-wrapped is opt-in only
--anthropic-schema-mode <compat|semantic>
compat forwards a small parser-safe schema subset [default: compat]; semantic preserves more standard constraints after backend verification
-h, --help Show help
The proxy rewrites every tool to a type: "function" tool before forwarding to the backend.
Namespace tools become flat function tools with a namespace prefix:
{"type":"namespace","name":"multi_agent_v1","tools":[{"type":"function","name":"close_agent"}]}becomes:
{"type":"function","name":"multi_agent_v1__close_agent"}For non-streaming responses and Responses SSE events, the default response rewrite is deliberately conservative: backend-returned calls such as multi_agent_v1__close_agent remain ordinary Responses function_call items, with only the synthetic namespace prefix stripped:
{
"type": "function_call",
"name": "close_agent",
"arguments": "{}"
}The project brief says empirical testing may show that flat unprefixing is enough, and no captured Codex fixture in this bundle proves an exact wrapper schema. To avoid inventing an unverified client contract, flat is the default. An opt-in --codex-namespace-response-mode experimental-wrapped mode still emits the earlier experimental wrapper for fixture testing only:
{
"type": "namespace_call",
"namespace": "multi_agent_v1",
"name": "close_agent",
"call": {
"type": "function_call",
"name": "close_agent",
"arguments": "{}"
}
}Do not promote experimental-wrapped to the default unless a captured Codex Responses fixture proves this exact schema.
The proxy normalizes Anthropic input_schema definitions before forwarding. The default --anthropic-schema-mode compat is intentionally conservative because backend Anthropic schema parser support varies:
- Adds missing
typefields instead of dropping untyped properties. - Lowercases known JSON Schema type names.
- Forwards only the small parser-safe subset:
type,description, objectproperties, filteredrequired, arrayitems, and boolean/objectadditionalProperties. - Moves parser-risky but semantically useful standard constraints such as
enum,const,default,pattern, string lengths, numeric bounds, array lengths, uniqueness, and property-count bounds into compactdescriptionnotes instead of putting those keys in the forwarded schema. - Inlines nullable single-branch
anyOf,oneOf, andallOfforms while adding a description note that null is accepted. - Merges same-type multi-branch object/array schemas when possible.
- Converts unsupported multi-branch or parser-unfriendly schema details into compact
descriptionnotes rather than silently discarding them. - Still strips extension-style fields that are known to make backends reject schemas.
Use --anthropic-schema-mode semantic only after testing the target backend with representative Claude Code schemas. Semantic mode preserves the larger set of standard JSON Schema constraints from earlier bundles.
For non-streaming Anthropic message-shaped responses and Anthropic SSE message/content-block events, the proxy adds safe Claude Code compatibility defaults if the backend omits them: top-level message type, role, stop_reason, stop_sequence, zero-valued usage, and per-content-block cache_control: {"type":"ephemeral"}. Existing fields are never overwritten. Anthropic error-shaped JSON bodies, including roots with type: "error" or an error object, pass through unchanged so backend and proxy errors keep their protocol shape.
The proxy maps:
GET /v1beta/models(and/gemini/v1beta/models) toGET /v1/models, translating the OpenAI model list into Gemini's model discovery format withname,displayName,supportedGenerationMethods, and available metadata. This enables gemini-cli's interactive model picker to show locally available models.POST /v1beta/models/{model}:generateContenttoPOST /v1/chat/completions.POST /v1beta/models/{model}:streamGenerateContentto streamed Chat Completions.- Nonstandard paths carrying Gemini-shaped JSON bodies to
POST /v1/chat/completionsas well.
The {model} segment in Gemini URLs is treated as informational only. Translated Chat Completions requests always use --backend-model for the outgoing model field, so a client path such as /v1beta/models/gemini-2.5-flash:generateContent does not leak gemini-2.5-flash to the backend. This matches a single-backend design where the backend server already owns model selection.
Content-based Gemini detection is intentionally narrow: the proxy requires a Gemini contents[].parts[] shape with Gemini indicators such as systemInstruction.parts, tools[].functionDeclarations[], generationConfig, or a contents-only request. OpenAI-style bodies with messages or input remain pass-through.
It translates Gemini contents, systemInstruction, supported single-candidate generationConfig fields, and tools[].functionDeclarations[] into OpenAI Chat Completions fields, then maps OpenAI responses back to Gemini candidates. Because this proxy intentionally maps only the first OpenAI choice to candidates[0], it does not forward Gemini generationConfig.candidateCount to OpenAI n; requesting multiple backend choices while dropping all but the first would be surprising and is outside the brief's required mapping. During request translation, every Gemini functionCall receives the deterministic OpenAI tool-call ID call_<safe-name>_<ordinal>, and later Gemini functionResponse parts resolve back to those generated IDs by function name and call order. If a request contains a Gemini functionResponse whose matching functionCall is absent from the transmitted history, the proxy now inserts a minimal synthetic assistant tool_calls[] message immediately before the translated tool result and reuses that generated ID; it never falls back to the bare function name as tool_call_id. Mixed text, functionCall, and functionResponse parts are emitted in the original part order instead of hoisting all tool results to the end of the content block. When the backend returns an OpenAI-style error for a Gemini request, the translated Gemini error body preserves the HTTP status code when it is an error status, and otherwise falls back to numeric OpenAI error codes or known OpenAI error types before defaulting to 500 INTERNAL; the JSON body no longer claims 500 INTERNAL for upstream 400, 401, 404, 429, or 504 responses.
By default, classifier-style gemini-*-flash-lite:generateContent requests without tools return a deterministic local classification response with route/category/type set to general. Use --no-gemini-hardcoded-classifier to forward those requests to the backend instead.
The proxy responds to Ollama health probes locally without contacting the backend:
GET /returns200 OKwithtext/plainbodyOllama is running.GET /api/versionreturns200 OKwith{"version":"0.15.0"}.
These are required by tools that verify Ollama is alive before sending requests (e.g., Codex's Ollama provider probes both endpoints during startup).
The proxy maps:
POST /api/chattoPOST /v1/chat/completionswith Ollama messages, tools, options, and response format normalized into OpenAI Chat Completions fields.POST /api/generatetoPOST /v1/chat/completionsby wrapping the prompt as a user message and the optionalsystemfield as a system message.GET /api/tagstoGET /v1/models, then converts the OpenAI model list into Ollamamodels[]entries. When a backend model omitscreated, the fallbackmodified_atuses a stable sentinel (1970-01-01T00:00:00Z) rather than the current clock, so repeated calls over the same backend state return identical JSON.POST /api/showto backendGET /v1/models, then selects the requested model when present, falls back to the single loaded backend model otherwise, and reshapes backend metadata into Ollamadetails,model_info,parameters,template,license,modified_at, andcapabilitiesfields.POST /api/pullandDELETE /api/deleteto synthetic success responses because the local wrapper owns model lifecycle.
Ollama chat and generate endpoints stream by default. The proxy mirrors that default by setting outgoing Chat Completions stream: true unless the inbound request sends stream: false. Streamed backend SSE payloads are converted to Ollama newline-delimited JSON chunks with application/x-ndjson; non-streaming backend JSON is converted to the corresponding Ollama response object. The proxy does not forward a client's Accept-Encoding header to the backend; it sends Accept-Encoding: identity because response translators consume backend JSON and SSE bytes directly. If a translated response still arrives with a non-identity Content-Encoding, the proxy returns a protocol-shaped error instead of parsing compressed bytes as JSON or SSE. The Ollama stream transformer scans for SSE event boundaries as bytes and decodes UTF-8 only after a full event has arrived, so transport chunking cannot corrupt multibyte content. When the backend answers a streaming request with anything other than text/event-stream, the proxy buffers that finite response before choosing an output shape: JSON success bodies are translated into Ollama JSON and emitted as one NDJSON line, JSON error bodies become Ollama-shaped NDJSON errors, and non-JSON bodies such as text/plain or text/html become one explicit Ollama NDJSON error line instead of an empty stream.
Ollama compatibility coverage now includes observed client-library traffic under fixtures/ollama/observed/ for ollama-python 0.6.2 and ollama-js 0.6.3 across /api/tags, /api/show, /api/pull, /api/delete, non-streaming chat/generate, streaming chat/generate, streaming chat with tools, and backend-error responses as actual client exceptions. Each observed request fixture records the client-emitted method, path, stable headers, JSON body, and the relevant companion artifact: expected Chat Completions rewrite, expected backend routing, deterministic JSON/NDJSON response, or client-observed error. fixtures/ollama/integration/ covers backend response transforms that clients cannot directly emit, including OpenAI SSE tool-call deltas to Ollama NDJSON, successful non-SSE upstream bodies that must not disappear into the stream transformer, compressed upstream metadata that must trigger a protocol-shaped error on translated paths, and /api/tags model-list rows that omit backend creation timestamps. scripts/validate_ollama_observed_fixtures.py checks the full matrix without a Rust toolchain, scripts/validate_proxy_compression_guard.py checks the compression-request and response-guard invariants, scripts/validate_ollama_tags_idempotency.py checks the stable /api/tags fallback, and scripts/capture_ollama_observed_fixtures.py can refresh the observed fixtures from the official clients.
For tool use, Ollama assistant tool_calls[] become OpenAI tool_calls[]. If an Ollama tool result arrives without a tool_call_id, the translator reuses the most recent generated call ID for that tool name; if no matching assistant call appears in the current request history, it inserts a minimal assistant tool_calls[] message before the translated tool result. This keeps OpenAI Chat Completions history structurally valid without cross-request state.
Ollama-specific options are forwarded only when they have Chat Completions equivalents: temperature, top_p, presence_penalty, frequency_penalty, seed, stop, and num_predict as max_tokens. Model names pass through unchanged so the backend or wrapper can resolve the loaded model.
The proxy distinguishes hard proxy failures from translation failures:
- Hard proxy failures, such as a body exceeding
--max-body-bytes, backend connection failure, and backend timeout, receive protocol-shaped JSON errors. Gemini error bodies use the same HTTP status code inerror.codeand map it to the closest Gemini canonical status string, such asINVALID_ARGUMENT,UNAUTHENTICATED,NOT_FOUND,RESOURCE_EXHAUSTED, orDEADLINE_EXCEEDED. - Malformed JSON on recognized translation endpoints returns a protocol-shaped
400without contacting the backend. This applies to/v1/responses,/v1/messages, Ollama JSON endpoints, and Gemini generation paths such as/v1beta/models/{model}:generateContentand/v1beta/models/{model}:streamGenerateContent. - Well-formed request translation failures, such as unexpected request shapes that cannot be normalized safely, are logged to stderr with method, path, protocol, byte length, and the raw request body, then forwarded to the backend at the original path after backend-incompatible field sanitization (see below).
- Responses from that fallback request are returned without response translation so backend errors or pass-through behavior propagate unchanged.
This matches the brief's split between malformed request bodies and translation failures: syntactically invalid JSON gets a protocol-shaped 400, while surprising but well-formed client requests fall back to best-effort pass-through.
All requests forwarded to the backend are sanitized to remove fields that local inference servers do not support:
reasoning_effortis removed from the top-level request body.thinkingis removed from the top-level request body.max_completion_tokensis renamed tomax_tokenswhenmax_tokensis absent; if both are present,max_completion_tokensis dropped.reasoning_contentandreasoningare removed from any message object in themessagesarray.
This sanitization applies to all protocols, including translation failure fallback and pass-through paths. Non-JSON request bodies pass through unchanged.
curl -s http://127.0.0.1:8081/health | jqA healthy response looks like:
{"status":"ok","backend_ok":true,"backend":"http://127.0.0.1:8080","backend_probe_method":"GET","backend_probe_path":"/health"}If the backend returns 404 on /health, the proxy falls back to GET / and treats a 200 response containing "Ollama is running" as healthy. The response includes backend_probe_path and primary_backend_status so operators can see which probe succeeded:
{"status":"ok","backend_ok":true,"backend":"http://127.0.0.1:8080","backend_probe_method":"GET","backend_probe_path":"/","primary_backend_status":404}If neither probe succeeds, the proxy returns 503 or 504 with backend_ok: false.
- The proxy keeps no cross-request state. It builds tool-name mappings per request and discards them after response forwarding.
- Unknown paths pass through without translation unless their JSON body is positively identified as Gemini-native
contents[].parts[]. - The proxy overwrites
AuthorizationwithBearer <backend-api-key>when--backend-api-keyis non-empty. - Request bodies are bounded by
--max-body-bytesbefore JSON parsing. /api/tagsdoes not invent wall-clockmodified_atvalues for backend model rows that lackcreated; it uses backend timestamps when available and otherwise emits the stable unknown timestamp1970-01-01T00:00:00Z.- Streaming transformers buffer only incomplete SSE frames and, for Gemini/Ollama tool calls, partial tool-call argument deltas until the upstream finish event. Gemini streaming emits Gemini JSON
data:frames and suppresses OpenAI's terminal[DONE]sentinel because it is not a Gemini JSON chunk. Ollama streaming emits newline-delimited JSON chunks, suppresses[DONE], maps any finite non-SSE upstream response to either translated one-line NDJSON or an explicit Ollama NDJSON error, and handles split UTF-8 by buffering raw bytes until an SSE frame boundary arrives. Observed Ollama client fixtures cover official Python and JavaScript request/error shapes across the native endpoint matrix; integration fixtures cover backend SSE tool-call response translation and finite non-SSE fallback behavior. OpenAI Responses and Anthropic SSE rewriting preserves originalevent:,id:,retry:, comment, and blank-frame metadata and rewrites onlydata:payloads.
Codex namespace tool calls use a conservative, evidence-gated response policy. Incoming namespace tools are flattened for the backend with collision-resistant namespace__tool names. Responses are translated back to ordinary function_call objects by stripping the synthetic prefix, including in streaming SSE output-item payloads. Responses SSE frame metadata is preserved exactly at the field level; only the JSON data: payload is replaced when translation is needed.
The previous wrapper shape (type: "namespace_call", namespace, name, nested call) remains available only via --codex-namespace-response-mode experimental-wrapped; it is intentionally documented as unproven because the bundle does not include captured Codex traffic that establishes this exact schema. Add real fixtures under fixtures/codex/observed/ before treating a wrapper mode as rigorous.