Add a vision bridge for text-only main models - #1
Open
ihubanov wants to merge 1 commit into
Open
Conversation
Some of the best models for long coding sessions are text-only — you pick them for the context window, then hit a wall the moment an image lands in the conversation and the model throws "not multimodal" and wedges the turn. The vision bridge fixes that when the gateway also serves a multimodal model. Set a vision model (saved or via SETFREE_VISION_MODEL) and SetFree starts a local proxy the CLI talks to instead of the gateway directly. The proxy forwards everything unchanged except image content blocks: each is captioned by the vision model and replaced with text before the request reaches the main model. The text model never sees a raw image block, so it never errors. Captions are cached to disk (NDJSON, keyed by image bytes) so an image is described once ever, not once per turn. This is the one deliberate exception to SetFree's "step aside, never sit in the request path" rule. It's strictly opt-in — no vision model means no proxy and a launch byte-identical to today. The proxy is localhost, lives only as long as the CLI does, and never touches auth or licensing. The CLI binary is still the one you installed, unmodified. Ported the captioning approach from a forked-CLI implementation (claude-local's visionBridge.ts): Anthropic image-block detection at the top level and nested one level inside tool_result content (the easy-to-miss case that reproduces the not-multimodal error), the captioning call forcing chat_template_kwargs.enable_thinking=false (a vLLM passthrough that keeps Qwen3.5 from burning its token budget on thinking and returning empty), and bounded-concurrent prewarm before the serial rewrite (each caption is an ~8s round-trip; serial it looks like a hang). Lifecycle: the vision path runs the CLI as a child (launcher.Run, new — signal-forwarded) instead of exec-replacing (launcher.Launch), so the parent survives to tear the proxy down when the CLI exits. An idle-timeout (default 1h) is only a backstop for a proxy orphaned by an abnormal parent death; it never culls a live session that's between turns. The non-vision path is unchanged. Covers the Anthropic /v1/messages path (claude and vscode adapters). Codex uses a different wire format (OpenAI Responses API) and will follow. Config: [vision] model = "qwen3.5" # presence enables the bridge # base_url and api_key optional, default to the main gateway Env overrides: SETFREE_VISION_MODEL, SETFREE_VISION_BASE_URL, SETFREE_VISION_API_KEY, SETFREE_VISION_OFF, SETFREE_VISION_CONCURRENCY, SETFREE_VISION_IDLE_TIMEOUT. The vision API key lives in the secrets store under "vision", never in config.toml. Limitation vs a forked-CLI bridge: the proxy can't keep the original image for an on-demand "look closer" re-query, so the text model works from the caption. That's the trade for staying out of the binary.
ihubanov
force-pushed
the
feat/vision-bridge
branch
from
August 25, 2026 07:08
5880ee5 to
538c3e6
Compare
Author
|
First PR here, so GitHub is holding the CI workflow runs until a maintainer approves them. The checks are standard (build/vet/test on ubuntu/macos/windows + gofmt) — I've run them locally against all three platforms and they're green. Happy to adjust anything; just let me know. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Some of the best models for long coding sessions are text-only — you pick them for the context window, then hit a wall the moment an image lands in the conversation and the model throws "not multimodal" and wedges the turn. This adds a vision bridge so a text-only main model can work in a multimodal session when the gateway also serves a multimodal model.
What it does
Set a vision model (saved in
[vision]or viaSETFREE_VISION_MODEL) and SetFree starts a local proxy the CLI talks to instead of the gateway directly. The proxy forwards everything unchanged except image content blocks: each is captioned by the vision model and replaced with text before the request reaches the main model. The text model never sees a raw image block, so it never errors. Captions are cached to disk (append-only NDJSON, keyed by image bytes) so an image is described once ever, not once per turn.This is the one deliberate exception to SetFree's "step aside, never sit in the request path" rule. It's strictly opt-in — no vision model means no proxy and a launch byte-identical to today. The proxy is localhost, lives only as long as the CLI does, and never touches auth or licensing. The CLI binary is still the one you installed, unmodified.
How
The bridge ports the captioning approach from a forked-CLI implementation of the same idea, adapted to SetFree's "never modify the CLI binary" constraint: since there's no hook to reach inside the request the unmodified binary builds, the proxy sits in the request path instead. It handles Anthropic image-block detection both at the top level of a message's content and nested one level inside
tool_resultcontent (the easy-to-miss case that reproduces the not-multimodal error), forceschat_template_kwargs.enable_thinking=falseon the captioning call (a vLLM passthrough that keeps a reasoning vision model like Qwen3.5 from burning its token budget on thinking and returning empty), and prewarms captions concurrently before the serial rewrite (each caption is an ~8s round-trip; done serially it looks like a hang).Lifecycle: the vision path runs the CLI as a child (
launcher.Run, new — signal-forwarded) instead of exec-replacing (launcher.Launch), so the parent survives to tear the proxy down when the CLI exits. An idle-timeout (default 1h) is only a backstop for a proxy orphaned by an abnormal parent death; it never culls a live session that's between turns. The non-vision path is unchanged.New
internal/vision/package;config.Settingsgains a[vision]table (API key in the secrets store, never in config.toml);launchergainsRunalongsideLaunch;app/launch.gostarts the proxy between Resolve and Build (adapters untouched). The proxy is the setfree binary itself in avision-proxysubcommand — no new artifact.Scope
Covers the Anthropic
/v1/messagespath — theclaudeandvscodeadapters. Codex uses a different wire format (OpenAI Responses API) and will follow in a separate PR.Config
Env overrides:
SETFREE_VISION_MODEL,SETFREE_VISION_BASE_URL,SETFREE_VISION_API_KEY,SETFREE_VISION_OFF,SETFREE_VISION_CONCURRENCY,SETFREE_VISION_IDLE_TIMEOUT.Limitation
The proxy can't keep the original image for an on-demand "look closer" re-query — the text model works from the caption. That's the trade for staying out of the binary.
Testing
Unit tests cover block detection (top-level + nested in
tool_result), theenable_thinking=falsepassthrough, caption caching across calls, failure fallback, image-free passthrough, proxy rewrite + header forwarding + invalid-JSON passthrough, and config precedence/round-trip.go build,go vet,gofmt, andgo test ./...are all clean, no new dependencies (stdlib only).Also verified end-to-end against a real gateway: a
/v1/messagesrequest with a text-only main model (nvidia/GLM-5.2-NVFP4) and a base64 image, sent through the proxy, was captioned byQwen3.5-397B-A17B-NVFP4and returned a correct answer ("Red"for a red square) — no not-multimodal error.