The Anthropic SDK silently rerouted my Claude calls to MiniMax
POST /api/book-generation/plan-sketch returned HTTP 500 with a body of a single string: "404 Page not found". No JSON wrapper. No Anthropic error shape. Just that bare line. The endpoint had been working for a week. I had changed nothing in the request shape, the model id, or the auth.
The provider in the failure log was "anthropic". The model was "claude-haiku-4-5-20251001". So Claude was 404’ing on a model it definitely knows about?
It wasn’t Claude. It was MiniMax pretending to be Claude.
The minimal repro
The bug lived in our LLM gateway’s Anthropic client factory. Two lines, one of them looked harmless:
// server/llm/registry.ts (before the fix)
case 'claude':
apiKey = process.env.ANTHROPIC_API_KEY;
baseURL = undefined; // real Claude uses the SDK default api.anthropic.com
break;
// ...
const client = createAnthropic({
apiKey,
...(baseURL ? { baseURL } : {}),
});
The comment on the second line is exactly the assumption that bit me. “Pass undefined and the SDK falls back to its default.” That’s true for the SDK’s intrinsic default. It is not true once an environment variable enters the chat.
What was actually going on
We run two Anthropic-shape providers from the same process. Real Claude (api.anthropic.com) for the strategic planning agent. MiniMax (api.minimax.io/anthropic, also Anthropic-compatible) for cheaper chapter drafting. To keep them hermetic, each has its own env file under server/llm/env/:
| File | Sets |
|---|---|
claude.env | ANTHROPIC_API_KEY=sk-ant-... + ANTHROPIC_MODEL=claude-opus-4-7 etc. |
minimax.env | ANTHROPIC_AUTH_TOKEN=sk-cp-... + ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic + ANTHROPIC_MODEL=MiniMax-M2.7 |
The catch-all server/.env (loaded first at boot) had inherited the legacy single-provider config and was also setting ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic. After envLoader merges the per-provider files on top of process.env, the picture looks like this:
| Variable | After server/.env | After claude.env | After minimax.env |
|---|---|---|---|
ANTHROPIC_API_KEY | (unset) | sk-ant-... | (unchanged) |
ANTHROPIC_AUTH_TOKEN | sk-cp-... | (unchanged) | sk-cp-... |
ANTHROPIC_BASE_URL | api.minimax.io/anthropic | (unchanged — claude.env doesn’t set it) | api.minimax.io/anthropic |
claude.env doesn’t set ANTHROPIC_BASE_URL, because the original author assumed real Claude needs no base URL override. So process.env.ANTHROPIC_BASE_URL keeps pointing at MiniMax.
Then we built the Anthropic client for a real-Claude model id and passed no baseURL option. That’s where the SDK does its quiet, helpful thing:
// node_modules/@ai-sdk/anthropic/dist/index.mjs (paraphrased)
const baseURL = withoutTrailingSlash(
options.baseURL ??
loadOptionalSetting({
settingValue: options.baseURL,
environmentVariableName: 'ANTHROPIC_BASE_URL',
})
);
options.baseURL is undefined. loadOptionalSetting falls through to process.env.ANTHROPIC_BASE_URL — which is still the MiniMax URL. The SDK happily constructs a client pointed at MiniMax with the real-Claude API key it can’t authenticate.
The MiniMax endpoint at /anthropic/v1/messages doesn’t host claude-haiku-4-5-20251001. It doesn’t return Anthropic’s { type: 'error', error: { type: 'not_found_error', message: '...' } } either, because it’s not really Anthropic. It returns a bare "404 Page not found" string.
The fix
Explicitly pin the real Anthropic URL whenever the claude env file is loaded. Don’t trust the SDK to read its own intrinsic default — read it ourselves and pass it:
case 'claude':
apiKey = process.env.ANTHROPIC_API_KEY;
- baseURL = undefined; // real Claude uses the SDK default
+ baseURL = 'https://api.anthropic.com/v1';
break;
One line. The 404s stopped.
There’s a more aggressive fix — delete process.env.ANTHROPIC_BASE_URL after envLoader runs so the SDK can’t leak it. I didn’t take it because deleting env vars at runtime has its own surprises (other code paths might read them later), and the explicit pin is local to the call site that has the bug. But it’s there if you want a single defensive layer.
What the Vercel AI SDK is doing — and what it’s not doing wrong
The SDK’s loadOptionalSetting pattern is documented and used by every provider:
const apiKey = loadApiKey({ ..., environmentVariableName: 'ANTHROPIC_API_KEY' });
const baseURL = loadOptionalSetting({ ..., environmentVariableName: 'ANTHROPIC_BASE_URL' });
It’s exactly the convenience you want when you’re shipping a CLI tool: drop a .env file, set ANTHROPIC_BASE_URL=..., the SDK picks it up. The bug isn’t in the SDK. The bug is that we have two providers sharing the same env-var namespace and one of them poisoned the well at boot.
The deeper anti-pattern: trusting “pass undefined and the library does the right thing” when the library’s “right thing” is to read environment state. The contract isn’t undefined → intrinsic default. The contract is undefined → look elsewhere — env, config file, whatever I find. If you’re running multi-tenant inside one process, “look elsewhere” is the surface where tenants leak into each other.
Detection fingerprint
If you ever see an HTTP 4xx whose body isn’t your provider’s documented error shape, suspect that you’re not actually talking to that provider. Three checks in order:
- Body format: real Anthropic returns JSON with
{ type: 'error', error: { type, message } }. OpenAI returns{ error: { message, type, code } }. A bare string body, or HTML, or any format you don’t recognize — you’re hitting the wrong host. - Effective base URL:
grep -rn '_BASE_URL' server/.env server/llm/env/*.env. If two different providers’ env files name the same<PROVIDER>_BASE_URLand only one of them sets it, the unset one inherits the wrong value at runtime. - SDK source for
loadOptionalSetting: every Vercel AI SDK provider has it. Grepnode_modules/@ai-sdk/<provider>/dist/index.mjsforloadOptionalSetting. Every env var listed there is a potential leak surface in a multi-tenant process.
The same trap exists for OPENAI_BASE_URL, DEEPSEEK_BASE_URL, OPENAI_ORGANIZATION, and any other “optional setting” the SDK exposes. If you’re running multiple flavors of one provider through the same SDK, pin every baseURL and every auth setting explicitly. undefined is not the same as the SDK’s intrinsic default — it’s the SDK’s invitation to look anywhere in process.env for an answer.