fix(core): refuse models whose declared output has no text - #4243
Conversation
`isModelExplicitlyUnsupportedForChat` guards four call sites — the model catalog, connection readiness, the execution model authority, and session catalog selection — and could not fire on real data. Its image branch keys on `capabilities.imageGeneration`, and nothing in production has ever set that flag: the generated metadata carries the fact as `modalities.output` instead, where 128 entries declare an output that is not text and 0 declare `imageGeneration`. So `gpt-image-2` and every other image or speech model was selectable as a chat model, and the failure surfaced as a provider error on the first request rather than a refusal at selection. The guard now also refuses a model whose declared output names modalities but not text, which is the form the fact actually arrives in. Only an explicit `chat: true` outranks it; `reasoning` and `functionCalling` do not, because a TTS model carrying `reasoning: true` is describing how it composes speech and still cannot answer in text. An empty output list stays allowed. `modalities.output` is typed to text, image, and audio, so a video model's real output has no representation and serializes as `[]` — the same shape a generator bug would produce. Blocking on it would be guessing, so the rule reads only non-empty lists. `makeEntry` now passes the merged `modalities` into the availability derivation beside the merged `capabilities`. It passed only `normalizedModel.modalities`, so a bundled image-only model reached the guard with no output declaration at all. `toModelInfo` stopped dropping `output_modalities`. It was validated and discarded, so a relay that advertised an image-only model handed back a row indistinguishable from a chat model's. The fact is recorded as a capability rather than as modalities because `decodeConnectionModel` is an exact record that does not persist `modalities` — emitting it would throw `unknown field` on the next catalog read. Generated-by: Claude Opus 5 via Claude Code
Astro-Han
left a comment
There was a problem hiding this comment.
Thanks—this fixes the problem at the right shared authority rather than adding a picker-only workaround.
The issue is real: production metadata expresses image/audio-only output through modalities.output, while the existing guard was looking primarily at capabilities.imageGeneration, which production data did not populate.
This change extends the existing isModelExplicitlyUnsupportedForChat() predicate used by connection readiness, model catalogs, the Runtime Host execution-model authority, and Session catalog selection. It also stops discovery from dropping the provider’s output-modality fact. The guard remains conservative: only a known, non-empty output declaration without text is rejected, while an explicit chat: true still wins.
That keeps one decision boundary, covers both selection and execution, and does not introduce a parallel model-capability authority. I found no P0–P2 issues on exact head 54b9aa4dc9f05f0e41d695f8a411a80b74391a36; the hosted checks are green.
中文对照
这个修复落在正确的共享权威上,而不是只在某个选择器里打补丁。它让连接检查、模型目录、Runtime Host 执行入口和 Session 目录共用同一个判断,同时保守处理未知或空的输出类型。当前没有发现 P0–P2,可以批准。
AI-assisted review: Codex traced the shared production authority and drafted this review; the maintainer verified the final conclusion.
Summary
isModelExplicitlyUnsupportedForChatguards four call sites — the model catalog, connection readiness, the execution model authority, and session catalog selection — and could not fire on real data.Its image branch keys on
capabilities.imageGeneration. Nothing in production has ever set that flag; the generated metadata carries the fact asmodalities.outputinstead:So an image or speech model was selectable as a chat model, and the failure surfaced as a provider error on the first request instead of a refusal at selection. Found while diagnosing a relay connection whose only enabled model was
gpt-image-2: the connection test posted a text probe to/v1/responsesand the toast could only suggest checking the key, the URL, and the proxy — all three of which were fine.Three changes:
chat: trueoutranks it;reasoningandfunctionCallingdo not, because a TTS model carryingreasoning: trueis describing how it composes speech and still cannot answer in text.makeEntrypasses the mergedmodalitiesinto the availability derivation, beside the mergedcapabilities. It passed onlynormalizedModel.modalities, so a bundled image-only model reached the guard with no output declaration at all.toModelInfostops droppingoutput_modalities. It was validated byassertOptionalArrayand then discarded, so a relay that advertised an image-only model handed back a row indistinguishable from a chat model's.An empty output list stays allowed.
modalities.outputis typed to text, image, and audio, so a video model's real output has no representation and serializes as[]— the same shape a generator bug would produce. Blocking on it would be guessing, so the rule reads only non-empty lists.gemini-omni-flash-previewandminimax/minimax-h3are the concrete cases: both are video models whose output serializes empty, and both stay selectable. Expressing video in the modality union is a separate data question and is not attempted here.The fetched fact is recorded as a capability rather than as modalities on purpose:
decodeConnectionModelis anexactRecordoverid, displayName, apiProtocol, contextWindow, maxOutputTokens, capabilitiesand does not persistmodalities, so emitting it would throwunknown fieldon the next catalog read.chatandimageGenerationare both already in that allowlist.Behavior change
Models that declare a non-text output are now refused at selection rather than at the first request. Concretely, on bundled metadata that is every image model (
gpt-image-*,bytedance/seedream-*,bfl/flux-*,recraft/*, …) and every TTS model (openai/tts-*,gemini-*-tts,fish-audio/*,mimo-*-tts, …). I checked all 128 entries whose declared output is not text: every one is an image, speech, or video generation model, and none is a chat model.A user who had already selected such a model as their default sees
model_not_chat_capableinstead of a provider error whose text depends on the relay.Verification
The 5
packages/runtimefailures arespawn rg ENOENT— ripgrep is not installed on this machine. I confirmed they fail identically on this branch's merge-base withmainand with these changes stashed, and all five are Glob/Grep tests untouched by this PR.Each change fails a test when reverted individually:
modalitiesintoderiveModelUnavailableReasonoutput_modalitiescapture intoModelInfoknownOutputModalitiesfilterBehavior against the real bundled catalog, before and after:
Self-review
Reviewing the diff before opening this turned up one defect, which is fixed here.
assertOptionalArrayvalidates the container and never its items, sooutput_modalitiesis typedstring[]but holds whatever the relay returned. My first version read the raw array, which meant['Text'],[null], or[42]would all failincludes('text')and setchat: false— silently disabling a working chat model. The neighbouring modality reads in that function do not have this problem because they only ever add a capability, where an unrecognized value merely costs a fact; this one removes chat, so the same miss is destructive. The read now goes throughknownOutputModalities, which keeps only values this build understands, so an unrecognized list reads as "said nothing" rather than "said not text". A test covers each of those four inputs plus the mixed['image', 'hologram']case.AI use
Tool(s) and scope: Claude Opus 5 via Claude Code — investigation, the three code changes, the tests, and this description. Reviewed and verified locally by me; the commit carries a
Generated-bytrailer.Checklist
Does this PR entail a change in behavior?