Skip to content

feat(*): send user attachments to the model, gated on vision support - #285

Merged
arelchan merged 2 commits into
mainfrom
feat/image_attachments_vision_gate
Aug 10, 2026
Merged

feat(*): send user attachments to the model, gated on vision support#285
arelchan merged 2 commits into
mainfrom
feat/image_attachments_vision_gate

Conversation

@arelchan

Copy link
Copy Markdown
Contributor

Summary

Before this change a picture a user attached never reached the model. TurnRequest.media was wired end to end and channels already used it, but turn.send had no media field, so a front end could only paste the path into the message text. This adds that field and the delivery rules behind it.

Getting the file in. turn.send takes an optional media array of workspace paths, resolved with the filesystem tools' own policy (so uploads/shot.png means the same thing it means to every other tool). A path that does not resolve, resolves outside the allowed directory, or cannot be read costs its own note in the message rather than the whole turn. The list is bounded at 64 in the schema, and one message inlines at most 16 images or 16 MB of base64.

Deciding how it reaches the model. A vision-capable model gets the image inlined as a base64 image_url block in the user message; anything else gets a text note naming the file, and the note names a description tool only when one is actually registered. The image is preprocessed by the same prepare_image that read_file already used: a 4032x3024 phone photo goes out as 1180x885, which is 30x less base64 than inlining it raw.

Where the verdict comes from. The gateway catalog Raven already fetches and caches for pricing, which publishes input_modalities for every model it lists. That completeness is why it is the source rather than LiteLLM's price table, which states supports_vision on under a third of its rows -- reading that silence as a denial would take a picture that reaches Grok, Llama 4 and the Qwen-VL family today and replace it with prose.

The catalog is read from cache only, never fetched inside a turn, and warmed on a background thread with a cooldown. The warm is not optional: the pricing path asks LiteLLM first and only reaches this catalog when that table misses, which excludes every model Raven ships a default for, so without it the probe would answer optimistically forever on a fresh install.

Which way it fails. Absence, staleness, a lookup that raises, and a caller-chosen deployment name all read as "no answer", which resolves to optimism and is never cached. Being wrong that way fails loudly at the endpoint. Being wrong the other way is silent: the picture never arrives and the model answers from the surrounding text as though it had seen one, and unlike the tool-result path there is no automatic recovery. Deployment names are excluded for that reason -- gpt-4 is the name Azure's own quickstarts use, and a team keeps the deployment name while repointing it at a newer model, so joining it against a vendor catalog answers about somebody else's model.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

  • uv run pytest tests -q -> 1 failed, 5334 passed, 58 skipped

  • LITELLM_LOCAL_MODEL_COST_MAP=true uv run pytest tests -q -> 3 failed, 5332 passed, 58 skipped

  • uv run ruff check raven tests -> clean; uv run ruff format --check raven tests -> 791 files already formatted

  • npm run lint:rpc -> generated.ts in sync; npm run type-check -> clean; npm test -> 969 passed

  • All 4 failures above reproduce on a pristine main worktree and are unrelated to this change: test_cli_theme.py::test_bold_accent_renders_styled_not_bare is a rich version issue, the two test_token_wise_pricing.py MiniMax-M3 assertions need a cost map the bundled LiteLLM does not carry, and test_default_context_engine.py::TestTwoTrackConcurrency::test_skill_and_memory_run_concurrently is a load-sensitive timing assertion.

  • Verified against a live catalog that the probe denies the five text-only models Raven ships defaults for and grants every vision model tested, while every deployment and local-runtime name stays optimistic.

  • Verified on a cold cache that the first turn answers optimistically without blocking, the background warm lands, and the next turn on the same long-lived loop gets the real verdict.

  • Relevant tests pass locally

  • Relevant lint / type checks pass locally

  • User-facing docs or screenshots are updated when needed

Risk

  • Security impact considered
  • Backward compatibility considered
  • Rollback path is clear for risky changes

media is optional, so every existing caller is unaffected, and the channels producer of TurnRequest.media is untouched. Attachment paths are resolved with the same policy as the file tools, so restrict_to_workspace still bounds them and a path outside it is dropped.

Pricing is deliberately unchanged: the catalog entry gained an input_modalities field and the on-disk cache version was bumped, but the pricing lookup itself has no deleted or modified lines, and cost and context-window resolution were compared against main across 40 real model ids in both cost-map modes with identical results.

Rollback is ProviderSpec.vision_override per provider for a wrong verdict, and reverting the commit otherwise.

