Skip to content

fix(provider): stop treating unknown modalities as unsupported for BYOK models - #2056

Open
wqymi wants to merge 4 commits into
mainfrom
fix/byok-modality-inference
Open

fix(provider): stop treating unknown modalities as unsupported for BYOK models#2056
wqymi wants to merge 4 commits into
mainfrom
fix/byok-modality-inference

Conversation

@wqymi

@wqymi wqymi commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Problem

A model declared under provider.<id>.models.<id> without modalities had its non-text input capabilities resolved by a two-step ?? chain ending in a hardcoded false:

image: model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false

The middle step consults models.dev, but only within the same provider id. A user-invented provider id matches nothing there, so a model whose catalog entry plainly lists ["text","image","audio","video"] still resolved to image: false. provider/transform.ts then replaced the user's image part with ERROR: Cannot read "<file>" (this model does not support image input). — text addressed to the model, written from inside an AI SDK middleware.

The observable result: the model truthfully reports it received no image, the image is sitting in the session record the whole time (a single file part's data URL measured 456,955 chars in the wild), and nothing anywhere says the engine chose to drop it. It reads like the consuming client corrupted the attachment.

?? false turned "we never looked this up" into "we know it cannot do this".

Resolution order

  1. declaredmodalities in config. Always authoritative.
  2. provider-entry — the same-named provider's existing catalog entry (prior behaviour).
  3. directorynew: the bare model id looked up across the whole models.dev directory, taking the first-party entry.
  4. assumed — nothing is known, and that is recorded as unknown rather than collapsed to false.

Deriving first-partyness without a vendor table

models.dev publishes no "first party" flag, so tier 3 reads the directory's own shape: aggregators list a resold model under the vendor namespace (xiaomi/mimo-v2.5, openai/gpt-5), while the vendor lists it under the bare id. Intersecting the namespaces providers agree on with the providers holding the bare id names the vendor's entry.

Exactly one survivor is required — no union of aggregator claims, no majority vote. A namespace no provider serves and an aggregator namespacing under its own id both drop out for free. Against the real directory:

bare id providers holding it first-party candidate verdict
mimo-v2.5 6 xiaomi text,image,audio,video
mimo-v2.5-pro 8 xiaomi text
gpt-5 12 openai text,image
claude-sonnet-4-5 11 anthropic text,image,pdf
gemini-2.5-pro 15 google text,image,audio,video,pdf
deepseek-v3 5 none tier 4
mimo-1000 0 none tier 4

Note mimo-v2.5 is multimodal while mimo-v2.5-pro is text-only — which is exactly why there is no family-name fallback here.

Unknown resolves permissively

The two ways of being wrong do not cost the same:

  • Guess "supported" wrongly → the request goes out and the provider answers with an explicit 4xx naming the media it rejected. Loud, attributable, fixable by declaring modalities.
  • Guess "unsupported" wrongly → the content is replaced before the request exists, by a sentence only the model can see, and there is no record of the decision.

Scope is narrow: config-declared models that no catalog entry covers. Catalog-sourced models (fromModelsDevModel) still answer strictly from metadata. Output modalities get tiers 1–3 but not tier 4's permissiveness — nothing decides whether to send based on them, so there is no silent-drop risk to correct for.

Inference is marked and attributable

capabilities.inferred (new, optional) records the tier and the exact models.dev ref a verdict was inherited from, so a wrong verdict is attributable to the inheritance instead of anonymous. It is absent when the user declared the modalities.

Autonomous selection does not trust assumed

getVisionModel() ranks by cheapest, and a BYOK model defaults to cost.input = 0 — so a blanket permissive default would have made a possibly-text-only BYOK model the systematic first pick for "the vision model". That would trade this bug's silent drop for a new silent failure. getVisionModel now filters through hasEvidencedImageInput, which rejects assumed.

The asymmetry: honouring what the user explicitly attached is permissive; the engine picking a model on the user's behalf requires evidence.

Second commit: the withhold decision is now reportable

unsupportedParts still substitutes when a modality genuinely is unsupported — but the decision is no longer invisible. It is announced on the user's own channel using the mechanism already in the repo for this (MEMORY_WRITE_OFF_FALLBACK_NOTICE): a synthetic text part with ignored: true so it stays out of the model's context, and time.end set so the CLI emits it. Single-language English, matching that precedent — the engine cannot know the reader's locale and the consuming client already carries its own translations.

The notice names the file, the modality, the model, and — when the verdict was inferred — the tier and the entry it came from:

Not sent to acme-byok/mimo-v2.5-pro: probe.png (image). The model is recorded as not accepting image input, so the attachment was replaced with a note to the model rather than uploaded. This model's input modalities were not declared in config — they were inferred (directory from xiaomi/mimo-v2.5-pro), so the verdict may be wrong. To send it anyway, set modalities.input for this model in config, or switch to a model that accepts it.

The verdict is factored into ProviderTransform.withheldModality, which unsupportedParts now calls, so the announcement and the substitution cannot drift apart.

Verification

End-to-end against a fake OpenAI-compatible upstream that records request bodies verbatim, with a custom provider id absent from models.dev, a pinned models.json, and a real 64×64 PNG. Baseline is the same tree at HEAD~1, materialised with git archive + symlinked node_modules.

Scenario Model Baseline This PR
the defect acme-byok/mimo-v2.5, no modalities image_url=0; "no vision support" image_url=1, 12,570-char data URL
tier 3 negative mimo-v2.5-pro (catalog: text-only) image_url=0 image_url=0 — correctly withheld
tier 4 unknown acme-private-9000 (absent) image_url=0 image_url=1
tier 1 authority mimo-v2.5 + modalities.input:["text"] image_url=0 image_url=0 — config wins over catalog
reportable notice raw file part via POST /session/:id/message 0 notice parts 1 ignored: true notice

The baseline run's own advice is worth quoting, because it is the bug in one line — it suggested dispatching a subagent with --model xiaomi/mimo-v2.5, i.e. the directory knew perfectly well that this exact model has vision, while the instance under a custom provider id was marked blind.

Every inference emits a log line carrying its provenance:

service=provider providerID=acme-byok modelID=mimo-v2.5 apiID=mimo-v2.5
  provenance=directory source=xiaomi/mimo-v2.5 image=true  input modalities inferred

service=provider providerID=acme-byok modelID=acme-private-9000 provenance=assumed image=true
  fix=Declare modalities.input in mimocode.json under provider.acme-byok.models.acme-private-9000 ...

bun test test/provider 474 pass / 0 fail. bun test test/session 910 pass / 0 fail, identical counts to the baseline tree. Repo-wide turbo typecheck clean. capabilities.inferred is optional, so the change is schema-compatible.

Not verified

  • No real provider was contacted. Every run went to a local fake upstream. "A wrong permissive guess produces a clean upstream 4xx" is reasoning from the asymmetry, not an observation.
  • hasEvidencedImageInput inside getVisionModel is not covered end-to-end — it typechecks and the predicate is trivial, but no run exercised an assumed model being excluded from vision auto-selection.
  • Only image was exercised. The code path is modality-generic, but audio / video / pdf tier-4 permissiveness was not observed.
  • The notice fires only for file parts on the user message. The read-tool path pre-checks and emits its own user-visible text, so it never reaches the transform. Reachability was verified through the server route; other client entry points were not audited.
  • inferred.output is written but nothing reads it — recorded for attribution only.
  • Directory-index cost not profiled. One pass over ~180 providers at provider-state init, memoized per bare id.
  • prettier --write was never run (known hazard on this repo). provider.ts / prompt.ts / transform.ts remain prettier-dirty in exactly the hunks that were already dirty at HEAD; modality-inference.ts is clean.
  • test/session flakiness is asserted, not root-caused. 9 failures appeared on one run and did not reproduce on rerun or on the baseline tree.

wqymi added 4 commits August 7, 2026 22:07
…OK models

A model declared under `provider.<id>.models.<id>` without `modalities`
had its non-text input capabilities resolved by a two-step `??` chain that
ended in a hardcoded `false`. The second step consulted models.dev, but
only within the SAME provider id, so a user-invented provider never
matched anything: a model whose catalog entry plainly lists image input
still came out `image: false`, and `provider/transform.ts` then replaced
the user's image with an error sentence addressed to the model. The model
correctly reported it had received no image while the image sat in the
session record, which reads exactly like a regression in the client.

Resolution is now four ordered tiers (provider/modality-inference.ts):

  1. declared       — `modalities` in config, always authoritative
  2. provider-entry — the same-named provider's existing catalog entry
  3. directory      — NEW: bare model id looked up across the whole
                      models.dev directory, taking the first-party entry
  4. assumed        — nothing is known, and that is recorded as unknown

Tier 3 derives first-partyness from the directory's own shape rather than
a hand-maintained vendor table: aggregators list resold models under the
vendor namespace (`xiaomi/mimo-v2.5`), the vendor lists it bare, so the
namespaces that providers agree on, intersected with the providers holding
the bare id, name the vendor entry. Exactly one survivor is required — no
union of aggregator claims and no majority vote, so a contradictory or
absent directory falls through to tier 4 instead of guessing.

Tier 4 resolves permissively. The two ways of being wrong do not cost the
same: guessing "supported" wrongly produces an explicit upstream 4xx that
names the rejected media, while guessing "unsupported" wrongly drops the
content before the request exists and leaves nothing anywhere saying the
engine chose to. Its scope is only config-declared models no catalog entry
covers; catalog-sourced models still answer strictly from metadata.

Inferred verdicts are marked (`capabilities.inferred`) with the tier and
the exact models.dev ref they came from, so a wrong verdict is
attributable to the inheritance instead of being anonymous. Autonomous
capability-based selection (`getVisionModel`) additionally refuses
`assumed` evidence via `hasEvidencedImageInput` — the permissive default
exists to honour content the user explicitly attached, not to let the
engine pick a text-only model to look at a screenshot.
`ProviderTransform.unsupportedParts` replaces a file part the model is
recorded as unable to read with "ERROR: Cannot read … Inform the user."
That sentence is addressed to the MODEL and is written from inside an AI SDK
middleware: nothing about the substitution reaches the transcript. The
observable result is a model insisting it received no image while the image
is right there in the session record — which reads like the consuming client
mangled the attachment, not like the engine deciding to withhold it.

A component that decides to do less has to be able to say so. The decision
is now announced on the user's own channel, using the mechanism this repo
already has for exactly this (`MEMORY_WRITE_OFF_FALLBACK_NOTICE`): a
synthetic text part with `ignored: true`, so it stays out of the model's
context, and `time.end` set, so the CLI emits it. Single-language English,
matching that precedent — the engine cannot know the reader's locale and the
consuming client already carries its own translations.

The notice names the file, the modality, and the model, and when the
verdict was INFERRED rather than declared it says so and names the entry it
was inherited from. That last part is the difference between "your config
says this model is text-only" and "we guessed, and the guess may be wrong".

The verdict itself is factored into `ProviderTransform.withheldModality` and
`unsupportedParts` now calls it, so the announcement and the substitution
cannot drift apart.
mimo-auto is a free-tier routing alias: no catalog publishes it, and it
dispatches to a vision-capable model. Its image support was applied by
assigning `capabilities.input.image = true` onto the finished model, after
modality resolution had already recorded the verdict's provenance as
`assumed` — nothing was known about this id.

Both halves were then true at once: the model claimed image input, and the
claim was labelled a guess. `hasEvidencedImageInput` exists precisely to
refuse guesses when the ENGINE picks a model on the user's behalf, so the
alias was filtered out of `getVisionModel`. On a free-tier configuration
mimo-auto is the only vision channel there is, so a user on a text-only
model who read an image got told, by the read tool, that no vision-capable
model was configured — the same class of wrong advice this branch set out
to remove.

Fixed at the source rather than by widening the predicate: resolution grows
a BUILTIN tier, ranked directly below the user's own `modalities` and above
every catalog lookup, holding the modalities this repo itself vouches for
its own aliases. A stated fact now enters the pipeline as part of the
verdict, and post-hoc mutation of a constructed model — the actual cause —
is gone.

The entry is the complete input map (`text`, `image`), which is the shape
mimo-auto has always had; the permissive `assumed` tier is for ids nobody
looked up, which is the opposite of an alias this repo defines.

`hasEvidencedImageInput` needed no change: it tests against `assumed`
specifically rather than allow-listing good provenances, so a new source of
knowledge counts as evidence by default. Callers that report a verdict as
inferred or possibly wrong now ask `ModalityInference.isStated` instead of
comparing against `declared`, so a stated fact is not described to the
model as a guess.

Also names the permissive counterpart `acceptsImageInput`, so the two
image questions — may an image be handed to the model the user chose, and
may the engine pick a model to look at one — stop being told apart by
whichever literal a call site happened to write.

Tests pin the cause as an absence: mimo-auto's verdict must never be
recorded as `assumed`, since that is what removes it from selection however
the selection is later written. Also pinned: an assumed verdict is honoured
for the user's own model yet stays unselectable, and a user's own
declaration still overrides the builtin entry.
Three call sites answered the question three ways. `getVisionModel` required
evidence; the vision list in the system prompt and `actor models --vision`
both read `capabilities.input.image` raw. So a model whose image support is
merely assumed — a BYOK id no catalog covers — was advertised to the model
as a `--model` target it could dispatch a subagent to, while the engine's
own selection would never have chosen it.

Advertising and picking are the same decision: a ref the engine hands over
is one the model will dispatch to. Both lists now filter on
`hasEvidencedImageInput`.

That disagreement was also masking the mimo-auto exclusion fixed in the
previous commit. The system prompt fell back to `visionModels[0]` from its
own permissive list, which still contained the alias that selection had
dropped, so the block kept naming a usable model; only the read tool, which
has no such fallback, showed the failure. Both halves are needed — one
predicate, and a correct verdict feeding it.

The remaining sites ask the opposite question: whether an image may be
handed to the model the user already chose. Those keep the permissive answer
and now say so by name, `acceptsImageInput`. In session/system.ts the two
questions sit one line apart, which is where the distinction most needed to
stop being invisible. Per-modality mime tables that read `input.image`
alongside `input.pdf` and `input.audio` are left alone: they are uniform
lookups, not this policy choice.

The fake provider's `getVisionModel` answered from the raw capability too,
so a test could have passed on a model production would skip. It now uses
the real predicate.

The new test pins the negative: a model whose image support is only assumed
must never appear in the refs offered to the model, and the free-tier alias
must not be missing from them.
@wqymi

wqymi commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

Both findings fixed — head 6b40d259a. Thanks for tracing every getVisionModel / hasEvidencedImageInput caller; Finding 1 was a real regression and neither I nor the original implementation caught it.

Finding 1 — fixed, but not at the site you suggested

You proposed marking the verdict as evidence inside the mimo-auto override. That works, but I think the override itself was the cause rather than a detail: mutating an already-constructed model is what let input.image === true and inferred.input === "assumed" be simultaneously true. Patching inferred at the same site would have the same shape and would break again the next time a field is derived from provenance.

So mimo-auto is now declared rather than patched, in modality-inference.ts:

const BUILTIN_INPUT: Record<string, readonly InputModality[]> = {
  "mimo/mimo-auto": ["text", "image"],
}

New provenance tier "builtin", ranked below the user's own modalities and above every catalog lookup. The post-hoc parsedModel.capabilities.input.image = true block is deleted.

Not reusing "declared" deliberately: the module defines that as "the user wrote modalities in config", and builtin says where the fact actually came from — which then shows up in the log line and the model record.

hasEvidencedImageInput needed no change, because it tests !== "assumed" rather than allow-listing good provenances, so a new knowledge source counts as evidence by default. That property is now stated in its doc comment so it isn't later "fixed" into an allow-list.

Two follow-on sites that would otherwise have mis-reported a builtin fact as inferred (prompt.ts:2490 and the log.info("input modalities inferred") gate) now ask ModalityInference.isStated(provenance)declared || builtin.

⚠️ One consequence worth a second opinion. The builtin entry is a complete map, so pdf / audio / video are false. That restores the shape mimo-auto had at e7c691ab0 (verified: pre-PR each non-text modality resolved via ?? false). This PR's permissive tier had incidentally widened the alias to accept pdf/audio/video, because the alias was falling through to "nobody looked this up" — which was never true of a model this repo defines. I kept the long-standing negative rather than inventing a new one, but if mimo-auto does route pdf, BUILTIN_INPUT is now the one line to change, and it is at least visible now instead of implicit.

Finding 2 — agreed it matters, and it was worse than reported: three copies, not two

tool/actor.ts:673 — the actor models --vision list, which read.ts's own hint explicitly tells the model to consult — was a third raw-literal copy, as was the (vision) annotation at :679.

I did both unify and split, because there really are two questions — but the boundary is not where the review put it:

Question Predicate Sites
May the engine pick or recommend this model to look at an image? hasEvidencedImageInput getVisionModel, system.ts:150 list, actor.ts --vision filter + (vision) label
May an image be handed to the model the user already chose? acceptsImageInput (new, permissive) system.ts:138 gate, read.ts, view-image.ts

The reasoning on system.ts: the list is refs the model passes to --model and dispatches a subagent to, so advertising is selecting and it must match getVisionModel — that is the unification you asked for. But system.ts:138, one line above, and read.ts:232 ask whether the user's own model can take the image, where an assumed verdict must still count; that is the permissive policy this PR exists to protect. That difference is now two named exported predicates with the reason in each doc comment, instead of two coincidentally-matching literals.

Left alone deliberately: session/tool-attachment.ts:35 and provider/capability-registry.ts:163 read input.image beside input.pdf / input.audio in uniform per-modality mime tables — naming only the image line would make them less consistent, and they are not the image policy choice.

Also fixed: test/fake/provider.ts's getVisionModel answered from the raw capability, so a test could have passed on a model production would skip.

Your masking observation, reproduced live

The E2E now drives the real read tool (fake upstream answers request 1 with a real read call), on a pure free-tier config: current model declared text-only, mimo/mimo-auto present, xiaomi disabled so the alias is the only vision channel.

Read tool output as the engine put it on the wire — fixed:

Cannot read image "e2e-probe.png" — the current model has no vision support, so its visual content is unavailable.
If you need to understand the image visually, dispatch a vision-capable subagent: actor run <type> "<desc>"
"analyze the image at .../e2e-probe.png" --model mimo/mimo-auto (run `actor models --vision` for the full list).

Same harness, same config, src reverted to 0b30b21b5pre-fix:

... If you need to understand the image visually, no vision-capable model is configured — ask the user to
configure one or use an OCR tool.

Sentinels, scoped to the tool message rather than the whole request body:

--- buggy  : has "--model mimo/mimo-auto": false | has "no vision-capable model is configured": true
             system-prompt advertised list: mimo/mimo-auto      <-- the accidental mask
--- fixed  : has "--model mimo/mimo-auto": true  | has "no vision-capable model is configured": false
             system-prompt advertised list: mimo/mimo-auto

That middle line is your Finding-2 point observed directly: pre-fix read.ts fails while system.ts still names mimo/mimo-auto, purely because its permissive list happened to carry the model that selection had dropped. Scoping the grep to the whole body would have hidden the failure — which is how it stayed hidden.

Tests, pinned as absences

  • expect(auto.capabilities.inferred?.input).not.toBe("assumed") — the cause, pinned as an absence, so it survives any rewrite of how the list is built (a positive toContain("mimo/mimo-auto") would pin only today's construction).
  • Policy-difference pin on an assumed BYOK model: acceptsImageInput true and hasEvidencedImageInput false — collapsing the two predicates in either direction fails here.
  • system.test.ts, through the real SystemPrompt.environment: expect(prompt).not.toContain("acme/mystery-1") — an assumed-image model must never be advertised as a dispatch target.
  • Tier order: a user's modalities: {input:["text"]} on mimo-auto still wins.

Discriminating power checked rather than assumed — the pinned catalog has 182 providers, no mimo provider and no mimo-auto anywhere, so pre-fix the alias necessarily lands in tier assumed.

typecheck clean. bun test test/provider 477 pass / 0 fail (474 → 477). bun test test/session 911 pass / 0 fail. Prettier: the 5 files that warn in this tree are exactly the 5 that already warn at HEAD, and my changed lines appear as unchanged context in those hunks; --write was not run.

Still not verified

  • mimo-auto's real pdf / audio / video support is unconfirmed — see the flag above.
  • actor.ts's --vision path is covered by the shared predicate and by reasoning, not by an observed actor models --vision run.
  • The E2E asserts the read tool's hint text; it does not assert a subagent dispatched to mimo/mimo-auto actually receives the image (upstream is fake).
  • Full bun test not run — only test/provider and test/session.
  • Carried over from the original submission: no real provider was ever contacted, so "a wrong permissive guess produces a clean upstream 4xx" remains reasoning from the cost asymmetry rather than an observation; and only image was exercised end-to-end.

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.

1 participant