LLM API Proxy
vanzik v2.0updated view on github
The vanzik-llm-proxy binary speaks /v1/chat/completions
(OpenAI) and /v1/messages (Anthropic) wire formats and forwards
to the real upstream after gating any tool calls the model emits —
including tool calls that arrive inside an SSE stream.
For cross-cutting concerns (binary structure, audit transport tag,
deployment topologies, fail-mode flag) see
docs/PROXY_ARCHITECTURE.md.
1. Architecture#
┌─────────────────────────────────────┐
│ agent process (LangChain, raw │
│ OpenAI SDK, etc.) │
│ OPENAI_BASE_URL=http://...:8081 │
└──────────────────┬──────────────────┘
│ HTTP (chat/completions, messages)
│ Authorization: Bearer <user-token>
▼
┌─────────────────────────────────────┐
│ vanzik-llm-proxy │
│ (HTTP server, default 127.0.0.1:8081)│
│ │
│ ┌─ /v1/chat/completions (OpenAI) │
│ ├─ /v1/completions (legacy) │
│ ├─ /v1/messages (Anthropic)│
│ ├─ /v1/embeddings (passthru) │
│ ├─ /v1/models (passthru) │
│ └─ /healthz │
│ │
│ per-request: │
│ • parse body for tools[] │
│ • upstream HTTP client (passthrough │
│ Authorization) │
│ • streaming parser (OAI deltas / │
│ Anthropic events) │
│ • tool-call accumulator → policy │
│ check → forward / refuse-rewrite │
└──────────────────┬──────────────────┘
│ HTTP/HTTPS (passthrough)
▼
┌─────────────────────────────────────┐
│ api.openai.com / api.anthropic.com │
│ (or self-hosted OpenAI-compatible)│
└─────────────────────────────────────┘
─── policy-check side channel ───
proxy → http://127.0.0.1:8080/v1/check (central guard)
2. CLI surface#
vanzik-llm-proxy \
--listen 127.0.0.1:8081 \
--upstream-openai https://api.openai.com \
--upstream-anthropic https://api.anthropic.com \
--guard-url http://127.0.0.1:8080 \
--api-key $VANZIK_API_KEY \
--proxy-api-key $PROXY_AUTH_TOKEN \
--tenant-id local \
--fail-mode deny \
--max-buffer-bytes 1048576 \
--log-level info
| flag | meaning | default |
|---|---|---|
--listen |
address to bind (host:port). A non-loopback address — including an empty host such as :8081, which binds every interface — requires --proxy-api-key (§ 8.1). |
127.0.0.1:8081 |
--upstream-openai |
base URL for OpenAI-shape requests | https://api.openai.com |
--upstream-anthropic |
base URL for Anthropic-shape requests | https://api.anthropic.com |
--guard-url |
central server /v1/check URL (from VANZIK_URL when unset) |
http://127.0.0.1:8080 |
--api-key |
bearer for /v1/check and POST /v1/audit (from VANZIK_API_KEY, then the key vanzik setup saved). Required for full forced-refusal audit fidelity when the central server is keyed (§ 5.4 Audit fidelity); without it those audits fall back to the lower-fidelity /v1/check path. |
unset (warn) |
--proxy-api-key |
optional key the proxy itself enforces on inbound API requests; callers send it in the X-Vanzik-Proxy-Auth header (<key> or Bearer <key>). Empty = no proxy auth; required for a non-loopback --listen (§ 8.1). |
unset |
--tenant-id |
tenant header value | local |
--fail-mode |
deny / allow / fail-closed-with-audit |
deny |
--fail-audit-log |
local JSONL fallback audit for fail-closed-with-audit denials (empty disables) |
vanzik-fail-audit.jsonl |
--max-buffer-bytes |
per-stream tool-call buffer cap (see § 6). The flag accepts 1 byte – 64 MiB; a config carrying 0 ("operator cap disabled", reachable only by embedding the package — the flag rejects 0) is still bounded by a built-in 64 MiB absolute ceiling, past which the stream is refused fail-closed. |
1048576 (1 MiB) |
--max-concurrent-streams |
cap on simultaneously open streaming responses; excess requests are rejected (vanzik_llmproxy_streams_rejected_total) |
100 |
--policy |
path to Vanzik policy YAML; loaded only for tool_scope_map operator overrides. Without it, the proxy falls back to DefaultLLMToolScopeMap and logs a WARN at startup. |
unset |
--log-level |
stderr verbosity | info |
--version |
print version and exit (checked before any other flag is parsed) | — |
If --listen is non-loopback (0.0.0.0, [::], an external address,
or an empty host such as :8081) and --proxy-api-key is unset, the
proxy refuses to start — see § 8.1. --api-key is unrelated to this: it
is the proxy's credential for the central server's /v1/check.
3. Forwarding model#
3.1 Headers#
The proxy is dumb on auth: it forwards Authorization,
x-api-key (Anthropic), OpenAI-Organization, OpenAI-Project, and
all other request headers to the upstream verbatim — except
hop-by-hop headers, the proxy's own X-Vanzik-Proxy-Auth (§ 8.1) and
X-Vanzik-Approval-ID (§ 11.3), which are dropped. The proxy
never reads the user's bearer token; the upstream is responsible for
auth. The Authorization header for the central guard server is a
separate concern (see § 3.2) — the proxy adds it on the
/v1/check side channel only, never on the upstream-forwarded request.
User-Agent is rewritten to start with vanzik-llm-proxy/<version>,
followed by the client's original User-Agent when it sent one
(vanzik-llm-proxy/2.0.0 openai-python/1.x), so server-side logs at
OpenAI/Anthropic can identify proxied traffic. Spec-compliant: User-Agent is mutable per RFC 7231.
The Host header is rewritten to the upstream's authority. Cookie
headers (rare for LLM APIs) are forwarded unchanged.
Three request headers also name who is calling. The proxy copies them
into each tool call's /v1/check request (and forwards them upstream
like any other header):
| Header | /v1/check field |
Notes |
|---|---|---|
X-Agent-ID |
agent_id |
llm-proxy when absent. |
X-Session-ID |
session_id |
Keys cost.max_per_session and the audit's sessions. |
X-Vanzik-Parent-Session |
parent_session_id |
The session that started this one, e.g. the orchestrator's session when a sub-agent calls the model. Not used by policy; GET /v1/sessions and the dashboard's Sessions view use it to link the two. Omitted when the header is absent. |
3.2 Body handling#
Two paths:
Non-streaming (stream: false or absent)#
- Read full body into memory. Cap:
--max-buffer-bytes(1 MiB default). Larger requests get HTTP 413. - Parse JSON to inspect
tools(OpenAI) /tools(Anthropic) for policy-relevant context. The request itself is not gated — only the model's tool-call response is gated. - Forward the original bytes (not re-encoded JSON — preserves field order, whitespace, and any caller-specific quirks the upstream might key on).
- Stream the upstream response back to the client. If the response
contains
tool_calls(OpenAI) ortool_usecontent blocks (Anthropic), gate them before forwarding the response body. See § 5.
Streaming (stream: true or Accept: text/event-stream)#
- Forward request body (capped, but typically small).
- Open the upstream connection with
Accept: text/event-stream. - Read the upstream SSE response chunk-by-chunk via the streaming parser (§ 5).
- Pause and gate any tool calls before flushing the corresponding deltas to the client.
3.3 Pass-through endpoints#
/v1/embeddings and /v1/models are forwarded as-is, with no
policy check and no audit entry — there are no tool calls in the
response, so nothing reaches /v1/check. This is honest
about coverage: the proxy is for tool-call gating, not for blocking
the agent from talking to the model.
4. Tool-call → scope mapping#
The mapping table is shipped with the binary and overridable via
policy YAML, mirroring the MCP gateway approach (§ 4.4 of
MCP_GATEWAY.md).
4.1 Built-in defaults#
DefaultLLMToolScopeMap in
pkg/llmproxy/scope_map.go is a
[]policy.ToolScopeMapping{Pattern, Scope} list: a tool name (exact,
case-sensitive, or a prefix_* glob) and the scope it gates under.
It has 47 entries:
| Scope | Patterns |
|---|---|
shell |
Bash, PowerShell, bash, sh, shell, run_command, execute_command, cmd, system, exec |
filesystem |
Read, Write, Edit, MultiEdit, NotebookEdit, Glob, Grep, read_file, write_file, list_directory, list_files, file_read, file_write, edit_file, delete_file, create_directory, ls, cat, find, glob |
network |
WebFetch, web_search, fetch_url, http_request, http_get, http_post, search, fetch, url_request |
browser |
playwright_*, browser_*, chrome_*, firefox_*, selenium_*, navigate, click, screenshot |
The capitalised names are Claude Code's built-in tools. A mapping
carries no argument names: which arguments feed the check is fixed per
scope (pkg/llmproxy/gate.go):
| Scope | command |
Other fields |
|---|---|---|
shell |
the command / cmd / script / code argument; else the tool name |
— |
filesystem |
<tool_name> <path> |
path from path, file_path, notebook_path, filepath, target, target_path, filename, file, destination, src or dst; action inferred from the tool name (read / write / delete …) |
network |
<tool_name> <url> |
url from url, uri, endpoint or target_url; domain from that URL's host, else a domain / host / hostname argument |
browser |
<tool_name> <url> |
url and domain as for network; action is the tool name |
unmapped |
the tool name | — |
4.2 Policy-YAML override#
tool_scope_map is a top-level list in the policy YAML (same level as rules:) — there is no separate llm: section; the LLM proxy and the MCP gateway share the one key (see POLICY_REFERENCE.md):
tool_scope_map:
- pattern: custom_db_query
scope: network
- pattern: deploy_lambda
scope: shell
- pattern: "*_secret"
scope: data
Merge: policy entries before built-ins, first match wins. Same semantics as the MCP gateway.
4.3 Unmapped tools#
A tool name that matches no built-in or policy entry is dispatched
as scope: "unmapped" with the tool name in command and each
top-level argument as a redacted meta["arg_<key>"] entry.
Operators MUST configure an unmapped scope rule to control behaviour:
- scope: unmapped
deny:
- pattern: "*" # default-deny on anything we don't recognise
If no unmapped rule exists, the engine's standard fall-through
default-deny applies — fail-closed by design. A tool the policy
author has not seen is, by Vanzik's policy contract, denied.
5. Streaming pause / resume / rewrite — the hard one#
When the response is streaming and the model is calling tools, the proxy must:
- Read the SSE stream from upstream chunk by chunk.
- Parse tool-call deltas (different shape per provider — see § 5.1 and § 5.2).
- Accumulate until a tool call is complete.
- Pause the stream to the client (do not flush partial deltas yet — buffer them).
- Run
Engine.Checkon the assembled tool call. - ALLOW → flush buffered deltas to the client and resume the stream.
- DENY → rewrite the buffered deltas as a synthetic tool-result message containing Vanzik's denial; resume the stream with the rewrite.
- REQUIRE_APPROVAL → similar to DENY but the synthetic message includes the approval URL.
5.1 OpenAI streaming wire format#
Verified shape (cross-checked against the OpenAI Python SDK source tree and live captures; reference docs at https://platform.openai.com/docs/api-reference/chat/streaming and https://platform.openai.com/docs/guides/streaming-responses.)
SSE-formatted lines: data: <json>\n\n. Final delimiter: data: [DONE]\n\n.
A streaming chat-completion delta with tool calls:
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1730000000,"model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"read_file","arguments":""}}]},"finish_reason":null}]}
data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"path"}}]}}]}
data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\"/tmp/x"}}]}}]}
data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"}"}}]}}]}
data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}
data: [DONE]
Critical observations:
- Multiple tool calls in one assistant turn arrive interleaved by
tool_calls[i].index. The accumulator MUST key by index, not by delta arrival order. tool_calls[i].idmay appear only on the first delta for that index. Subsequent deltas for the same index have onlyfunctionfields.function.namemay be split across deltas in principle; in practice it always arrives whole on the first delta. The accumulator handles fragmentation for bothnameandargumentsdefensively.argumentsis a JSON-encoded string in the wire format (yes — the field is a string, not an object). Concatenating fragments and parsing the result yields the actual argument object.finish_reason: "tool_calls"on a delta with emptydeltasignals the assistant turn is complete. This is the gate trigger — every accumulated tool call must be policy-checked before we forward the next delta.
5.2 Anthropic streaming wire format#
Verified shape (against
https://platform.claude.com/docs/en/api/messages, fetched 2026-05-06.)
SSE event types arrive in this order for a single message that calls a tool:
event: message_start
data: { "type": "message_start", "message": { ... } }
event: content_block_start
data: { "type": "content_block_start", "index": 0,
"content_block": { "type": "tool_use",
"id": "toolu_01D7FLrfh4GYq7yT1ULFeyMV",
"name": "read_file",
"input": {} } }
event: content_block_delta
data: { "type": "content_block_delta", "index": 0,
"delta": { "type": "input_json_delta",
"partial_json": "{\"path\": \"/tmp" } }
event: content_block_delta
data: { "type": "content_block_delta", "index": 0,
"delta": { "type": "input_json_delta",
"partial_json": "/x\"}" } }
event: content_block_stop
data: { "type": "content_block_stop", "index": 0 }
event: message_delta
data: { "type": "message_delta", "delta": { "stop_reason": "tool_use" } }
event: message_stop
data: { "type": "message_stop" }
Critical observations:
- Each content block has its own
index(0-based). Multiple tool_use blocks in one message stream get distinct indices. content_block_startcarriesid,name, and an emptyinput: {}.content_block_deltawithdelta.type == "input_json_delta"carries apartial_jsonstring. Concatenate ALLpartial_jsonfor a given block index, parse the result as JSON.content_block_stop(with the matching index) signals the tool block is fully assembled. This is the gate trigger for that block. Anthropic gates per block, not per assistant message — this is the key shape difference vs OpenAI.message_delta { stop_reason: "tool_use" }is informational; gating has already happened.
5.3 Pause/resume mechanism (both providers)#
Per request, the proxy holds the tool call being assembled and the raw
upstream events held back until it is gated, capped by
--max-buffer-bytes. The pseudocode below shows the logic, not the code.
Algorithm (OpenAI):
for each SSE line from upstream:
parse(line) -> delta
append raw line bytes to bufferedBytes
bufferedSize += len(line)
if bufferedSize > maxBufferBytes:
abort: write a synthetic refusal block to client and return
for each delta.tool_calls[i]:
accumulator[i].appendName(delta.tool_calls[i].function.name)
accumulator[i].appendArgs(delta.tool_calls[i].function.arguments)
accumulator[i].id = first non-empty delta id observed for this index
if delta.finish_reason == "tool_calls":
// GATE TRIGGER — assistant turn complete.
decisions := for each accumulated tool_call: guard.Check(...)
if all ALLOW:
flush bufferedBytes to client // resume
bufferedBytes = nil; bufferedSize = 0
accumulator = {}
else:
// Don't flush the buffered deltas. Synthesize refusal:
write synthetic chunk to client (see § 5.4)
bufferedBytes = nil; bufferedSize = 0
accumulator = {}
// Continue reading upstream to drain it; discard remaining bytes
// unless there are no more tool calls coming (assistant turn done).
elif delta has no tool_calls AND no buffered tool calls:
// Plain content delta; flush immediately to keep TTFT low.
flush this delta to client
bufferedBytes = ... // reset to empty for non-tool deltas
Algorithm (Anthropic): identical structure, but:
- Gate per
content_block_stop(with matching index whosecontent_block_startwas atool_use), not perfinish_reason. - Plain
textblocks pass through immediately. Onlytool_useblocks buffer. - The buffer scope is the bytes for that one block, from
content_block_startthroughcontent_block_stop. Other content blocks (text, image) flow around the buffered tool block in arrival order — but if a text block arrives after an unflushedtool_useblock on the same message, we flush the tool block first (preserving order).
5.4 Synthetic refusal shape#
OpenAI#
When DENY (or REQUIRE_APPROVAL with a URL):
data: {"choices":[{"delta":{"content":"Vanzik denied this action.\n\nReason: <reason>\nRule: <rule>","role":"assistant"},"finish_reason":"stop","index":0}],"created":0,"id":"vanzik-refusal","object":"chat.completion.chunk"}
data: [DONE]
The agent SDK's normal text-completion handling engages — the model appears to have given up and returned plain text. No SDK changes required.
For REQUIRE_APPROVAL the content is:
Vanzik paused this action pending human approval.
Reason: <reason>
Rule: <rule>
Approval URL: <approval_url>
Approval ID: ap_<hex> (send it in the X-Vanzik-Approval-ID header on retry to resume after approval)
Wait for human approval, then retry the same call.
§ 11.3 covers the retry.
Design note on
toolrole injection. Injecting a syntheticrole: "tool"message with a faketool_call_idinstead has two problems: (a)role: "tool"messages are valid in request turns, not response deltas — the OpenAI response schema only emitsrole: "assistant"; (b) the agent's SDK would expect a pairedtool_calls[*].idit had already buffered, which the proxy did NOT flush. Synthesising assistant text is simpler and correct.
Anthropic#
For a denied tool_use block, the proxy emits a synthetic text
block in place:
event: content_block_start
data: { "type": "content_block_start", "index": <same>,
"content_block": { "type": "text", "text": "" } }
event: content_block_delta
data: { "type": "content_block_delta", "index": <same>,
"delta": { "type": "text_delta",
"text": "Vanzik denied this action.\n\nReason: <reason>\nRule: <rule>" } }
event: content_block_stop
data: { "type": "content_block_stop", "index": <same> }
event: message_delta
data: { "type": "message_delta", "delta": { "stop_reason": "end_turn" },
"usage": { "output_tokens": 0 } }
event: message_stop
data: { "type": "message_stop" }
Anthropic's content_block index is reusable — emitting a text block
at the same index that the tool_use block was buffered for is wire-
legal. The proxy stops reading the upstream there and closes the
message itself: its message_delta sets stop_reason to end_turn
(not tool_use) so the agent doesn't expect a tool result it's not
getting, and carries usage, which the Anthropic SDKs'
messages.stream() reads on every message_delta (without it they
raise AttributeError).
Malformed tool calls fail closed (v1.0)#
A tool call that completes with arguments that are not valid JSON —
routine on a max_tokens cutoff mid-arguments, and attacker-inducible —
is refused with the same synthetic-refusal shapes above, under the fixed
rule deny:llm_api_proxy:malformed_tool_call (stable; alert on it). The
refusal is recorded in the central audit trail with the exact DENY the
client received (see Audit fidelity below), the buffered malformed
bytes are discarded (never forwarded), and the accumulator is reset so
any later valid tool call in the same stream is still gated. Malformed
non-completion deltas are dropped silently, because corruption that
matters resurfaces at completion, where it denies.
Undecodable non-streaming bodies that carry a tool call fail closed#
A non-streaming response is gated by decoding it into the typed response struct and inspecting the tool calls it contains. A body that fails that decode is forwarded verbatim — Vanzik never rewrites an already-corrupt wire — with one exception.
Go's decoder is stricter than a client SDK's in one specific way: it
aborts on a type-mismatched field the struct declares, such as
"created": "1730000000" where the spec says integer. A Python or
TypeScript SDK parsing the same bytes ignores the odd field and executes
the tool_calls sitting beside it. So "Vanzik cannot decode it" does
not imply "the client cannot use it".
Before falling back to passthrough the proxy asks the narrower
question — does this body carry a tool call at all? — using a probe that
constrains only the path to tool_calls / tool_use and nothing else.
If one is present the response is refused under the fixed rule
deny:llm_api_proxy:undecodable_tool_call. If none is present the body
passes through unchanged.
Where to see it. The refusal is recorded in the central audit
trail with the DENY the client actually received, via the same
/v1/audit ingest path the malformed-tool-call refusal uses — so it
arrives tagged transport: llm_api_proxy under that stable rule string,
which is the signal to alert on. The proxy also increments
vanzik_llmproxy_undecodable_tool_call_total{provider}, but it counts
in the proxy process, which serves no /metrics (see
OBSERVABILITY.md). The central server's
/metrics does list all six vanzik_llmproxy_* series (the two
binaries share one metrics registry), but nothing there increments
them, so they always read 0. Don't alert on any of them; alert on the
audit rule.
Two details worth knowing:
- The refusal is not subject to
--fail-mode allow, matching the duplicate-JSON-key deny.--fail-modegoverns an unreachable guard; here the guard is healthy and simply cannot see what the client will execute, so honouringallowwould reopen the bypass. - A JSON
nullor an unknown extra field is not a type error — Go ignores both — so neither diverts a response down this path. Only a wrong-typed value does. A provider adding fields never trips it.
A non-zero rate on that counter usually means an OpenAI-compatible shim (vLLM, Ollama, LiteLLM, a cloud gateway) is stringifying a numeric field. Worth fixing at the source: each one is a call your agent did not get to make. Streaming responses are unaffected — they are gated by the accumulator, which has its own fail-closed defences.
Ungateable streams fail closed (v1.0)#
Some streams are not corrupt so much as unpartitionable: the bytes arrive, but there is no way to bind them to a tool call the gate can evaluate. Gating requires knowing which fragments belong to which call, and when one of those relationships is missing the firewall cannot honestly say what the client is about to execute. Those streams are refused rather than forwarded, each under its own stable rule string:
| Rule | Trigger |
|---|---|
deny:llm_api_proxy:tool_use_interleaved |
Anthropic. A second tool_use content block opened before the first closed, or a block's content_block_start.input carries real arguments and input_json_delta fragments stream for the same block. Two argument sources, no way to tell which one the client will use. |
deny:llm_api_proxy:orphaned_tool_input |
Anthropic. An input_json_delta arrived while no tool_use block was open. The fragments belong to a call the firewall never saw start, so no gate cycle can ever evaluate them. |
deny:llm_api_proxy:multi_choice_tool_calls |
OpenAI. Tool-call deltas arrived on more than one choices[].index. tool_calls[i].index is unique only within a choice, so two choices' fragments would merge into a single argument string the firewall gates and no client executes. |
All three discard the buffered bytes and stop reading upstream. None is
subject to --fail-mode allow: the guard is healthy, it simply cannot
see what the client will execute.
All three are recorded in the central audit trail, as are the two
buffer-cap refusals (deny:llm_api_proxy:buffer_overflow, emitted both
for one oversized SSE event and for cumulative arguments past
--max-buffer-bytes). Each entry carries the rule, tenant, agent and
provider, plus whatever tool identity the parser observed before
refusing — often none, since a stream refused because its bytes cannot
be bound to a call is by definition one where no call assembled.
n > 1 with tool calls is the only capability this costs, and it can't
be gated correctly anyway — the merged arguments would be wrong.
Every mainstream agent framework sends n = 1, which is untouched.
A stream that ends mid-tool-call is gated, not dropped#
When upstream reaches EOF — or sends [DONE] — while a tool call is
still buffered, the cycle is completed, not abandoned:
- Arguments that parse are gated normally and, on ALLOW, replayed byte-identical.
- Arguments truncated by the cut do not parse, so the cycle lands on
deny:llm_api_proxy:malformed_tool_callabove: denied and audited.
The buffered bytes are never flushed unexamined — that would hand the
client the ungated call this whole mechanism exists to gate, and
discarding them would leave the client an empty 200 with no audit
entry. [DONE] is treated as the end of stream in its own right, so a
gateway that holds the connection open after it does not delay the
verdict.
Audit fidelity#
For a proxy-manufactured refusal (the malformed-completion DENY above),
the engine verdict a plain /v1/check would log is the wrong verdict —
the engine might well ALLOW the garbage-projected request while the client
got DENY, so the trail would read ALLOW for a refused call. To avoid that,
the proxy POSTs the client-visible DENY to the central server's
POST /v1/audit ingest, which appends it verbatim (no re-evaluation).
POST /v1/audit is auth-gated. If the central server runs with
--api-key, give this proxy --api-key too — otherwise the audit POST
returns 401. On any /v1/audit failure the proxy does not drop the
entry: it best-effort falls back to the /v1/check audit path,
which still writes a transport-tagged entry (carrying the engine verdict,
not the DENY). So fidelity degrades to the lower-fidelity path without
--api-key, but audit coverage never does — a forced refusal is always
recorded one way or the other.
5.5 Byte-identity invariant on ALLOW#
Critical correctness property: in the ALLOW path, the bytes delivered to the client must be byte-identical to what the upstream sent. The proxy buffers raw bytes (not re-marshalled JSON) for exactly this reason. Re-marshalling could re-order fields, normalise whitespace, or change Unicode escape forms — any of which can break client SDKs that rely on byte-level invariants (some agents hash the delta stream for reproducibility).
CI replays captured upstream streams through the proxy with an ALLOW policy and asserts the byte diff is empty.
5.6 Pause/resume mechanism — buffer bound#
--max-buffer-bytes (default 1 MiB) caps the bytes buffered while
gating a single tool call. Rationale:
- Typical tool argument JSON is 50–500 bytes.
- Pathological cases (large file contents passed as a tool argument,
e.g., a
write_filewith a multi-megabyte payload) hit this cap. - Above the cap, the proxy emits a synthetic refusal under the rule
deny:llm_api_proxy:buffer_overflow. Its text starts "Vanzik refused this tool call: its accumulated arguments exceeded the gating buffer cap.", gives the reason "tool call arguments exceed gating buffer", and names the--max-buffer-bytesflag. The proxy counts it invanzik_llmproxy_buffer_overflow_total(process-local, like everyvanzik_llmproxy_*series; see Undecodable non-streaming bodies in § 5.4); operators can raise the limit if their use case needs it.
Why 1 MiB:
- Matches the central server's
MaxRequestBodySizefor /v1/check. - Captures 99% of real-world tool calls.
- Keeps the proxy memory bounded under load (N concurrent streams × 1 MiB worst case).
6. Concurrency model#
- Each request has its own buffer and parser state; no mutable state
is shared between requests. The upstream and
/v1/checkHTTP clients (and their connection pools) are shared. - When the client closes the connection, the proxy cancels both the
upstream read and any in-flight
/v1/checkcall, then drops buffers. - An SSE event is read up to its
\n\ndelimiter with no fixed line limit other than--max-buffer-bytes.
7. Audit shape#
One audit entry per tool call (not per stream, not per delta).
{
"schema_version": "v1",
"transport": "llm_api_proxy",
"timestamp": "2026-05-06T12:34:56.789Z",
"session_id": "<from x-session-id header; empty when absent>",
"agent_id": "<from x-agent-id header, or 'llm-proxy'>",
"request": {
"scope": "filesystem",
"parent_session_id": "<from x-vanzik-parent-session header; omitted when absent>",
"command": "read_file /tmp/x",
"path": "/tmp/x",
"action": "read",
"meta": {
"arg_path": "/tmp/x",
"mapped_scope": "filesystem",
"model": "gpt-4",
"provider": "openai",
"stream": "true",
"tool_call_id": "call_abc",
"tool_name": "read_file",
"transport": "llm_api_proxy"
}
},
"result": {
"decision": "ALLOW",
"matched_rule": "allow:filesystem:tmp",
"reason": "matched allow rule for /tmp/**"
},
"duration_ms": 4
}
Each top-level tool argument becomes a meta["arg_<key>"] entry,
redacted via pkg/notify.DefaultRedactor; command is redacted the
same way. stream is present only for streaming responses, and
approval_id is added when the request carried
X-Vanzik-Approval-ID.
For an unmapped scope: request.scope = "unmapped",
request.command = "<tool_name>", and the arguments are the same
redacted arg_<key> entries.
Dashboard chip: transport: "llm_api_proxy" (color: purple — distinct
from mcp_gateway blue and sdk green).
8. Auth posture#
8.1 Inbound (agent → proxy)#
By default, the proxy enforces no auth of its own and binds to
loopback (127.0.0.1). Treat this like the central guard server's
no-API-key fallback: localhost is the implicit trust boundary.
If --proxy-api-key is set, every API request (everything except
GET /healthz) must carry the key in the X-Vanzik-Proxy-Auth
header — as <key> or Bearer <key>. The key is compared in constant
time; a missing or wrong key gets 401 with
{"error":{"message":"unauthorized: X-Vanzik-Proxy-Auth missing or invalid","type":"vanzik_error"}}.
It is a separate header so it never collides with the caller's own
provider credential: Authorization / x-api-key (the OpenAI /
Anthropic key) pass through to the upstream unchanged, while
X-Vanzik-Proxy-Auth is stripped before forwarding. The proxy does
not re-key calls — the upstream sees the caller's credential.
If --listen is non-loopback and --proxy-api-key is unset, the
proxy refuses to start (it prints the reason to stderr and exits with
status 2). Non-loopback means 0.0.0.0, [::], an
external address, or an empty host (:8081), which binds every
interface; only 127.x.x.x, [::1] and localhost count as
loopback. Avoids accidental internet-exposed proxies.
8.2 Outbound to central guard#
The proxy uses --api-key as Authorization: Bearer <key> on every
/v1/check call. Same shape as the SDKs.
8.3 Rate limiting#
The proxy itself does not rate-limit. The central guard server's
existing per-scope per-agent rate limiter applies — synthetic DENY
with Rule="deny:ratelimit:<scope>" flows back to the proxy via the
/v1/check response and is rewritten as a synthetic refusal in the
stream. Operators can tune the limits in policy YAML
(rate_limit.max_requests / rate_limit.window).
9. Test strategy#
The tests use scripted fake upstreams (Go httptest in pkg/llmproxy,
with captured streams in pkg/llmproxy/testdata; Python mocks in
plugins/python/tests/integration), so CI needs no provider key.
10. Currently out of scope#
- Google Gemini wire format (different shape again, uses
functionCallcontent parts in a streaming JSON-line format). - Per-model cost gating beyond the existing cost-scope rules — the
proxy uses
scope: costagainst the agent-suppliedest_cost, not actual model token usage. - Tool-result inspection — gating the agent's response to a tool's output (e.g., refuse to act on data containing PII patterns). The proxy gates request-side only.
- Caching of policy decisions for repeat tool calls inside one stream.
The proxy makes one
/v1/checkcall per tool call. - The OpenAI Assistants API (different wire format, different streaming
envelope). Supported endpoints are
/v1/chat/completionsand/v1/messagesonly.
11. Client integration#
Vanzik ships working examples for the four most common ways agents
talk to OpenAI / Anthropic: the raw OpenAI and Anthropic Python SDKs,
LangChain (langchain-openai), and CrewAI (LiteLLM under the hood).
Each example is a runnable Python script with a paired .md
walkthrough. The fastest end-to-end is
docs/QUICKSTART_LLM_PROXY.md.
The examples need Vanzik 2.0 or later: older proxies lack the approval
retry header (X-Vanzik-Approval-ID, § 11.3), and their Anthropic
refusal streams crash messages.stream().
| SDK | Example | Path convention |
|---|---|---|
| OpenAI Python | examples/openai-sdk-config.py + openai-sdk-config.md |
OPENAI_BASE_URL=http://127.0.0.1:8081/v1 (with /v1 suffix) |
| Anthropic Python | examples/anthropic-sdk-config.py + anthropic-sdk-config.md |
ANTHROPIC_BASE_URL=http://127.0.0.1:8081 (no /v1 — the SDK appends it) |
| LangChain | examples/langchain-agent-config.py + langchain-agent-config.md |
ChatOpenAI(base_url="http://127.0.0.1:8081/v1") (1.x signature) |
| CrewAI | examples/crewai-agent-config.py + crewai-agent-config.md |
LLM(base_url="http://127.0.0.1:8081/v1", model=..., api_key=...) |
11.1 Per-SDK base-URL summary#
| SDK | Constructor parameter | Env var | Path convention | Verified version |
|---|---|---|---|---|
| OpenAI Python | base_url= |
OPENAI_BASE_URL |
/v1 suffix |
openai==2.35.1 (2026-05-05) |
| Anthropic Python | base_url= |
ANTHROPIC_BASE_URL |
no /v1 |
anthropic==1.17.0, messages.stream() incl. refusals (2026-10-21) |
LangChain ChatOpenAI |
base_url= |
(inherits OpenAI SDK) | /v1 suffix |
langchain-openai 1.x (2026-05-05) |
LangChain ChatAnthropic |
base_url= |
(inherits Anthropic SDK) | no /v1 |
langchain-anthropic 1.x (2026-05-05) |
CrewAI LLM |
base_url= |
OPENAI_API_BASE / OPENAI_BASE_URL |
/v1 suffix (OpenAI-shape) |
crewai 1.14.x (2026-05-05) |
11.2 Verifying the integration#
Once the proxy is running and your code points at it:
- The proxy logs a startup line. Stderr:
vanzik-llm-proxy <version> listening on 127.0.0.1:8081 (...). If you don't see this, the proxy didn't start — check--listenand policy paths. - The first SDK call appears on the dashboard. Send any
tool-using prompt — within a second the dashboard at
http://127.0.0.1:8080/dashboard shows an event with
transport=llm_api_proxy(purple chip), the resolved scope, and the tool name ascommand. If nothing appears, the proxy isn't reaching the central server's/v1/check— check--guard-urland--api-key. - An ALLOW rule round-trips. With the bundled
configs/default.yaml, ask the model tols /tmpvia abash-named tool. The dashboard logsALLOW; the SDK receives the original streamed tool-call deltas byte-identically. - A DENY rule rewrites the stream. The bundled policy denies
dd if=/dev/*underscope: shell. Prompt the model to rundd if=/dev/zero of=/dev/sda. The dashboard logsDENY; your code receives a synthetic assistant text starting withVanzik denied this action.instead of any tool-call deltas.
If actions never reach Vanzik, the SDK is probably still talking
to the real upstream — verify the env var is set for the process
that runs your script (not just the parent shell), and that the SDK
constructor isn't overriding base_url from elsewhere.
11.3 Common gotchas#
Both binaries must be running. The proxy alone gates nothing —
every tool call calls back to the central server's /v1/check, and
--fail-mode decides what happens when that server is down (full
table: PROXY_ARCHITECTURE.md § 6.1).
Three API keys, three purposes. Provider key (forwarded verbatim,
never read), VANZIK_API_KEY (the /v1/check side-channel
bearer), and the optional inbound --proxy-api-key
(X-Vanzik-Proxy-Auth) — the header split is spelled out in § 3.1
and § 8.1.
Anthropic's path convention. ANTHROPIC_BASE_URL should be
http://127.0.0.1:8081 — no /v1 suffix. The Anthropic SDK appends
/v1/messages itself. Setting http://127.0.0.1:8081/v1 produces
double-prefixed paths and 404s. This is the most common
configuration mistake; double-check the env var when debugging.
Resuming after an approval takes the X-Vanzik-Approval-ID
header. When Vanzik returns REQUIRE_APPROVAL, the proxy emits a
synthetic refusal with the approval ID and URL embedded (§ 5.4). To
resume:
- Approve it on the dashboard (or
POST /v1/approve/<id>). - Send the request again with the header
X-Vanzik-Approval-ID: <id>— for the Python SDKs,extra_headers={"X-Vanzik-Approval-ID": approval_id}. - When the model emits the same tool call, the proxy passes the id to
/v1/checkasapproval_id, Vanzik honors the approval, and the tool call reaches your code asALLOW.
The server's replay checks still apply: the id unlocks only the action
that was approved (same agent, scope, command, path, domain, URL and
action), only once, and only within the approval-validity window.
Anything else in the retried response is evaluated fresh, so a
different command is paused or denied as usual. Without the header a
retry gets a new REQUIRE_APPROVAL with a new id. The proxy reads the
header for /v1/check only and does not forward it upstream. (The MCP
Gateway carries the same id in _meta.dev.vanzik/approval_id; see
docs/APPROVAL_WORKFLOW.md.)
Streaming vs non-streaming both work. Streaming goes through the
pause/resume/rewrite gater (§ 5); non-streaming responses are buffered
in full (capped by --max-buffer-bytes) and gated before forwarding.
Concurrent calls are safe — per-request parser state is isolated (§ 6).
Custom tool names need a tool_scope_map: entry. Vanzik
ships a default mapping for 47 common tool names (§ 4.1; see
pkg/llmproxy/scope_map.go).
Anything else dispatches as scope: unmapped, which the engine
treats as default-deny unless an unmapped rule exists. For custom
tools, add an entry under your policy's tool_scope_map: section
(see docs/POLICY_REFERENCE.md § "LLM API Proxy tool scope mapping") — the proxy hot-reloads via the same mechanism the central server uses.
Self-hosted OpenAI-compatible upstreams. The proxy's
--upstream-openai flag accepts any OpenAI-compatible base URL
(LM Studio, vLLM, llama.cpp's --api-key server, Together AI,
Anyscale, etc.). The streaming wire format is the same; the
/v1/chat/completions parser handles them all. For Azure OpenAI,
note the path convention is different (/openai/v1/... not
/v1/...) — point --upstream-openai at the Azure base URL
including the resource path.
12. References#
- OpenAI Python SDK (verified 2026-05-05):
- https://github.com/openai/openai-python — README documents
base_url=andOPENAI_BASE_URL. Latest release v2.35.1. - https://platform.openai.com/docs/api-reference/chat/create
- https://platform.openai.com/docs/api-reference/chat/streaming. The streaming wire shape in § 5.1 is cross-checked against the OpenAI Python SDK source tree.
- https://github.com/openai/openai-python — README documents
- Anthropic Python SDK (verified 2026-05-05):
- https://github.com/anthropics/anthropic-sdk-python — README +
src/anthropic/_client.pydocumentbase_url=andANTHROPIC_BASE_URL. Default base URL ishttps://api.anthropic.comwith no/v1suffix; SDK appends/v1/messagesetc. itself. - https://platform.claude.com/docs/en/api/client-sdks
- https://platform.claude.com/docs/en/api/messages
- https://github.com/anthropics/anthropic-sdk-python — README +
- LangChain (verified 2026-05-05):
- https://docs.langchain.com/oss/python/integrations/chat/openai —
langchain_openai.ChatOpenAIbase_url=parameter. - https://reference.langchain.com/python/langchain-anthropic/chat_models/ChatAnthropic —
langchain_anthropic.ChatAnthropicbase_url=parameter.
- https://docs.langchain.com/oss/python/integrations/chat/openai —
- CrewAI (verified 2026-05-05):
- https://docs.crewai.com/en/learn/llm-connections —
LLM(...)base_url=parameter;OPENAI_API_BASEenv var honored.
- https://docs.crewai.com/en/learn/llm-connections —
- Architectural reference (no feature copying):
- go-mitmproxy stream addons: https://github.com/lqqyt2423/go-mitmproxy
- LiteLLM proxy structure: https://github.com/BerriAI/litellm
- Cross-cutting design:
docs/PROXY_ARCHITECTURE.md.