In production
Serving Cursor's requests through the gateway
Three request shapes production refused or quietly broke, and why the quiet failure was the dangerous one.
By Ehsan Gazar, 14 September 2026
You point Cursor at a custom base URL and ask it to edit a file. The model replies with text and changes nothing. No error appears anywhere.
You conclude the model chose not to use its tool. Or you conclude the gateway does not work with Cursor. Both conclusions are wrong, and the second one costs a customer.
What Cursor actually sends
With a custom base URL, Cursor calls /chat/completions from its own servers. Cursor's staff said so on its forum on 13 August 2026. So the door that matters is the Chat Completions door on gateway.vatan.one.
What Cursor puts inside that request is the harder part, because it has changed from release to release. On 14 September 2026, before any fix, I measured three shapes against production. I used a key in our own test workspace, with anthropic/claude-haiku-4-5 and openai/gpt-5-nano.
- Flat function tools. Cursor sends
name and parameters directly on the tool, with no nested function object. The Anthropic model answered 200 with no tool call. The OpenAI model answered 400. - A custom tool. Cursor's file-edit tool,
ApplyPatch, is declared as type: "custom". The results were the same: a 200 with no tool call, and a 400. - A Responses body on the chat path. A body carrying
input, reasoning and text instead of messages. Both models answered 400.
The 400s were the easy part
A 400 is loud. Somebody sees it and files a bug.
The two 200s were the real problem. A reply with finish_reason: "stop" and no tool call is a valid answer. It is exactly what a model returns when it decides not to call a tool. An agent reading it moves on. A person reading it blames the model.
Nothing throws and nothing turns red. The tool definition was lost on the way to the Anthropic wire, because the translation expected the nested shape and found nothing to translate.
A success that did not do the work is worse than an error, because nobody goes looking for it.
One canonical body, at the door
The tempting fix is three patches, one per shape. I did not do that, because the next Cursor release can send a fourth.
A canonical form is the one agreed shape that every other shape is converted into before real work starts. It is the old rule of normalising input at a system boundary, and it pays the same way: everything behind the boundary handles one case.
Everything downstream of our chat handler already read OpenAI's nested tool shape. The Anthropic translation read it, and so did the OpenAI passthrough and the cache key. So the new file, chat-shapes.ts, makes the body canonical once, at the door. Nothing below it learns that Cursor exists.
Flat tools become nested tools. A Responses body on the chat path is translated into a chat body. A custom tool becomes a function with one string argument. A body with none of these shapes comes back as the same object, so every existing client takes exactly the path it took before.
Normalise at the boundary, and prove the untouched path is really untouched.
And back out again
Converting the request is half the job. Cursor declared ApplyPatch as a custom tool, so it expects a custom call back, not a function call.
So a call to a tool the caller declared as custom goes back as a custom call. Its raw patch text is pulled out of the one-field wrapper first. The reverse also happens on later turns, when Cursor sends that call back in the conversation history.
This had to work buffered and streamed, and those are separate code paths. Streaming is the fiddly one. The model streams its argument as fragments of JSON, and the wrapper around the text must never leak through. The stream rewriter tracks each call and sends only the new text since the last chunk. Every other event passes through byte for byte.
I insisted on both paths because this repository has been here before. A streamed translation once dropped tool calls for days while the buffered one was correct.
What we gave up
A custom tool can carry a grammar that constrains what the model writes. OpenAI's wire supports that natively. We still convert custom tools to functions on every wire, OpenAI's included.
So the model is asked for a string and writes the patch as free text, unconstrained. In exchange, one translation means one set of tests and one shape coming back. I think that trade is right, and it is still a real loss. The gateway names it in the x-vatan-responses-dropped header as custom.format, so the loss is never silent.
If you drop part of a request, say so in the response.
How it was proved, and what that does not prove
The proof is client-compat.mjs, a script that sends requests to production and checks the answers. It gained Cursor's shapes in the same commit, 7d56156. Each shape runs on an Anthropic-wire model and an OpenAI-wire model, buffered and streamed.
It checks that a flat tool is actually called. It checks that a custom tool comes back as a custom call carrying raw text. It checks that the call and its result are accepted on the next turn. It also checks that a streamed tool turn still reports usage, so the turn is not metered at zero.
It is not the real Cursor app. Cursor sends requests from its own servers, and a custom key needs a paid Cursor plan. The script has neither. So this proves the gateway answers Cursor's published request shapes correctly. It does not prove that what Cursor sends today is one of those shapes.
That gap is why the fix covers the class rather than three cases. When a new shape arrives, it should land on a door already built to be surprised.
A gateway is judged by the client that fails quietly, so test the quiet failure first.