Known scope boundary. Channels populate TurnRequest.media today and benefit immediately. The TUI does not reach this lane yet, and not for want of a client: it already implements both attachment gestures (a dropped path via image.attach, a clipboard image via clipboard.paste), but the server side of those is a not supported in v0.1 stub or absent entirely, and the TUI catches the failure silently. Connecting them is a follow-up PR, and it is server-side work -- ui-tui/ needs no change.

Two follow-ups deliberately left out of scope: a media list that fails validation surfaces as -32603 internal_error rather than -32602, which is how every TurnSendParams validation failure already behaves and needs a dispatcher change to fix; and the vision verdict is computed for the routed primary while a LiteLLM fallback further down the chain is sent the same message list, which the sibling transport probe shares and which needs the chain assembled per candidate.

Related Issues

N/A

arelchan and others added 2 commits August 10, 2026 10:36
turn.send gained an optional media param, so a caller can hand the spine
attachment paths the way channels already do. Paths resolve with the
filesystem tools' own policy, and one that does not resolve is dropped
with a log line rather than failing the turn.

A picture then reaches the model as an inline base64 image_url block when
the model can see it, and as a note naming the file when it cannot. The
verdict comes from the gateway catalog Raven already fetches for pricing,
which publishes input_modalities for every model it lists, and it is
warmed in the background because the pricing path only ever reaches that
catalog for models LiteLLM's static table misses.

Absence, staleness, an exception and a deployment name all read as "no
answer", which resolves to optimism and is never cached. Being wrong that
way fails loudly at the endpoint; being wrong the other way silently turns
a picture into prose, and there is no recovery on the attachment path.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Three loose ends from the delivery change, all about what the model is told
to do next.

read_file is the tool that hands a model the picture, so the note it gets
when a message cannot carry one now names read_file wherever read_file
would work: past the blind check the model can see, and read_file
downscales rather than refusing on size. The description tool is named only
where it is the one that can help -- a non-image attachment, or a model
with no vision at all -- and understand_media's own description now
recognises the '[Image: ...]' note as a source of paths, which it had to
after that note started pointing at it.

read_file's description drops "when the active model can see images". It
described an implementation detail the model cannot evaluate about itself,
and the runtime now says so directly in the result instead.

The blind branch also stops reading the file. Every reason to refuse is
settled from the header and the path, so a model that cannot see a picture
no longer pays to load one, up to the per-attachment ceiling.

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>

@0xKT 0xKT left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the full diff and verified the referenced symbols against main
(split_model_id, spec.client/is_local, _resolve_path, prepare_image,
Media fields, the attach_blocks -> pending_images hand-off). The
fail-direction design (unknown -> optimistic -> never cached) is
consistent across vision_verdict / _supports_vision and locked by tests.
CI is green and the 4 local test failures listed in Verification
reproduce on a pristine main. LGTM.

Three non-blocking notes, none worth holding the merge for:

R1 (nit) raven/context_engine/segments/render.py, build_user_content:
the can_see_images check runs after the file is read whole, so a blind
model attaching a large image reads up to 64MB just to emit a note.
Fix: hoist the can_see_images branch above raw = head + handle.read().
Verify: existing test_the_attachment_note_names_no_tool_when_none_is_registered
still passes; optionally assert reads == [_SNIFF_BYTES] for the blind path.

R2 (follow-up) raven/agent/loop/main.py, _supports_vision: self.provider
is passed for any routed id, so when the primary provider is
AzureOpenAIProvider every routed model classifies as caller-chosen and
stays optimistic. Only bites mixed Azure-primary + router setups and
fails in the loud direction; worth a line in the known-boundaries list
or a per-candidate provider when the fallback-chain follow-up lands.

R3 (nit) tests/test_read_file_image.py grew ~770 lines of vision/warm
coverage; the file's subject is read_file image handling. Consider
splitting a test_providers_capabilities.py in a follow-up.

@0xKT
0xKT self-requested a review August 10, 2026 12:15
@arelchan
arelchan merged commit 694e7b0 into main Aug 10, 2026
9 checks passed
@arelchan
arelchan deleted the feat/image_attachments_vision_gate branch August 10, 2026 13:54

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Adversarial review pass. I read every hunk, opened the surrounding code, and
verified the first two findings by executing the code in a throwaway worktree at
this head (also ran tests/test_read_file_image.py test_plugin_tools.py test_tui_rpc_turn_send.py test_token_wise_pricing.py -> 201 passed).

The design is careful and the "absence means yes" bias is the right one -- the
asymmetry argued in the description holds up. The problems are all in the
exclusion list: the code that decides when a model string must not be looked up
in the vendor catalog. A name that is wrongly allowed through lands in exactly the
silent failure mode the description says must be avoided.

