Skip to content

fix(runtime,kernel): redact images for text-only models via catalog supports_vision (#6010)#6013

Merged
houko merged 5 commits into
mainfrom
fix/openai-vision-gate-6010
Jun 5, 2026
Merged

fix(runtime,kernel): redact images for text-only models via catalog supports_vision (#6010)#6013
houko merged 5 commits into
mainfrom
fix/openai-vision-gate-6010

Conversation

@houko

@houko houko commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Problem

A channel/sidecar bot whose default agent runs a text-only model (e.g.
DeepSeek deepseek-v4, supports_vision = false) breaks the moment a user
sends a photo. The OpenAI-compatible API rejects image content parts with
HTTP 400:

unknown variant `image_url`, expected `text`

The channel layer downloads the image and prepends a textual [media]
description, but it does not strip the ContentBlock::Image / ImageFile
block, so the image still reaches the driver, is inlined as a data: URL, and
400s the whole turn. This affects every entry path with a text-only default
agent (WebUI, channels, sidecars, triggers).

Approach (authoritative, catalog-driven)

The previous revision of this PR used a driver-local name deny-list in
openai.rs. That was rejected by the maintainer as non-authoritative. This
revision moves the gate to the model catalog and redacts upstream of the
driver, so it covers all entry paths and all OpenAI-compatible drivers,
respects user capability overrides, and never hard-codes provider names.

LibreFang's authoritative vision flag is the model catalog's
effective_capabilities().supports_vision, which already honours user
overrides (#4745). This is surfaced to request-build time via the same
CatalogQuery trait that #4842 used to thread reasoning_echo_policy.

Changes

  1. CatalogQuery::supports_vision_for(model) -> bool — new trait method
    mirroring reasoning_echo_policy_for, with a default body returning true
    (fail open) so non-overriding mocks/stubs keep sending images unchanged.
    crates/librefang-kernel-handle/src/catalog_query.rs

  2. Kernel override — resolves the flag from the catalog, honouring user
    overrides via effective_capabilities, fails open on a catalog miss
    (unwrap_or(true)), using the same catalog accessor as
    lookup_reasoning_echo_policy and the same call shape as ws.rs.
    crates/librefang-kernel/src/kernel/handles/catalog_query.rs

  3. Agent-loop gate — at the request-build site in run_agent_loop, look up
    supports_vision via the kernel handle (same kernel.as_ref().map(|k| …)
    pattern as the adjacent reasoning_echo_policy lookup). When
    !supports_vision, redact image blocks to a text placeholder via a small
    pure helper redact_images_for_text_only(messages, model) before
    building the request. The vision path is byte-identical to before.
    crates/librefang-runtime/src/agent_loop/mod.rs

  4. openai.rs reverted to main — the driver again serializes images
    unconditionally; the gate now lives upstream.

Why this is correct

  • Authoritative: capability comes from the catalog
    (effective_capabilities().supports_vision), not a name heuristic.
  • Respects user overrides (Feature request: Manually editable model capabilities #4745): effective_capabilities applies the
    per-model override, so an operator who force-enables vision on a
    mis-declared model keeps sending images.
  • Covers all paths: redaction happens in the shared agent loop, so every
    inbound surface (WebUI, channels, sidecars, triggers) is protected, not just
    the OpenAI driver / a known family list.
  • Fails open: no kernel handle wired, or a catalog miss → images are sent
    unchanged, so vision and unknown models are never degraded.

Tests

  • crates/librefang-kernel-handle/src/tests.rs
    catalog_query_supports_vision_defaults_to_true: a stub that doesn't
    override the method returns true (mirrors the existing
    catalog_query_default_returns_none test).
  • crates/librefang-runtime/src/agent_loop/tests/utilities.rs
    redact_images_for_text_only_replaces_image_and_imagefile_blocks: both
    Image and ImageFile blocks become text placeholders that mention the
    model; text/other blocks and block counts are preserved.
    redact_images_for_text_only_is_noop_without_images: messages without image
    blocks pass through structurally unchanged.

Verification

Local compilation/tests were not possible in this environment (no native
cargo/rustc, no reliable Docker). The change follows the existing
reasoning_echo_policy_for precedent and ws.rs catalog-call shape, and is
left for CI to compile and run as the gate.

Closes #6010

@github-actions github-actions Bot added the size/L 250-999 lines changed label Jun 4, 2026
@houko
houko force-pushed the fix/openai-vision-gate-6010 branch from cdd8be2 to dd59049 Compare June 4, 2026 18:52
@github-actions github-actions Bot added area/runtime Agent loop, LLM drivers, WASM sandbox area/kernel Core kernel (scheduling, RBAC, workflows) size/L 250-999 lines changed size/M 50-249 lines changed and removed size/L 250-999 lines changed labels Jun 4, 2026
@houko houko changed the title fix(llm-drivers): gate OpenAI image parts on vision capability (#6010) fix(runtime,kernel): redact images for text-only models via catalog supports_vision (#6010) Jun 4, 2026
run_agent_loop_streaming built CompletionRequest using the raw message
list, so a text-only model (supports_vision = false) still received image
content blocks and returned HTTP 400.
Apply the same catalog-driven supports_vision gate introduced for the
non-streaming path: look up supports_vision_for from the kernel handle,
redact image blocks via redact_images_for_text_only when false.
Make redact_images_for_text_only pub(super) so run_streaming.rs can
call it.
@github-actions github-actions Bot removed the size/L 250-999 lines changed label Jun 4, 2026

@houko houko left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Streaming path was unguarded — pushed a fix.

run_agent_loop_streaming built CompletionRequest from messages.clone() directly, skipping the supports_vision_for check added to the non-streaming path.
Any production deployment using the WebUI or channel bots (which run the streaming loop) would still receive HTTP 400 on image content with a text-only model.

Fix applied: redact_images_for_text_only is now pub(super) and the streaming loop mirrors the non-streaming vision gate before constructing CompletionRequest.


Advisory: lookup_supports_vision inlines what effective_capabilities_for already provides

In crates/librefang-kernel/src/kernel/handles/catalog_query.rs, the implementation:

catalog.find_model(model).map(|m| catalog.effective_capabilities(m).supports_vision).unwrap_or(true)

duplicates the two-step logic that catalog.effective_capabilities_for(model) already encapsulates (that method exists at model_catalog.rs:735).
Using it would reduce this to a one-liner:

catalog.effective_capabilities_for(model).map(|c| c.supports_vision).unwrap_or(true)

Not a bug, but inconsistency with the existing API surface means a future change to effective_capabilities_for's logic would need to be mirrored here manually.


Advisory: supports_vision_for is re-evaluated inside the iteration loop

api_model is constant across loop iterations, but supports_vision_for does a fresh ArcSwap::load() on each call.
A catalog hot-reload mid-turn could flip vision support between iterations: early iterations send images, later ones redact them, and the session history becomes inconsistent.
The reasoning_echo_policy lookup has the same pattern, so this is not unique to this PR, but it is worth documenting the "snapshot per iteration" semantics or hoisting both lookups before the loop for consistency.


Generated by Claude Code

@houko
houko enabled auto-merge (squash) June 5, 2026 08:59
@houko
houko merged commit 9dec70c into main Jun 5, 2026
32 checks passed
@houko
houko deleted the fix/openai-vision-gate-6010 branch June 5, 2026 09:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/kernel Core kernel (scheduling, RBAC, workflows) area/runtime Agent loop, LLM drivers, WASM sandbox size/M 50-249 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: OpenAI driver serializes image blocks as image_url without checking model vision capability (text-only models return HTTP 400)

2 participants