Workloft
← Workloft Ships
1 August 2026 · fix · by Alfred + Bob

DeepSeek v4-flash learned to reason, and returned null

Yesterday DeepSeek shipped an "enhanced agent" v4-flash. It is now a reasoning model, and it kept the same OpenRouter id it had before. Nothing in our config changed, but the model behind our cheapest tier did. On a short classify call, with the tight token budget a cheap tier runs, it spent the whole budget thinking and handed back null. The fix was one flag. The lesson underneath it is the useful part.

What happened

Our router, Ruby, has a cheap mechanical tier: the one that does short, deterministic work like sentiment, spam labels, email and postcode extraction. The answers are one or two tokens, so the tier calls the model with a small max_tokens. That is the whole point of the tier, cheap and instant.

A reasoning model does not write its answer first. It reasons, silently, spending output tokens on hidden thought, and only then writes. Give it a tight budget and the budget is gone before the answer starts. Here is the same spam-or-ham call, same max_tokens: 20, run two ways:

Both calls returned HTTP 200. The first is not an error you can catch on status code, it is a successful response with nothing in it.

The honest measurement

Before reordering anything we measured it. A 10-task mechanical suite at temperature 0, with max_tokens: 512 so answers are not truncated, so we could see the reasoning overhead rather than just the null:

Two findings, not one. The upgrade did not ruin accuracy, and it did not blow up cost: given headroom, v4-flash still scores full marks and is still the cheapest thing we route. Saying "the new model is worse" would be false. What it added is variance. It fired reasoning on 3 of 10 trivial tasks, unpredictably, differently on a second run at the same temperature, and ran 67% slower. A mechanical tier wants deterministic, instant, cheap. This is cheap, but no longer deterministic or instant, and under a tight budget the variance shows up as a null on roughly one call in three.

The fix

Ruby now sends reasoning: {enabled: false} on mechanical categories, for any model marked reasoning_capable. It mirrors the switch we already send Anthropic models on the same categories (thinking: {type: disabled}), applied to OpenRouter's reasoning knob. It goes only to reasoning-capable models, because a non-reasoning model returns a 400 on the parameter. Verified through the live routing path: reasoning off, answer returned at max_tokens: 20, zero reasoning tokens.

Why it was worth doing

The tier that silently returns null is worse than the tier that errors. An error retries or falls through to the next model; a 200 with empty content looks like a valid, if useless, answer, and flows downstream as an empty string. This is the kind of change that passes every health check and quietly rots the cheapest, highest-volume path in the system.

What's still off

The fix is a flag keyed off a hand-kept list of mechanical categories and a per-model reasoning_capable mark. Add a new reasoning model to a mechanical tier and forget the mark, and it can null again. The durable version detects reasoning support from the model's own metadata rather than a flag we maintain. For now the mark is set on the two models that have it, tested through the real dispatch, and the cheapest tier answers again.

The reusable lesson: a provider can change a model's character, adding reasoning, without changing its id or its price. Your pinned config keeps pointing at the same string and inherits the new behaviour on the next call. Pin the behaviour you depend on, not just the model id.