stayz3ro.dev

The API key my coding agent was actually sending

A terminal coding agent (Pi) was configured with several model providers. One of them returned 400 insufficient credits for every model in it. The provider’s own CLI worked on the same machine, and so did another editor integration. The failure was inside the agent, and only there.

That shape of error is easy to misread. “Insufficient credits” sounds like the plan, and the provider’s other clients being healthy sounds like the provider is fine. Both readings were wrong. The problem was a key, and the key the agent sent was not the key anything else was using.

The other providers in the same agent were unaffected. That narrowed the problem to one credential rather than the agent’s config or the network under it, which is the first useful thing the symptom said.

Comparing keys without printing them

The first check was to compare the credentials by fingerprint instead of by reading them. Hash the values, compare the hashes, and keep every key out of the terminal, the logs, and the transcript.

The agent’s config held a literal API key. The CLI was logged in with a different one. That was the whole bug. The agent’s key had no credit balance, and the CLI’s key was the one on the working plan. Every model failed because the provider rejected the credential before it looked at the model name at all.

The first test pointed at the wrong suspect

Before changing anything, I wanted to reproduce the failure against the provider directly, outside the agent. The first script came back with HTTP 403 and “error code: 1010”. That reads like a rejected key. It was not. Cloudflare was blocking the script’s default user agent, so the 403 arrived before the request reached the API.

Setting a normal User-Agent gave the real answer. The models list returned 200 for both keys. Chat completions returned 400 for the agent’s key and 200 for the CLI’s key. Same endpoint, same request body, same machine, and the only difference was the credential.

Two things are worth keeping from that round trip. A list endpoint answering 200 does not mean the key can spend money, because billing is checked on the completion call and not on the list call. And a 403 from in front of the API is not the same as a 403 from the API. The user agent has to be ruled out before the credential gets blamed.

One source of truth for the key

The agent’s config supports a leading !command for secrets. Instead of storing a copy of the key, the apiKey field now runs a command at request time that reads the CLI’s own auth file:

apiKey: "!jq -r .apiKey <cli auth file>"

There is one copy of the credential on the machine, the one the CLI already maintains, and the agent reads it. Nothing has to be rotated in two places, and nothing drifts the next time the plan or the key changes.

Running sessions kept the old value until they restarted, so the change looked like it had not worked until a new session proved it had.

The audit that came out of it

The same night, a script tested all 52 models enabled in the agent, one request each. 51 passed. One failed for a different reason: the provider had renamed deepseek-v4-flash to deepseek-v4.1-flash, so the configured id no longer resolved. That is a failure a session would hit with no warning and no obvious cause.

The script now lives in a private harness repo and runs against the whole enabled set, so a rename gets caught on schedule instead of in the middle of a session.

What I took from it

  • “Works in the CLI” does not mean “works in the tool.” Each client sends its own credential, headers, and user agent, so the same machine can fail in three different ways against the same provider.
  • Compare what each client actually sends. A config field that looks populated is not evidence that the value is the right one.
  • A models list can return 200 while completions return 400 for the same key. Reaching the API is not the same as being allowed to use it.
  • Read the whole error before blaming the credential. 403 error code: 1010 was a user-agent block in front of the API, and fixing the request found the real 400 underneath.

Closing note

The original error said credits. The actual problem was that the agent was holding a second, empty key, and the only way to see it was to compare what each client sent instead of what the account looked like from outside. Pointing the config at the CLI’s own auth file removes the copy that caused this, and the model audit is a small, scheduled check that keeps the rest of the list honest.