On November 6 a customer sent me a screenshot from OpenCode:
ERROR: Cannot read <file>.png (this model does not support image input)
The model was DeepSeek V4.1 Flash, served through my API, tommyapi.com. That model reads images fine. The request never reached our server. OpenCode refused it locally.
It took a few hours to find two separate bugs, one in how OpenCode decides what a model can do and one in our own metadata. Fixing the second turned up 115 models we had mislabeled.
Bug 1: OpenCode decides capabilities before it sends anything
OpenCode doesn't ask the provider whether a model accepts images. It looks up the model in one of two places:
-
The models.dev catalog (
https://models.opencode.ai). If your provider and model are listed withmodalities.inputincluding"image", attachments go through. -
Your own config. A model entry in
opencode.jsoncan declaremodalities.
If neither says "image", OpenCode treats the model as text-only and strips or rejects the attachment client-side. The check lives in the session request code (unsupportedMedia in packages/core/src/session/model-request.ts at the time I read it).
A custom provider defined with @ai-sdk/openai-compatible and a bare model list gets the default: text only. That covers most small API providers. models.dev lists 237 providers, and we weren't one of them.
So every one of our customers who added us to OpenCode by hand, without writing modalities for each model, had image input switched off for every model.
Bug 2: our own /v1/models was lying
Some clients don't use models.dev and read capability hints from the provider's /v1/models response instead. Ours returned an architecture.input_modalities field per model.
That field came from a regex on the model name. Names that matched a list of known vision families got ["text","image"]; everything else got ["text"].
That held up until providers started shipping multimodal updates under the same names. DeepSeek's V4.1 Flash update added vision in October and the name didn't change, so the regex didn't either. When I audited it, 115 models that do accept images were tagged text-only: DeepSeek, Kimi, Qwen, GLM, Grok, MiniMax and others. We had 81 of 315 models marked as vision. The real number was 191.
A name tells you who made a model. It doesn't tell you what the model can do this week.
The fix: test each model with a real image, every week
I deleted the assumption and replaced it with a test.
Once a week a probe script sends every text-capable model a small image and a question, then sorts the model into vision, text_only or unknown. The proxy reads that result file when it builds /v1/models and hot-reloads it within 30 seconds of a change. The regex only applies to models the probe hasn't covered yet. An environment flag turns the evidence file off if it ever misbehaves.
Getting the probe right took longer than writing it.
Attempt 1: a 1x1 red pixel
It's tiny and cheap, and the question "what colour is this?" is easy to grade.
Most vision models said black. Image pipelines resize and pad small inputs, and a single pixel ends up as a speck on a black canvas. Grading on that answer would have marked nearly every working model as broken.
Attempt 2: a 128px solid colour block
Better, but still unreliable. On one route, 10 of 13 attempts named the colour correctly. The rest said white or orange. Colour is a weak question: it depends on preprocessing and colour handling, and a model can guess it.
Attempt 3: a text card with a random number
What worked was a 420x130 PNG with the text TOMMY 7391 OK, generated with PIL. The question asks for the number. The model passes only if its answer contains 7391.
Nobody guesses a four-digit number by accident, so a pass means the model actually read the pixels. A model that never got the image can't produce the number, so false positives are close to zero.
The rest of the grading logic:
-
Explicit rejections count as text-only. A 400 or 404 whose body mentions image input, or lists allowed values like
['text'], means the upstream refused the image. That's a cleartext_only. -
Parameter errors aren't capability errors. Some models reject
thinkingorreasoning_effort. The probe drops those parameters and retries before deciding anything. - One wrong answer gets one retry. Models are stochastic, and one bad read shouldn't flip a label for a week.
-
Everything else is
unknown, not text-only. Timeouts and 5xx errors say nothing about capability.
The final run on November 6 sorted 259 routable models into 182 vision, 65 text-only, 12 unknown.
Making OpenCode see it
Correct metadata on our side does nothing if OpenCode never reads it. I shipped three ways in.
Option 1: paste-in config
We publish a ready-made opencode.json, regenerated every hour from the probe results:
https://tommyapi.com/opencode/opencode.json
One entry looks like this:
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"tommyapi": {
"name": "TommyAPI",
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "https://tommyapi.com/v1",
"apiKey": "{env:TOMMYAPI_API_KEY}"
},
"models": {
"deepseek-v4.1-flash-1017": {
"name": "DeepSeek V4.1 Flash 1017",
"modalities": { "input": ["text", "image"], "output": ["text"] },
"limit": { "context": 1000000, "output": 384000 },
"tool_call": true,
"reasoning": true
}
}
}
}
}
The key comes from an environment variable, so the file is safe to share. For any OpenAI-compatible provider, not just mine, the field that matters is modalities.input. Without it, image input is off.
Option 2: point OpenCode at a models.dev mirror
OpenCode lets you override the catalog URL with OPENCODE_MODELS_URL. We serve the official models.dev api.json with a tommyapi provider merged in:
export OPENCODE_MODELS_URL=https://tommyapi.com/models.dev
export TOMMYAPI_API_KEY=sk-...
opencode
With that set, the provider shows up as a built-in, with correct capabilities and no per-model config. From the source (fetchApi in models-dev.ts), OpenCode first requests /experimental/v2.0/api.json and falls back to /api.json on a 404, so a mirror only has to serve the v1 file.
Two gotchas cost me time:
-
The first launch doesn't use the mirror. OpenCode starts from its bundled snapshot and fetches the URL in the background into
~/.cache/opencode/models-<sha1(url)>.json. The new catalog takes effect on the second launch. Our docs say this up front, because "I set the variable and nothing changed" is the first thing everyone hits. -
opencode modelsis not a valid test. It exits in about half a second without waiting for the fetch. To verify, runopencode serve, callGET /provider, and checkcapabilities.input.imageon the model. That's how I tested it on OpenCode 1.19.0:deepseek-v4.1-flashcame backimage: true, and a model the probe had marked text-only came backfalse.
Option 3: get listed upstream
The real fix is a models.dev listing, so nobody needs a mirror. I opened anomalyco/models.dev#12821 on November 6 with 160 model files. The generator only includes models we can actually route that also have a lab entry in the repo, and it copies reasoning-control fields from the lab's own provider entry instead of making them up. The PR passed the repo's validation and is still open as I write this.
What I took away
Capability metadata is part of your API. Clients make local decisions from it before your server sees a request. A wrong input_modalities value breaks things just as surely as a 500 does, and nothing gets logged on your end.
Don't infer capabilities from names. Names stay fixed while models change. If you serve more than a handful of models, test them.
A probe has to be impossible to guess. Colour questions fail both ways: tiny images break working models, and simple colours can be guessed. A random number on a text card is cheap, fast to grade, and forces the model to read the image.
Separate "no" from "don't know". A timeout isn't a capability result. Store unknown and keep the previous label.
Test the client, not just the server. Our /v1/models was correct for a day before any OpenCode user saw a change, because of the cache and second-launch behaviour. The end-to-end test was the only one that told the truth.
If you run an OpenAI-compatible endpoint and your users are on OpenCode, check two things today: whether you're on models.dev, and what your /v1/models says about images. If both are wrong, your vision models are text-only for those users.
I'm Tommy. I build tooling around Claude Code, Codex and OpenCode, and I run tommyapi.com. The OpenCode setup page with the live probe results is at tommyapi.com/docs/opencode/.
Top comments (0)