English | 中文
A Python async HTTP middleware that makes any text-only chat model vision-capable.
llm-visionrelay accepts images from OpenAI Chat Completions, Anthropic Messages, or
OpenAI Responses clients, runs them through a vision model to produce a structured
text description, replaces the image blocks with that description, and forwards the
result to your text model — all while speaking the client's original protocol.
- Multi-protocol input — auto-detected by path:
POST /v1/chat/completions(OpenAI Chat Completions)POST /v1/messages(Anthropic Messages)POST /v1/responses(OpenAI Responses)
- Vision extraction pipeline — images are fetched (base64 data URLs,
http(s)://), stored in a content-addressed cache, and described by the vision model in a native-multimodal style: a dense natural-languagedescriptionplus structured elements (ocr,objects,layout,relationships) each with normalized and pixel bounding boxes (bbox/bbox_px). The image is then replaced with that context before being sent to the text model. - Multi-layer caching — SQLite metadata + content-addressed file store + in-process
LRU +
singleflightconcurrency dedup. The same image is only sent to the vision model once; the same URL is only re-downloaded when its alias expires. - Tenant isolation — cache keys use the SHA-256 of the
Authorizationheader (or an explicitX-Vision-Cache-Namespace); raw keys are never stored. Tenants cannot read each other's images or results. - Built-in vision tools — the middleware injects
__vision_tools so the text model can request further analysis, and executes them itself:__vision_list_images,__vision_analyze__vision_crop,__vision_resize,__vision_mask(real image processing via Pillow)
- Upstream protocol support — the text model upstream can be OpenAI Chat Completions,
Anthropic Messages, or OpenAI Responses (
X-Upstream-Protocol, defaultchat); when the client and upstream share a protocol with no images, requests are proxied verbatim. - Upstream vision detection — if the upstream model itself declares image input
(
input_modalitiesincludesimage), images are passed through untouched instead of being re-processed by the vision model (X-Upstream-Vision: auto|true|false). - Streaming — SSE is proxied/translated in the client's protocol; internal tool calls are buffered and re-emitted as a valid stream.
- No environment-variable config — all credentials, model names, and API addresses come from HTTP request headers; nothing is read from the environment.
- Security — SSRF protection for remote images, atomic writes, WAL SQLite, redacting structured logs, path-traversal protection.
Requires Python 3.12+.
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"python -m llm_visionrelay \
--host 0.0.0.0 \
--port 8080 \
--cache-dir ./dataCLI options (all runtime settings come from the command line):
| Option | Description | Default |
|---|---|---|
--host / --port |
listen address / port | 127.0.0.1 / 8080 |
--cache-dir |
cache directory | ./data |
--max-image-size |
max single image size (MiB) | 20 |
--max-images-per-request |
max images per request | 8 |
--max-total-image-bytes |
max total image bytes per request (MiB) | 50 |
--timeout |
upstream text model timeout (s) | 60 |
--vision-max-concurrency |
max concurrent vision calls per (base-url, key, model) group | 8 |
--vision-max-retries |
vision retries on 429/5xx/transport errors | 2 |
--management-token |
optional token for /internal/* endpoints (sent via X-Management-Token) |
none |
Run the tests:
.venv/bin/ruff check .
.venv/bin/ruff format .
.venv/bin/python -m pytestcurl http://127.0.0.1:8080/v1/chat/completions \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer TEXT_MODEL_KEY' \
-H 'X-Upstream-Base-URL: https://text-model.example.com' \
-H 'X-Vision-Base-URL: https://vision.example.com/v1' \
-H 'X-Vision-Model: vision-model-name' \
-H 'X-Vision-Authorization: Bearer VISION_MODEL_KEY' \
--data-binary @request.json{
"model": "text-model",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "Analyze this diagram"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,...."}}
]
}
]
}The middleware:
- Extracts the image, stores it content-addressed, and asks the vision model for a structured description.
- Replaces the image block with the description, wrapped in an explicit
<visual_attachment>marker flagged as untrusted data. - Forwards the text-only request to
{X-Upstream-Base-URL}/chat/completionswith yourAuthorization. - Returns the upstream response in the original protocol.
Caching means the same image is only sent to the vision model once — subsequent
requests hit the cache (X-Vision-Cache: HIT).
| Header | Purpose |
|---|---|
Authorization |
text-model key, forwarded verbatim to the upstream (never sent to the vision model) |
X-Upstream-Base-URL |
text-model API base URL (required) |
X-Upstream-Model |
optional override for the request model |
X-Upstream-Protocol |
upstream protocol: chat (default) / anthropic / responses |
X-Upstream-Vision |
auto (default) / true / false — pass images through when the upstream is vision-capable |
X-Vision-Base-URL |
vision model base URL |
X-Vision-Model |
vision model name |
X-Vision-Authorization |
vision model key, sent only to the vision endpoint (bare keys like sk-... are auto-prefixed with Bearer for standard APIs) |
X-Vision-Header-* |
extra headers forwarded to the vision endpoint |
X-Vision-Auto-Analyze |
auto-generate a summary for each image (default true) |
X-Vision-Tools |
inject __vision_ tools (default true) |
X-Vision-Cache-TTL |
vision result / URL alias TTL in seconds (default 30 days) |
X-Vision-Force-Refresh |
bypass the summary cache (default false) |
X-Vision-Cache-Namespace |
tenant namespace (default: derived from Authorization) |
X-Vision-Params |
extra JSON body params for the vision request (e.g. thinking / reasoning effort) |
X-Vision-Reasoning |
vision-model thinking: on / off / auto (default auto). off sends thinking: disabled |
X-Vision-Reasoning-Effort |
override the vision reasoning level: none / low / medium / high / max / auto (default auto; falls back to the next lower supported level) |
X-Vision-Reasoning-Budget |
cap the vision chain-of-thought tokens (0 disables), overrides --vision-reasoning-budget; keeps thinking from starving the answer (default 2048, clamped to half of max_tokens) |
X-Vision-Max-Tokens |
cap the vision model's output tokens (1–200000), overrides --vision-max-tokens; prevents chain-of-thought runaway |
X-Vision-Max-Images |
per-request image count cap (1–4096), overrides --max-images-per-request |
X-Vision-Max-Image-Bytes |
per-request single-image size cap in MiB (1–200) |
X-Vision-Max-Total-Image-Bytes |
per-request total image bytes cap in MiB (1–2048) |
X-Request-ID |
request id echoed on the response (generated if absent) |
X-Vision-Header-* headers have count and length limits, and cannot override
Host, Content-Length, Connection, Transfer-Encoding, Content-Type,
Authorization, or Accept.
Generation parameters are translated between client and upstream protocols so they are not lost when protocols differ:
| Parameter | Chat Completions | Anthropic Messages | OpenAI Responses |
|---|---|---|---|
| output cap | max_tokens / max_completion_tokens |
max_tokens |
max_output_tokens |
| sampling | temperature, top_p |
temperature, top_p |
temperature, top_p |
| stop | stop |
stop_sequences |
— |
| tool choice | tool_choice |
tool_choice (auto/any/tool) |
tool_choice |
| reasoning | reasoning_effort |
thinking (budget) |
reasoning: {effort} |
| metadata | metadata |
metadata |
metadata |
| parallel tools | parallel_tool_calls |
— | parallel_tool_calls |
| user / store | user |
— | user, store |
| structured output | response_format |
— | text: {format} |
Chat requests also preserve unknown extension fields verbatim. Streamed responses
carry the same mappings (e.g. reasoning_content becomes an Anthropic thinking
block or a Responses reasoning output item).
The vision model is called with the same reasoning intensity the agent requested
(reasoning_effort / reasoning.effort), overridable per request via
X-Vision-Reasoning / X-Vision-Reasoning-Effort. If the vision model does not
support a level that high, the middleware automatically falls back to the next
lower supported level (supported levels are configurable via
vision_reasoning_levels, default low/medium/high). Reasoning level and
thinking toggle are part of the vision cache key, so different intensities never
reuse each other's analysis. Vision output tokens are capped (vision_max_tokens,
default 8192) so a small reasoning model cannot loop its chain-of-thought
forever, and the chain-of-thought budget is capped (vision_reasoning_budget,
default 2048, clamped to half of max_tokens) so thinking can never eat the
whole output and leave an empty answer. No timeout is imposed on the vision model
— only the client agent's own disconnect/interrupt stops it.
- Images are content-addressed:
{cache_dir}/objects/sha256/ab/cd/<sha256>, referenced asimg_sha256_<64-hex>. Identical bytes always map to the sameimage_ref. - First time: the image is stored and the vision model produces a structured summary, persisted in SQLite.
- Later requests with the same image reuse the summary — no vision call.
- Same URL: not re-downloaded while the URL alias is valid; when it expires, a conditional
request (
If-None-Match/If-Modified-Since) checks whether the content changed. - If a client disconnects mid-analysis, the summary batch keeps running in the background and caches the finished results, so a resumed session never re-reads the images.
- Concurrent requests for the same image are deduplicated via
singleflight. - If the vision model fails and an expired cache entry exists, it is used with a warning marker rather than failing the request.
Response headers:
X-Vision-Cache: MISS # first / all misses
X-Vision-Cache: HIT # all hits
X-Vision-Cache: MIXED # some hits, some misses
X-Vision-Image-Refs: img_sha256_xxx,img_sha256_yyy
When X-Vision-Tools: true and the request contains images, the middleware appends
__vision_ tools (executed internally, never returned to the client):
__vision_list_images— list available image refs and cached summaries (no vision call).__vision_analyze— targeted analysis of an image (query / mode / bbox / force_refresh), with its own result cache.__vision_crop— crop an image to a normalized region, returning a newimage_ref.__vision_resize— resize an image to a target pixel size, returning a newimage_ref.__vision_mask— mask a region (blur/highlight/dim), returning a newimage_ref.
Crop/resize/mask results are real image processing (Pillow), stored content-addressed and registered to the current tenant, so the model can chain operations (e.g. crop then analyze a detail region).
Client-defined tools are preserved; names colliding with the reserved __vision_ prefix are
rejected with HTTP 400. Internal rounds are capped at 4 and vision tool calls at 8 per
request; beyond that an error is injected and internal tools are disabled.
docker compose up -d --buildServes http://localhost:8080. The cache lives in ./data. Build-time proxy settings can
be supplied via HTTP_PROXY / HTTPS_PROXY / NO_PROXY environment variables.
By default only reachable from loopback; with --management-token set, requests must send
X-Management-Token.
curl http://127.0.0.1:8080/healthz
curl http://127.0.0.1:8080/internal/cache/stats
curl http://127.0.0.1:8080/internal/progress/<request_id>
curl -X DELETE 'http://127.0.0.1:8080/internal/cache?all=true'
curl -X DELETE 'http://127.0.0.1:8080/internal/cache?namespace=client-42'
curl -X DELETE 'http://127.0.0.1:8080/internal/cache?image_ref=img_sha256_<64hex>'
curl -X DELETE 'http://127.0.0.1:8080/internal/cache?expired=true'While a request is analyzing images, GET /internal/progress/<request_id> reports live
progress: phase, images_done / images_total, elapsed time, average time per image and
estimated remaining time (eta_ms). The request_id is echoed in the X-Request-ID
response header. Per-image progress is also written to the logs
(vision progress request_id=... images=N/M elapsed_ms=... eta_s=...).
llm_visionrelay/
├── app.py # FastAPI app, request orchestration, SSE handling
├── cache_db.py # SQLite (WAL) persistent cache
├── cli.py # CLI entry point
├── config.py # process configuration
├── errors.py # protocol-agnostic error model
├── headers.py # request-header parsing, tenant derivation
├── image_fetcher.py # base64 / URL image fetching (SSRF-guarded)
├── image_store.py # content-addressed object store (atomic writes)
├── imaging.py # image dimension / MIME sniffing
├── imaging_tools.py # crop / resize / mask (Pillow)
├── logging.py # structured, redacting logs
├── message_transform.py # image blocks → untrusted text context
├── models.py # Pydantic request models
├── protocols.py # client protocol adapters (chat / anthropic / responses)
├── security.py # tenant / SSRF / path validation
├── tool_loop.py # built-in vision tools + tool-call loop
├── upstream.py # low-level upstream HTTP client
├── upstream_models.py # cached upstream model capability registry
├── upstream_protocols.py# upstream protocol render / parse adapters
└── vision_client.py # vision model client + LRU + singleflight
- All API credentials, model names, and addresses are supplied via HTTP headers; no
environment-variable or
.envconfig loading. - Tenant isolation via SHA-256 digests; raw keys are never persisted or logged.
- Remote images are SSRF-checked (loopback / link-local / private / reserved networks and DNS-rebinding resolution are rejected), with redirect and download-size limits.
- Atomic file writes, WAL SQLite, and path-traversal protection.
- Structured logs redact credentials and never record request bodies or image payloads.