The fields upstreams decode: three 400s on a request that looked fine
Haiku 5.5 rejecting thinking, an image_url in a tool result, and an OpenCode session id failing a timestamp check: three 400s on valid requests.
A gateway spends most of its life moving fields from one request shape to another. The quiet assumption underneath that work is that a field it does not understand is a field it can copy. Most of the time that holds. The three bugs below are what happens when it does not: in each one the request was well-formed, the client was right, and the upstream still answered 400 or 429 — because it read a field the gateway had treated as opaque.
All three were fixed in kRouter v0.5.168 (the changelog has the full list). They are worth writing up together because the failure mode is the same each time, and it is not specific to kRouter: if you write anything that forwards LLM requests, two of these three can bite you.
1. A model that changed its mind
Anthropic's extended thinking has two shapes. The older one names a token budget — {"type": "enabled", "budget_tokens": 10000} — and the newer one lets the model decide: {"type": "adaptive"}.
Haiku 3.x and 4.x only accept the first. Ask them for adaptive thinking and the request fails, so a gateway that wants Haiku to work has to downgrade adaptive into a budget. kRouter did exactly that, with a rule that read, in full, /haiku/i.
Then Haiku 5.5 shipped, and it accepts only adaptive. The same downgrade that rescued Haiku 4.5 now broke Haiku 5.5:
400 thinking.type.enabled is not supported for this modelThe fix is to read the major version out of the id and downgrade only below 5. That sounds trivial until you look at how many ways a Haiku id is spelled. The version can follow the name (claude-haiku-4-5, claude-haiku-4.5) or precede it (claude-4.5-haiku, claude-3-5-haiku-20241022), with or without a provider prefix and with or without a date suffix. That last one is a trap of its own: match the digits greedily and claude-3-5-haiku-20241022 reads as version 20241022, which is comfortably above 5, so the downgrade stops happening for a model that needs it.
An id with no version at all keeps the old downgrade, because there is nothing to judge on and the old behaviour is the safe one. A future Haiku 6 is left alone.
The general lesson: a rule written against a model family is a rule with an expiry date. Providers change a model's accepted parameters between versions, and the family name does not change with them.
2. An image that was fine in one message and illegal in another
OpenAI and Anthropic describe images differently. OpenAI uses {"type": "image_url", "image_url": {"url": "..."}}; Anthropic uses {"type": "image", "source": {...}} with either a base64 payload or a url. A gateway translating between them converts images wherever it finds them.
kRouter converted them in user messages. It did not convert them inside a role: "tool" message, because that branch copied the tool's content straight into Anthropic's tool_result block. So a tool that returned a screenshot produced this:
400 Input tag 'image_url' does not match any of the expected tagsWhat makes this one frustrating is that Claude accepts images inside a tool result perfectly well. There was no capability missing on either side. The block simply had to be Claude-shaped, and one of the two paths that could produce it was not doing the conversion the other one already did.
The fix converts tool-result content the same way the user branch does, deliberately reusing the same logic rather than writing a second copy — two paths doing the same job differently is how the bug started. Data URIs become base64 image blocks, http(s) urls become url blocks, blocks that are already Claude-shaped pass through, and a plain string is left alone. If nothing in the payload is convertible the original is sent as-is, because an empty tool result is itself a 400.
The general lesson: when you convert a value, convert it everywhere that value can appear. A translator with one conversion per type and several per location will eventually miss a location.
3. A session id that was a one-in-a-hundred lottery
This one is the most interesting, because the field looks completely opaque.
OpenCode's free tier — a different problem from the missing x-opencode-session header on OpenCode Go — wants a session id shaped like ses_ followed by twelve hex characters and fourteen random ones. It looks like an identifier, so a gateway reasonably treats it as a token to generate and keep stable. It is not. Those twelve hex characters encode two values, and the upstream decodes both:
kRouter had worked out the counter half from live probes: a counter of 1 passes, 500 is refused, so it pinned the counter to 1 and moved on. The timestamp half it filled from a sha256 of the conversation seed.
That choice had a real reason behind it. The free quota is counted per session, so minting a fresh id on every request burns the quota and comes back as a 429 — and a 429 is not one error. A digest gives you a stable id for free — the same conversation always hashes to the same value, with no store to keep and nothing to evict.
The problem is what a digest does to a timestamp. Those 36 bits are a point in time, and a hash spreads them uniformly across everything the field can hold: about 795 days of milliseconds. The upstream decodes that value and refuses a session dated in the future or older than the free-tier window.
So the lane was not broken in the way it looked. It worked, roughly one request in a hundred.
The fix keeps the reason the digest existed and drops the digest. Ids are minted from the current clock and memoised per conversation, so one conversation still gets exactly one id — the quota property is intact — and the timestamp is one the gate accepts. Determinism was never the goal; stability was, and a current timestamp and pure determinism cannot both be true, because a value derived from the clock changes every time you derive it.
Worth saying plainly: this did not by itself revive the free lane. The tier gates on the TLS ClientHello as well, and without a client that impersonates one, the request still goes out over Node's fingerprint and is answered 429. That is separate work.
How to tell you are in this situation
The shared signature of all three: the client is sending something valid, the upstream is rejecting it, and the error names a field rather than a limit. Some things that help:
- Read the error's noun.
thinking.type.enabled is not supported for this modelis about a value, not a quota. A 429 that arrives instantly, on the first request of the day, is not really a rate limit either. - Compare against the vendor's own client. If the official CLI works from the same machine and IP and your gateway does not, the difference is in the bytes you send, not in your account.
- Read the request as it leaves. kRouter's MITM and request inspection shows the body actually sent, which is the only way to compare against a working client.
- Suspect fields you generate. Anything your gateway mints rather than forwards — session ids, request ids, fingerprints — is a field nobody validated against the upstream's expectations, because the upstream never saw a real client produce it.
- Check the published limits you did not set. The fourth fix in the same release:
oc/deepseek-v4.1-flash-freewas advertising a 1M context window inherited from the paid model's row, while the free backend rejects prompts over roughly 330k. Clients size their compaction from that number, so a long session never compacted and then died mid-run. Context windows across providers covers why that figure matters so much.
Common questions
How do I know whether my Haiku request is affected?
Only requests that carry a thinking field are. If you send Haiku 5.5 with thinking.type: "adaptive" through anything that rewrites thinking for Haiku, you will see thinking.type.enabled is not supported for this model. Requests without thinking were never affected.
Can I just not send images from tools?
You can, but you do not have to. Claude accepts image blocks inside tool_result — the block only has to use Anthropic's {"type": "image", "source": {...}} shape rather than OpenAI's image_url. If your gateway converts images in user messages but not in tool results, that is the bug, not a provider limit.
Does the session id fix make OpenCode Free work again? Not on its own. It removes one of three gates. The tier also checks the TLS ClientHello, and a request sent over Node's default TLS fingerprint is refused regardless of how correct the body is.
Why would a gateway ever derive an id from a hash? For stability without state. The free quota is counted per session, so an id that changes per request burns it; hashing the conversation seed gives the same id every time with nothing to store. The reasoning is sound — it just cannot be applied to a field that carries a timestamp the server checks.
Where do I see what kRouter actually sent? The dashboard's Translator page shows each request at every stage of translation, and the troubleshooting docs cover reading it. For the routing side, how kRouter picks an account explains what happens after a provider rejects a request.
Is any of this specific to kRouter? The second and third are not. Any proxy that translates OpenAI tool results into Anthropic messages can miss the image conversion in exactly the same place, and any proxy that mints OpenCode session ids can fill the timestamp with something the gate refuses. The first is specific to how a particular rewrite rule was written, but the shape of it — a rule keyed on a model family that a new version invalidates — is universal.
Published by Kodelyth, the team that builds kRouter. Posts are drafted with AI assistance and reviewed by a person before they go out. kRouter is free and MIT licensed.
Install kRouterRelated posts
- Fix an errorClaude Code context window with non-Claude models: the fixClaude Code assumes 200K for a model it doesn't recognize, so it compacts too early or overruns the real limit. How to declare the real window.
- Fix an errorScreenshots and text-only models like GLM-5.3: what worksGLM-5.3 can't read a pasted screenshot. Three fixes -- a model that reads images, a vision MCP server, a per-turn switch -- and where each falls short.
- Fix an errorCursor "You've hit your usage limit": three ways around itCursor's usage limit is two dollar pools, not a request count. Which one you spent, what on-demand costs, and how to keep working without upgrading.
Relevant docs
- TroubleshootingFixes for common kRouter problems: banned or rate-limited accounts, OAuth sign-ins, MITM certificates, localhost and Cursor, Docker logins and the CLI.
- Error referenceWhat the common AI provider errors mean, why they happen, and how to get past them.
- Combos & fallbackPut several models behind one name. kRouter falls back from one to the next, rotates them, or asks a panel of models and merges the answers.