# No ANTHROPIC_API_KEY set: two code paths disagreed on null

> A gateway deleted a valid Anthropic key because its cleanup branch read only the DB provider row while its detector also read env. How to test for split readers.

- Author: Chad Priest
- Published: 2026-10-04
- Canonical URL: https://blog.vodou.ai/no-anthropic-api-key-set-two-code-paths-disagreed-on-null/
- Tags: node, llm, debugging, configuration

---

A valid Anthropic key sat in my `.env`. My gateway deleted it at startup, then threw `No ANTHROPIC_API_KEY set` on the first chat. This is a whole class of bug. Two pieces of code answer the same config question, "which provider is active?", but they read different sources. One of them is destructive, and it treats "unset" as "some other provider".

For three days I assumed the key was never loaded. It was loaded. Then it was removed.

## The cleanup runs before the detector and reads one of its three sources

The detector checks three things in order: a settings row `llm_provider`, then the `LLM_PROVIDER` env var, then whether `ANTHROPIC_API_KEY` exists. It treats a null row, an empty string and the literal `'none'` as "not set". `'none'` is our sentinel for "no provider chosen". It is not the name of a provider, and the rest of this post depends on that. The cleanup code runs first and checks only the row:

```ts
const dbProvider = getSetting('llm_provider');
if (dbProvider === 'anthropic') {
  const dbAnthropicKey = getSetting('anthropic_api_key');
  if (dbAnthropicKey) process.env.ANTHROPIC_API_KEY = dbAnthropicKey;
} else {
  delete process.env.ANTHROPIC_API_KEY;
}
```

The cleanup had a reason to exist. We spawn the Claude CLI as a child process, and we didn't want that child to inherit an API key it wasn't supposed to use. On a headless install, though, the row is null. The `else` reads null as "not Anthropic", so the delete runs. Then `LLM_PROVIDER=anthropic` sends the detector to the SDK path, and that path finds nothing. The detector's third rule, "a key in the environment means Anthropic", can never fire, because the code that runs before it has already removed the key.

It stayed hidden for months because something further down kept putting the key back. Another process re-injected it from `.env` after the gateway deleted it. Every install with that process worked, and every install without it broke. That's the general lesson, and it's worse than the bug itself: **a compensating write downstream hides a destructive read upstream.** When a later step repairs the damage, nobody goes looking for the step that did it. If you find code that "restores" a value, ask what removed it.

## Only a positive answer may delete the key, and `'unresolved'` deletes nothing

The broken code asks a yes/no question ("is the row `anthropic`?") when the honest answer has three values. Here's the fix. Both the cleanup and the detector call it, and only a positive answer is allowed to delete anything:

```ts
function resolveProvider() {                  // 'anthropic' | 'other' | 'unresolved'
  const n = named();                          // row, then LLM_PROVIDER; empty/'none' skipped
  if (n) return n === 'anthropic' ? 'anthropic' : 'other';
  return process.env.ANTHROPIC_API_KEY ? 'anthropic' : 'unresolved';
}
function loadConfig() {
  const p = resolveProvider();
  if (p === 'other') delete process.env.ANTHROPIC_API_KEY;  // a positive answer, nothing less
  const k = getSetting('anthropic_api_key');
  if (p === 'anthropic' && k) process.env.ANTHROPIC_API_KEY = k;
}
function detectProvider() {
  const p = resolveProvider();
  return p === 'other' ? named() : p === 'unresolved' ? 'none' : 'anthropic';
}
```

`'unresolved'` is the state the old code didn't have. With the fix, "I don't know" leaves the key alone.

## Precedence rules only cover code that uses them

