This is a debugging story where every error message was technically true and completely misleading.
I run a local AI assistant built on the Claude Agent SDK. The architecture has a gateway layer: the desktop client talks to my server, and the server decides which upstream LLM provider handles the request. Anthropic direct, an OpenAI-compatible endpoint, whatever the user configured.
Everything worked against Anthropic. Then I pointed it at a DeepSeek gateway that speaks the OpenAI protocol, and every single request failed:
Model does not exist. Please check the model name. The model name was correct. I checked it four times — deepseek-chat, spelled right, available on the provider's model list, working fine in a plain curl. The error was a lie, or at least a badly chosen message for what was actually happening.
Three separate bugs conspired to produce it. Here they are, because each one is a trap you will hit if you build an OpenAI-compatibility layer.
Bug 1: A Query String That Broke Routing
The Claude Agent SDK builds its request URL from a configured base URL, and it appends ?beta=true to certain endpoints. That's harmless against the Anthropic API. It's poison for a gateway that routes on path.
The OpenAI-compatible gateway matches on the path prefix — /v1/chat/completions — and ?beta=true is a query parameter, which shouldn't matter at all. But the routing layer in front of it was doing exact-ish matching on the full path string, so /v1/chat/completions?beta=true fell through the route table and landed on a default handler that returned... "model does not exist."
The fix is one line: strip the query string before routing.
// Normalize the incoming path for routing — SDK query params are not meaningful upstream const url = new URL(req.url, 'http://localhost'); const routePath = url.pathname; // /v1/chat/completions, no ?beta=true The lesson: never assume a query parameter is harmless just because the HTTP spec says it is. If any layer does string matching on URLs, unknown parameters can change behavior. Normalize at the boundary, once, explicitly.
Bug 2: Assistant Messages Must Be Merged, Not Appended
The Claude Agent SDK represents a turn with tool use as separate content blocks — one for the text, one for the tool call, one for the tool result. The Anthropic Messages API accepts that natively:
[ { "role": "assistant", "content": [ { "type": "text", "text": "Let me look at that file." }, { "type": "tool_use", "id": "tu_1", "name": "read_file", "input": { "path": "a.ts" } } ]}, { "role": "user", "content": [ { "type": "tool_result", "tool_use_id": "tu_1", "content": "..." } ]} ] The OpenAI Chat Completions API does not. Its assistant message has a content string field and a separate tool_calls array. If you naively translate block-by-block, you emit two consecutive assistant messages:
[ { "role": "assistant", "content": "Let me look at that file." }, { "role": "assistant", "content": null, "tool_calls": [...] } ] Some gateways reject this. Some accept it and quietly lose the text. DeepSeek's gateway was in the first camp, and its error surfaced as the same unhelpful "model does not exist."
The fix is a translation step that merges all blocks from one assistant turn into one message:
function toOpenAIMessages(blocks: SdkMessage[]): OpenAIMessage[] { const out: OpenAIMessage[] = []; let pendingAssistant: any = null; for (const m of blocks) { if (m.role === 'assistant') { if (!pendingAssistant) pendingAssistant = { role: 'assistant', content: '', tool_calls: [] }; for (const b of m.content) { if (b.type === 'text') { pendingAssistant.content += b.text; } else if (b.type === 'tool_use') { pendingAssistant.tool_calls.push({ id: b.id, type: 'function', function: { name: b.name, arguments: JSON.stringify(b.input) }, }); } } } else { if (pendingAssistant) { // flush before switching roles if (pendingAssistant.tool_calls.length === 0) delete pendingAssistant.tool_calls; out.push(pendingAssistant); pendingAssistant = null; } // user turn: text and each tool_result become their own message for (const b of m.content) { if (b.type === 'text') out.push({ role: 'user', content: b.text }); else if (b.type === 'tool_result') { out.push({ role: 'tool', tool_call_id: b.tool_use_id, content: String(b.content) }); } } } } if (pendingAssistant) { if (pendingAssistant.tool_calls.length === 0) delete pendingAssistant.tool_calls; out.push(pendingAssistant); } return out; } The lesson: when you translate between two protocols, translate at the semantic level (a turn), not the syntactic level (a block). The shapes look similar enough that block-by-block mapping almost works, and "almost" is where the debugging hours go.
Bug 3: Errors That Describe the Wrong Layer
The third bug isn't in my code — it's that the failure message pointed at the wrong thing.
"Model does not exist" is what the gateway says when any part of its request validation fails, probably because the model field is validated first and that's the only error it knows how to articulate. My request had a perfectly valid model name; the actual problem was in the message array's shape.
This is a recurring pattern with OpenAI-compatible gateways: broad error messages with a single, specific-sounding cause. When you see "model not found" from a compatibility layer, don't just re-check the model name — dump the exact request body you sent and compare it against what a known-good curl sends.
What I ended up adding to my gateway was a request echo on failure:
try { const upstream = await fetch(target, { method: 'POST', body: JSON.stringify(payload), ... }); if (!upstream.ok) { const detail = await upstream.text(); logger.warn('upstream rejected', { status: upstream.status, detail, sentPath: routePath, sentMessageCount: payload.messages.length, roles: payload.messages.map(m => m.role), }); } return upstream; } Logging the roles array is what actually cracked it. Once I saw ["user","assistant","assistant","user"] with two adjacent assistant entries, the merge bug was obvious in five seconds, after an hour of staring at the model name.
The Checklist I Now Use
Wire a new OpenAI-compatible upstream:
- [ ] Strip or normalize query strings before any path-based routing
- [ ] Merge consecutive same-role messages into one before sending upstream
- [ ] Assistant text and
tool_callsgo in the same message, not two - [ ] Every
tool_resultbecomes arole: "tool"message with matchingtool_call_id - [ ] Log the full request shape (roles array, message count) on any non-2xx
- [ ] Verify against a known-good
curlpayload, not just "the docs"
The deeper point: compatibility layers fail at the seams, not in the middle. A gateway that "speaks OpenAI" accepts the happy path your curl test used, and falls over on the message shapes your agent actually produces — because agents build messages incrementally, across turns, with tool calls and results interleaved, and no human hand-writes those.
If you're building an agent on top of an OpenAI-compatible endpoint, test with a real multi-turn tool-use conversation in your very first integration run. The one-shot curl test will pass and tell you nothing.
The gateway lives in @blm/server in github.com/lujianfu2008/xiaoqingting. Previous post: teaching the assistant new skills at runtime.