Five inline notes below, most severe first. Nothing here is a design objection.

Checked and found sound: the routing/assembly reorder (the router needs only
content); media_paths does not filter on Media.kind/mime, so kind="file"
is fine downstream; prepare_image meta keys and the 64-byte sniff are adequate
for detect_image_mime; the count and byte ceilings, and the header-only read for
blind and non-image attachments; _save_turn's image stripping keeps the path
notes; AssemblyContext/TurnContext are only ever constructed with keywords;
the Codex Responses provider does translate user-side image_url parts; pricing's
TTL logic is genuinely unchanged by the decision to leave _OPENROUTER_CACHE_TIME
alone.

from raven.providers.azure_openai_provider import AzureOpenAIProvider
from raven.providers.registry import split_model_id

if isinstance(provider, AzureOpenAIProvider):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LazyProvider defeats this guard on the only path that reaches turn.send media.

The docstring above is right that "only the live provider instance knows" for a
bare Azure deployment name -- but raven tui builds the loop with
make_lazy_provider(), which is a plain proxy, not an AzureOpenAIProvider. So
for model="gpt-4" on an Azure deployment that has been repointed at gpt-4o:

  • arm 1: isinstance(provider, AzureOpenAIProvider) -> False (it is the lazy proxy)
  • arm 2: split_model_id("gpt-4")[0] is not in _DEPLOYMENT_NAME_PREFIXES -> False
  • arm 3: find_by_model("gpt-4") returns OpenAI's spec, so spec.is_local is
    False and spec.client == "azure" is False

_model_id_is_caller_chosen returns False, vision_verdict consults the catalog,
the catalog answers about OpenAI's gpt-4, and vision is denied. The picture is
silently replaced by prose -- the failure mode the PR description singles out as
the one with no automatic recovery.

Verified at this head: vision_verdict("gpt-4", find_by_model("gpt-4"), lazy)
returns False, versus None when passed a real AzureOpenAIProvider.

Worth noting arm 3's spec.client == "azure" is unreachable for the same reason:
by the time a bare deployment name reaches here, find_by_model has already
resolved it to a vendor spec. Unwrapping the lazy proxy (or asking the provider a
question instead of type-testing it) would fix both arms.

return True
if spec is None:
return False
return bool(spec.is_local) or spec.client == "azure"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

custom belongs in this predicate, and the documented rollback is dead for it.

Any OpenAI-compatible endpoint configured as custom carries a served model
name chosen by whoever runs the endpoint -- the same situation as Azure, ollama and
vLLM, which are all excluded here. spec.is_local does not cover it (a custom
endpoint is frequently remote), so a served name like gpt-4 or llama-3-vision
gets joined against the vendor catalog and answered about somebody else's model.

The second half is worse: the PR describes ProviderSpec.vision_override as the
rollback for a wrong verdict, but for custom that escape hatch cannot be
reached. find_by_model() resolves a bare or openai/-prefixed id to the
vendor's spec, so the spec that arrives here is never the custom one --
route_names == {"custom"} while model_prefix == "openai". An override written
on the custom spec is never read, which means this case has no rollback at all,
not just a wrong default.

