Images for Text-Only Models (Vision Fallback)
Some models behind AIR accept images (Qwen-VL, Qwen 3.x omni/VL builds, GPT-4o, Gemini),
others do not (GLM, gpt-oss). Clients rarely care: OpenWebUI, IDE agents and SDK scripts
send an image_url to whichever model is selected, and a text-only vLLM model answers with
an opaque 400 (... is not a multimodal model).
Vision fallback lets you declare which models cannot see images and decide what AIR does with an image sent to them:
| Mode | What happens | Cost |
|---|---|---|
reject (default) |
400 with a clear message, before any upstream call. No retries, no fail2ban hits |
none |
strip |
Every image is replaced with a text placeholder; the request goes on | none |
describe |
Images of the current turn are described by a vision model you choose, and the description replaces the image. Older images become placeholders | one extra call per new image |
Models that never declared supports_vision are not touched: images pass through as before.
vLLM only
Vision fallback applies only to models served exclusively by vllm
credentials. A supports_vision: false on a model reachable through any other provider
(OpenAI, Anthropic, Vertex, an AIR/proxy peer, ...) — alone or mixed with vLLM
credentials — is ignored, and its requests are forwarded unchanged. The first request with
images to such a model logs a WARN once:
supports_vision: false is ignored: the model is served by a non-vLLM credential.
Configuration
models:
- name: glm-text
credential: vllm_glm_a
supports_vision: false # text-only: vision_fallback applies
- name: glm-text
credential: vllm_glm_b
supports_vision: false
- name: qwen-vl
credential: vllm_qwen
supports_vision: true # optional; images pass through either way
- name: gpt-oss
credential: vllm_gpt_oss
supports_vision: false
vision_fallback:
mode: describe # reject | strip | describe
describe_model: qwen-vl # vision-capable model served by this AIR
max_images: 4 # images described per request (default 4, 0 = no limit)
max_tokens: 1024 # max_tokens of each describe call (default 1024)
timeout: 2m # per describe call (default 2m)
inject_into_response: true # write descriptions into the answer (default true)
# describe_prompt: "..." # system prompt of the describe call (a detailed default is built in)
supports_vision (per model)
| Value | Meaning |
|---|---|
| omitted | unknown — images are forwarded unchanged (previous behaviour) |
true |
the model accepts images — forwarded unchanged |
false |
the model cannot see images — vision_fallback applies (vLLM-only models) |
The flag is per model name. When the same name is served by several entries and one
of them says false, the whole name is treated as text-only, because a request may land on
that deployment.
Models loaded from the LiteLLM database take the flag
from model_info.supports_vision (the field LiteLLM itself uses). The same rule applies across
both sources: a false from config.yaml or from the database makes the name text-only, a
true from one source never hides a false from the other.
Aliases (model_group_alias, public_model_alias, model_alias) are resolved first, so the
flag of the target model applies.
vision_fallback (global)
| Parameter | Default | Description |
|---|---|---|
mode |
describe if describe_model is set, otherwise reject |
What to do with images sent to a supports_vision: false model |
describe_model |
— | Model used to describe images. Required for describe. Must be a model this AIR serves; a model declared supports_vision: false in config.yaml is rejected at startup |
describe_prompt |
built-in | System prompt of the describe call |
max_images |
4 |
Images of one request that are described; further images become placeholders. 0 = no limit |
max_tokens |
1024 |
max_tokens of each describe call |
timeout |
2m |
Timeout of each describe call |
inject_into_response |
true |
Write the descriptions at the start of the answer so later turns restore them (see Descriptions in the answer) |
All values accept os.environ/VAR.
timeout may exceed server.write_timeout: nothing is written to the client while images are
described, so the write deadline is lifted for that step, and the rest of the request gets a full
write_timeout afterwards. Keep the client's own timeout in mind — it sees no bytes until the
describe step is over.
How describe works
A conversation is split at the last model output (the last assistant message; in the
Responses API also the last reasoning or *_call item). Everything after it is the current
turn.
- Each image of the current turn (user message or tool result, e.g. a screenshot returned
by an agent tool) is sent to
describe_modelon its own. The user's question is not passed: the description is reused on every later turn, so it must cover the whole image, not only what the first question asked about.
{
"model": "qwen-vl",
"max_tokens": 1024,
"stream": false,
"messages": [
{"role": "system", "content": "Describe this image as precisely and completely as possible: ..."},
{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}
]}
]
}
Several images are described in parallel.
- The image part is replaced with a text part:
[Image 1, described by qwen-vl because glm-text cannot see images]
A brown owl sits on a birch branch at night. ...
-
Images before the current turn are never described again. Each gets the description recorded in the answer to its turn (see Descriptions in the answer), or
[image from an earlier turn omitted]when there is none. -
The rewritten request goes to the text-only model as usual (streaming included), and the descriptions of this turn are written at the start of its answer.
The response carries X-AIR-Vision-Fallback, a comma-separated list of:
| Value | Meaning |
|---|---|
described=N/M |
N of the M images of the current turn were described; the rest became placeholders (failed call, max_images, file_id) |
restored=K |
K history images got their description back from an earlier answer |
stripped |
only placeholders: strip mode, or history images without a recorded description |
N < M is a partial result; described=0/M means every describe call failed.
Example
First request — the image is new, so it is described:
{
"model": "glm-text",
"messages": [
{"role": "user", "content": "привет"},
{"role": "assistant", "content": "Привет! Чем могу помочь?"},
{"role": "user", "content": [
{"type": "text", "text": "что на картинке?"},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/4AAQ..."}}
]}
]
}
What glm-text receives:
{"role": "user", "content": [
{"type": "text", "text": "что на картинке?"},
{"type": "text", "text": "[Image 1, described by qwen-vl because glm-text cannot see images]\nНа изображении сова ..."}
]}
Next request — the client sends the whole history again, the image is now old:
{
"model": "glm-text",
"messages": [
{"role": "user", "content": "привет"},
{"role": "assistant", "content": "Привет! Чем могу помочь?"},
{"role": "user", "content": [
{"type": "text", "text": "что на картинке?"},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/4AAQ..."}}
]},
{"role": "assistant", "content": "На картинке изображена сова."},
{"role": "user", "content": "спасибо"}
]
}
No describe call is made. If the assistant answer still starts with the description block
AIR wrote into it, the image gets exactly the text it had on the first turn; otherwise it
becomes [image from an earlier turn omitted].
Trade-off: a follow-up about a detail the description does not mention ("what colour are its eyes?") cannot be answered — the model never saw the image. The describe prompt asks for an exhaustive description to keep this rare; the user can always attach the image again.
Descriptions in the answer
AIR keeps no state. To keep an image's description for later turns, it writes the descriptions of the current turn at the start of the answer, one collapsible block per image:
<details type="air-vision" n="1" model="qwen-vl">
<summary>Image 1 described by qwen-vl</summary>
A brown owl sits on a birch branch at night. ...
</details>
На картинке изображена сова.
Open WebUI renders the block collapsed under its summary and sends the answer text back with the history. On the next request AIR:
- matches the blocks to the images of the turn they answered, by order (
ncounts the images that were described, in order) and puts the same text in place of each image — the prompt is identical to the first turn, so vLLM's prefix cache still hits; - removes the blocks from the assistant messages before the conversation goes upstream. This
also happens for vision-capable vLLM models, e.g. when the conversation is switched to one:
it sees the original image and does not need the text. A side effect: text in an earlier
answer of a vLLM model that happens to match the block format exactly
(
<details type="air-vision" n="…" model="…">…</details>) is removed as well.
Like the rest of the feature, this applies only to models served exclusively by vLLM. A conversation switched to a model of another provider (OpenAI, Anthropic, ...) sends the blocks upstream unchanged, as ordinary text of the earlier answers.
Where the block goes:
| Endpoint | Non-streaming | Streaming |
|---|---|---|
/v1/chat/completions |
start of choices[i].message.content (every choice) |
an extra delta.content chunk before the first answer chunk of each choice |
/v1/responses |
start of the first output_text |
start of the first response.output_text.delta, and in the matching .done events |
/v1/messages |
start of the first text block (after thinking) |
start of the first text block (after thinking) |
An answer without text (tool calls only) still gets the block: Chat Completions as the message
content; Responses as an extra message item at the end of output (when streaming, its
events are inserted before response.completed).
The block comes after the reasoning, never inside it: Open WebUI does not send reasoning back
with the history. usage is unchanged — the inserted text is not billed as completion tokens.
The block is not written when the client asked for machine-readable output —
response_format / text.format other than text, Messages output_format, or a forced tool
call (tool_choice: "required", a named function, Messages any / tool). Set
inject_into_response: false to turn it off entirely; history images then always become
placeholders.
Nor is it written for a vLLM model with an explicit passthrough_messages: true: /v1/messages
then goes to vLLM natively and the answer comes back in Anthropic format. The images are still
described; only the block in the answer (and so the restore on later turns) is missing.
The same holds for a responses_only vLLM model
called through /v1/chat/completions: the request is converted to the Responses API, and the
block is not written into the converted answer. See Limitations.
Supported APIs
| Endpoint | Image parts recognized | Replaced with |
|---|---|---|
/v1/chat/completions |
{"type": "image_url"} in user and tool messages |
{"type": "text"} |
/v1/responses |
{"type": "input_image"} in messages and function_call_output.output |
{"type": "input_text"} |
/v1/messages |
{"type": "image"} (base64 or url source), including inside tool_result |
{"type": "text"} |
For /v1/responses with previous_response_id, the stored history is prepended first, so its
images are handled like any other earlier-turn image. The stored conversation keeps the
original images, and the stored answer keeps the description block, so the descriptions are
restored from the store as well.
An input_image given only as file_id cannot be fetched by AIR and becomes a placeholder.
Billing and limits
The describe call runs through AIR's own pipeline with the caller's headers: the same API key,
the same end-user headers (X-OpenWebUI-User-Email, ...). It is therefore authenticated,
rate-limited, load-balanced and billed like a normal request — it shows up as a separate
spend-log row for describe_model. The key must be allowed to use describe_model.
A request forwarded by another AIR (proxy/AIR credential, authenticated with the master key) is billed by that AIR. Its describe calls are sent the same way: they get a spend-log row here but are not charged to any key, and they are routed with the forwarding AIR's credential denylist, so they never go to a credential it excluded (including a route back to it). The forwarding AIR only sees the usage of the main request, so the describe calls of a forwarded request are not billed to the end user's key.
Each described image counts as one request against the key's RPM: a message with 4 images costs
5 requests. When the key's limit is hit, the remaining images become placeholders and the header
shows it (described=2/4). The describe calls can also use up the limit the main request needs:
on a key with 5 RPM, a message with 5 images gets its main request rejected with 429.
The images are described before a credential is picked for the main request, so a rejected
main request (429, no credential available, upstream error) has already paid for its describe
calls, and a client that retries pays for them again on every retry — AIR keeps no cache of
descriptions. Keep max_images low on keys with tight RPM or budget limits.
The describe call has its own request_id (it is the key of its spend-log row). It runs inside the
caller's trace, and the DEBUG line Vision fallback: describe call links the two
(request_id = caller, describe_request_id = describe call).
If a describe call fails (error, timeout, model not allowed for the key — the caller gets no
error for that, only a placeholder), the image becomes
[image omitted: the image could not be described] and the original request still proceeds.
The failure is logged at WARN as Vision fallback: describe call failed.
A describe call never triggers another describe round. If describe_model is itself marked
supports_vision: false (e.g. by a model_info.supports_vision value synced from the database
after startup), the describe call is rejected, every image becomes a placeholder, and
vision_fallback.describe_model has supports_vision: false is logged once at WARN.
Limitations and edge cases
responses_only models. A text-only vLLM model with responses_only: true (or
model_info.mode: responses in the database) called through /v1/chat/completions is
converted to the Responses API upstream. reject, strip and the describe step itself work,
but the description block is not written into the answer, so later turns cannot restore it:
history images of such a conversation always become [image from an earlier turn omitted]
(X-AIR-Vision-Fallback shows stripped, never restored=K). The answer itself is not
affected.
Native WebSocket (/v1/responses over WebSocket). Native WebSocket requires an
OpenAI-compatible Responses provider, so a turn to a vLLM model fails with
Native WebSocket requires an OpenAI-compatible Responses provider anyway. In describe mode the
images are described (and billed) before that error.
Rejected or retried main requests. Images are described before credential selection and the
key's budget/rate-limit checks of the main request. A main request that then fails (429, 503,
no credential, upstream error) has already paid for its describe calls, and every client retry
pays again (see Billing and limits).
max_tokens: 0. Unlike max_images, 0 is not "no limit": it is replaced with the default
1024. Set an explicit large value if descriptions are cut off.
Stream without a trailing newline. The block is inserted in front of the first answer chunk.
If the upstream closes the stream right after that chunk without a line terminator, the inserted
and the original event end up on one line and the client may drop the chunk. vLLM always ends a
stream with data: [DONE], so this is not expected in practice.
Logs
[INFO] Rewrote image input for a model without vision support model=glm-text outcome=described=1/1 images=1 current_turn_images=1 described=1 restored=0 inject_into_response=true describe_model=qwen-vl
[WARN] Rejected image input for a model without vision support error_code=400 model=glm-text images=1
See also
- vLLM provider
- Configuration — Models
- Example config:
examples/vision_fallback.yaml