The usual advice, which [Beluga AI's config guide](https://beluga-ai.org/docs/guides/foundations/environment-secrets/) states clearly, is to define one hierarchy where env vars win over files and files win over defaults. That advice is right, and our detector followed it. The cleanup didn't. It skipped the hierarchy, read one layer, and acted on what it found. A precedence rule only covers code that goes through it, and destructive branches tend to be written as quick guards that don't.

**Any branch that removes a credential must decide "which provider" with the same resolver the consumer uses, and an unresolved provider must never count as a different provider.**

## Find the key deleted by name, then child envs built without it

Start with literal deletes:

```sh
grep -rnE "delete process\.env|os\.environ\.pop|del os\.environ|env_remove|unset [A-Z_]*KEY" src/
```

That only finds code that deletes the key by name. The more common scrub in hosts that spawn child processes builds a new environment for the child and leaves the key out. The second pattern finds the places where a child's environment gets built:

```sh
grep -rnE "\.\.\.process\.env|env:[[:space:]]*\{|env=|environ\.copy\(|env_clear|env -u|os\.unsetenv|process\.env\.[A-Z_]+[[:space:]]*=[[:space:]]*undefined" src/
```

I checked it against a file with one example of each form: an object spread with the key set to `undefined`, a `spawn` `env:` allowlist, `subprocess.run(env={...})`, `os.unsetenv`, `os.environ.copy()`, Rust's `env_clear()`, `env -u` and a direct `= undefined`. It matched all of them. What it can't tell you is whether the key is missing from an allowlist, because an allowlist drops a key by not naming it, so searching for the key's name finds nothing. You'll have to read every hit. For each one, write down which setting decides whether the key goes to the child. If that setting isn't read through the same function your client constructor uses, you have this bug.

To prove it, here's a self-contained harness. It's plain Node with no dependencies, and a stub object stands in for the settings store. Save it as `repro.mjs` and run `node repro.mjs`:

```js
let settings = {};                                   // stub for the DB row
const getSetting = (k) => settings[k];
const empty = (v) => v == null || v === '' || v === 'none';
const named = () => !empty(getSetting('llm_provider')) ? getSetting('llm_provider')
  : !empty(process.env.LLM_PROVIDER) ? process.env.LLM_PROVIDER : null;

// BEFORE: the cleanup reads one source, the detector reads three.
function loadConfig() {
  if (getSetting('llm_provider') === 'anthropic') {
    const k = getSetting('anthropic_api_key');
    if (k) process.env.ANTHROPIC_API_KEY = k;
  } else delete process.env.ANTHROPIC_API_KEY;
}
function detectProvider() {
  return named() ?? (process.env.ANTHROPIC_API_KEY ? 'anthropic' : 'none');
}

for (const row of [undefined, null, '', 'none'])
  for (const env of [undefined, 'anthropic']) {
    settings = { llm_provider: row };
    if (env) process.env.LLM_PROVIDER = env; else delete process.env.LLM_PROVIDER;
    process.env.ANTHROPIC_API_KEY = 'sk-test-placeholder';   // key present before load
    loadConfig();
    const p = detectProvider(), r = JSON.stringify(row);
    if (p === 'anthropic' && !process.env.ANTHROPIC_API_KEY)
      console.log(`FAIL key-gone  row=${r} env=${env} -> ${p}`);
    if (!env && p !== 'anthropic')            // key was there, nothing else configured
      console.log(`FAIL fell-thru row=${r} env=${env} -> ${p}`);
  }
```

The two assertions catch the two halves of the bug. `key-gone` covers a detector that picks Anthropic after the key has been deleted, which is the crash I hit. `fell-thru` covers the quiet case: a key was present, no other provider was configured, the key got deleted anyway, and the detector settled on something other than Anthropic. Here it settles on `'none'`. A detector with more fallbacks would settle on whichever provider comes next, and you'd get no error at all. This is the output from the version above:

```
FAIL fell-thru row=undefined env=undefined -> none
FAIL key-gone  row=undefined env=anthropic -> anthropic
FAIL fell-thru row=null env=undefined -> none
FAIL key-gone  row=null env=anthropic -> anthropic
FAIL fell-thru row="" env=undefined -> none
FAIL key-gone  row="" env=anthropic -> anthropic
FAIL fell-thru row="none" env=undefined -> none
FAIL key-gone  row="none" env=anthropic -> anthropic
```

All eight cells fail, one way or the other. Replace the `BEFORE` block with the three functions from the fix and it prints nothing.

To use the harness on your own code, keep the loop and the two assertions. Replace `settings`/`getSetting` with your settings store, and replace `loadConfig` and `detectProvider` with your startup cleanup and your provider detector, called in the order your process calls them. Each failing line is an install that works on your machine and breaks on the first fresh server.

---

Source: [No ANTHROPIC_API_KEY set: two code paths disagreed on null](https://blog.vodou.ai/no-anthropic-api-key-set-two-code-paths-disagreed-on-null/) by Chad Priest, from Building Vodou in Public.