"""
global _WARM_AT

if _OPENROUTER_CACHE:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A stale on-disk catalog suppresses the background warm permanently.

_cached_catalog_only (below) deliberately accepts a table of any age and, on the
way out, assigns it to _OPENROUTER_CACHE. This early return then sees a
non-empty cache and bails on every subsequent call.

So on a long-lived serve or TUI session that starts with a days-old cache file:
the first modality lookup populates _OPENROUTER_CACHE from disk, and the warm
never runs again for the life of the process. The cooldown reasoning in the
docstring covers the fetch-failed case but not this one -- there was never an
attempt to cool down from.

The two functions each make sense alone; the coupling is that "good enough for a
modality question" (any age) and "fresh enough that no warm is needed" are the
same flag. Gating this return on _OPENROUTER_CACHE_TIME freshness rather than on
_OPENROUTER_CACHE being non-empty would separate them.

# window with both a fresher table and a fresh ``_OPENROUTER_CACHE_TIME``.
# Overwriting it with this stale copy would leave that timestamp vouching for
# the wrong table, and the fetch's TTL check would then skip the refetch.
if _OPENROUTER_CACHE:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This re-check is still an unlocked check-then-act, so it does not close the window the comment describes. (Lower confidence than the other notes -- I did not reproduce it.)

The comment above is precise about the hazard: a warm landing during load()
leaves a fresh _OPENROUTER_CACHE_TIME that would end up vouching for the stale
table. But the guard is a read at line 342 and a write at line 348 with no lock
between them, so the same interleaving simply moves:

  1. line 342 reads _OPENROUTER_CACHE -- still empty, falls through
  2. the warm thread completes: fresh table into _OPENROUTER_CACHE, fresh
    _OPENROUTER_CACHE_TIME
  3. line 348 overwrites with disk[0]

which is the exact state the comment says must not happen -- and now pricing, not
just modality lookups, serves the stale table until the TTL expires (up to an
hour), because the fresh timestamp survives.

The window is much narrower than the one before load(), so this may be an
acceptable trade rather than a bug. If it is, saying so in the comment would be
worth more than the current wording, which reads as though the re-check
eliminates the race.

try:
resolved = _resolve_path(raw.strip(), workspace, allowed)
if not resolved.is_file():
logger.warning("turn.send: attachment {} does not resolve to a file", raw)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A dropped attachment is invisible to both the model and the front end.

This continue (and the one at line 88) drops the path before
build_user_content ever sees it, so unlike an unreadable-but-resolved file --
which the description says "costs its own note in the message" -- a mis-spelled or
out-of-workspace path produces no [Attachment: ...] note at all. The RPC still
returns success, and the log line goes to the server log the user is not reading.

Net effect for a typo'd path: the model answers as though nothing was attached,
and the user believes it saw the image. That is the same silent shape the PR
argues against for the vision verdict, and it is reachable by ordinary user error
rather than by a catalog edge case.

Emitting a note for the dropped path (it does not need the file to exist -- the
raw string is enough) would make it visible in the transcript, which is where the
user would notice.

0xKT added a commit that referenced this pull request Aug 10, 2026
## Summary

Closes out the provider path in one PR: the provider module redesign,
every
open issue and backlog item that lives on the identity / connection /
routing / capability axes, and multi-endpoint failover as a new feature.
78 commits, one problem per commit, rebased onto current main.

Redesign (base): four provider decisions were implemented outside
`raven/providers/` and had drifted copies -- wire form, credential
grammar,
pin resolution, price/window ladder, cache dialect. Each now has one
owning
module the surfaces call.

Fixes on that base, most visible first:

- The context window walks one ladder (explicit config > the model's
real
  window > documented fallback). Previously the config default 65536 fed
trimming and budgets raw, so a 200k-window model lost two thirds of its
context every turn; an unresolvable window now renders the gauge's empty
  state instead of a number that is nobody's. Construction and /model
switches resolve without touching the network, and a switch parked
behind
a running turn re-resolves the window at adoption, not at the RPC call.
- A provider without real streaming (azure, codex) no longer renders
  upstream errors as normal assistant text: the terminal stream delta
  carries the classification and joins the same recovery path the
non-streaming call uses. Both classify their non-200 from the live
status
  code (shared ProviderHTTPError) instead of regex-guessing the rendered
text, which also removes the bare "404" substring match that misfiled a
  400 whose body happened to embed one.
- Codex SSE failures keep their structured code, so an overloaded
backend
  is retried instead of classified unknown.
- Orphan `</think>` recovery (backends launched without their reasoning
  parser) is gated to the backend shapes that produce it, so ordinary
  content mentioning the tag is never cut.
- Fallback hops are vetoed when both identities are certain and disagree
(previously a cross-vendor hop went out under the wrong key, or silently
  to the wrong backend on a shared model name); the knn path dispatches
  each hop to its own endpoint and inherits model_overrides through the
  rotor.
- User model_overrides win over shipped extra_body defaults, and the
merge
  no longer drops user keys behind a gateway.
- The credential gate, the reader and the builder answer one way: a
spec's
shipped default address satisfies the gate exactly when the reader would
  serve it (custom runs on a bare key again, azure still demands its
  address), endpoint entries inherit the flat api_base/extra_headers per
  field, and every display face -- provider list, endpoint list, the TUI
picker -- reports the same resolved view, secrets redacted
(extra_headers
  values included, on the flat section field too).
- The vision probe stops joining operator-chosen names against the
vendor
  catalog: it reads through the TUI's lazy proxy to see the Azure
  transport, treats an explicit-selection gateway (custom) as
  caller-chosen, and the background catalog warm is no longer suppressed
  for the life of the process by a stale on-disk table.
- Skill forge rewriter/gate follow the configured agent model (the only
  auxiliary LLM calls that did not).
- The onboarding wizard refuses the six litellm vendors a bare API key
cannot configure, with the actual requirement named, instead of writing
  a section that 401s forever.
- The OAuth handoff replays a signal swallowed mid-login, so a SIGHUP no
  longer leaves a headless TUI.

New feature -- multi-endpoint failover (several accounts on one vendor):

- `providers.<name>.endpoints` (label / apiKey / apiBase / extraHeaders)
  with `endpointStrategy: sticky | round_robin`; the three credential
  spellings (explicit list, Gemini api_key_list, flat fields) resolve
  through one reader with strict precedence and no key merging.
- `EndpointRotorProvider` rotates and fails over with per-endpoint
cooldown
  (30s doubling to 300s, process-local state); auth failures rotate --
  another account's key is exactly what a dead key needs -- while
  endpoint-agnostic failures return immediately. Streams rotate only
  before the first delta, so tokens are never replayed.
- Managed from `raven provider endpoint add|remove|list` and the TUI
model
picker; the session footer names the active endpoint. Write faces refuse
a keyless endpoint for key-credential providers (local deployments keep
their legitimate keyless shape); invalid sections fail loudly instead of
  being read as empty and overwritten.
- Verified live twice: the rotor directly, and the full stack from a
  config file through make_provider (a dead key 401s, cools, fails over,
  answers). The first live run caught the auth-rotation gap the mocks
  could not.

Also in this PR: the onboarding wizard split (5069 -> 3000 lines, pure
moves with every migrated monkeypatch target mutation-checked),
CONTEXT.md
terms (Provider Endpoint, window ladder), benchmark alignment
(pinchbench
now prices through the shared ladder instead of a private drifted copy),
and the model picker reads the config twice per open instead of twice
per
provider row.

Reviewed adversarially across three external rounds and four internal
panel rounds on two model families: 24 before-merge findings raised in
total, every one either fixed with a mutation-verified test or refuted
with executed evidence (one reviewer finding was withdrawn after a
945-case main-parity sweep); final verdicts RATIFY. The rebase also
adopted the review notes left on #282/#285 that landed on this code:
image-capability verdicts are invalidated on a provider switch, and a
parked switch logs its park and its adoption.

Known follow-ups, named in the review thread (issues to follow): the
fallback routing loop contradicts the registry's explicit-selection note
for `custom` (pre-existing on main), the remaining bare status
substrings
in classify_error (429/5xx, pre-existing), per-hop identity rebuilding
for
fallback chains, and the pre-existing SessionInfo shape mismatch.

## Type

- [x] Feature
- [ ] Fix
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- `uv run pytest tests/ -q` -- 6128 passed, 33 skipped, run after the
  rebase onto current main; the provider-path test files additionally
  re-run under an empty HOME with identical results.
- `make lint-python` clean; commit messages pass commitlint and
  scripts/check_commit_messages.py across all 78 commits.
- `cd ui-tui && npm run lint && npx tsc --noEmit && npx vitest run` --
86
  files, 982 tests passed; `npm run gen:rpc -- --check` in sync.
- Live probes (OpenRouter, tiny max_tokens): rotor failover and
full-stack
  assembly failover, both passing; logs kept locally.
- Fixes are pinned by deletion mutations that turn a named test red;
  review rounds ran 22 such mutations and the two survivors were
  themselves fixed (one dead guard deleted, one vacuous test replaced).

## Risk

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

User-visible behavior changes: `contextWindowTokens` unset now resolves
to
the model's real window (explicit values are fully respected and no
longer
overridden); the context gauge shows an empty state when the window is
unknown; azure/codex upstream errors surface as errors instead of
assistant text; the wizard refuses six key-only-unconfigurable vendors
with the real requirement named; `custom` with only an apiKey starts
again
(as on main) and the picker no longer demands an address the gate does
not; key-credential providers refuse keyless endpoints at write time and
`endpoint add --api-key` becomes optional for local deployments;
endpoint
keys and extra_headers values are redacted in `provider get`/`list` and
over RPC; a model switch logs when it parks behind a running turn and
when
it adopts. Rollback is a straight revert of the squash commit; no data
migration is involved (config additions are opt-in fields).

## Related Issues

Fixes #124, fixes #234, fixes #155, fixes #152, fixes #254, fixes #151,
fixes #143, fixes #144, fixes #197. References #281 (not reproducible on
current or reported code; the api_key forwarding it suspected is now
pinned
by regression tests), #119 (already fixed by #116; remaining item is the
installer redirect, not provider code).

---------

Co-authored-by: Claude (claude-opus-5[1m]) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants