Pi Coding Agent Custom Provider: Setup, Cost, and 3 Fixes

Pi reports $0 for every session on a custom provider, and reasoning models 500 on Anthropic routes. Both are config, not bugs. Tested on pi 0.84.1.

Pi Coding Agent Custom Provider: Setup, Cost, and 3 Fixes

Pi ships with 36 API-key providers and six subscription logins already wired up, and there is still a decent chance yours is not among them. Pointing it somewhere else takes one JSON file and about five minutes, which is the easy part. The interesting part is the three things that go wrong afterwards, none of which announce themselves as configuration problems.

Everything below was run on 2026-08-18 against pi 0.84.1 on macOS, with a gateway that serves 131 models over an OpenAI-compatible endpoint.

Config file:        ~/.pi/agent/models.json
Provider fields:    baseUrl, api, apiKey, models[]
Protocols:          openai-completions, openai-responses,
                    anthropic-messages, google-generative-ai
Key from env:       "$OFOX_API_KEY"
Key from keychain:  "!security find-generic-password -ws ofox"
Unlisted model:     runs, with a warning, at 128K context
Cost accounting:    $0.00 until you add a cost block
Reasoning + Claude: 500 until you add supportsDeveloperRole: false
Built-in override:  baseUrl only, stop before /v1
Reload:             automatic, every time /model opens

What Can You Do After This Setup, and What Can’t You?

You get every model your gateway sells, in Pi’s own loop, billed to one key. You do not get a model list fetched from the gateway, working cost numbers, or any warning when a model quietly runs at an eighth of its real context window.

Works right away:

  • Any endpoint that speaks OpenAI chat completions, OpenAI Responses, Anthropic Messages or Google Generative AI.
  • Model switching mid-session through /model, including across providers, because the file is re-read each time the picker opens.
  • Keys pulled from the environment or from a shell command, so nothing secret has to sit in the JSON.
  • Thinking levels, image input and tool calling, as long as you declare them per model.

Does not work right away:

  • No catalogue discovery. Pointed at a local server that logs every request, Pi made four POSTs to /v1/chat/completions per run and not a single GET to /v1/models. What you type is what it knows.
  • No cost numbers. Token counts are exact, money is zero, until you write the rates yourself.
  • No protocol sniffing. Override a built-in provider with the wrong base path and the error you get back describes auth, not protocol.

Should You Point Pi at a Gateway or Just Use Its Built-In Providers?

Use the built-ins if you already pay Anthropic, OpenAI or Google directly. Add a custom provider when the model you want is not in that set, or when one key across every tool matters more than per-vendor dashboards.

When a custom provider earns its keep:

  • You run models that no first-party provider carries, which in practice means most Chinese open-weight flagships and anything hosted rather than official.
  • You already route Claude Code or Codex CLI through a gateway and want one key and one bill rather than four.
  • You want to A/B a cheap default against an expensive escalation model without opening a second account for the second model.

When it is not worth the file:

  • You use one vendor and one plan. /login covers six subscriptions directly, including ChatGPT Plus and Pro, Claude Pro and Max, GitHub Copilot, xAI and OpenRouter, without any of this.
  • You are on a local runtime. Ollama, vLLM and llama.cpp are the documented cases and need baseUrl plus a model id, nothing else in this post.
  • You only wanted to change the key on a built-in provider. That is a one-line override, covered near the end.

Stop rule: if pi --list-models already shows the model you intend to run, close this tab. Everything here exists to add models Pi does not know about.

What Do You Need Before You Start?

Node 22 or newer, a key, and a base URL you have already curled once.

RequirementWhat we usedNotes
Node.js24.14.1Package declares engines: node >=22.19.0
Pi0.84.1 (latest 0.84.2)@earendil-works/pi-coding-agent, MIT
Endpointhttps://api.ofox.ai/v1Must answer /chat/completions, not just /models
Keyone gateway keyHeld in $OFOX_API_KEY, never inline
Model idsexact stringsGateway ids, not vendor ids

Install, if you have not:

npm install -g @earendil-works/pi-coding-agent
pi --version

One thing worth deciding before you write the file: the provider name you pick becomes part of every --provider flag and every session record. Rename it later and old sessions point at a provider that no longer exists.

How Do You Add a Custom Provider to Pi?

Four fields in one file, then one command to prove it works.

Step 1: Write the provider block

~/.pi/agent/models.json holds everything. The minimum viable entry:

{
  "providers": {
    "ofox": {
      "baseUrl": "https://api.ofox.ai/v1",
      "api": "openai-completions",
      "apiKey": "$OFOX_API_KEY",
      "models": [
        { "id": "deepseek/deepseek-v4-flash", "contextWindow": 1000000, "maxTokens": 384000 }
      ]
    }
  }
}

openai-completions is the one to reach for first. It is the most widely implemented shape, and on our gateway openai-responses also worked for the same model, which is not something you can assume elsewhere.

Step 2: Put the key in the environment, not the file

export OFOX_API_KEY=sk-...

apiKey resolves three ways: a literal string, $VAR or ${VAR} interpolation, and !command, which runs a shell command and uses stdout. The third form is the one to use on a shared machine:

"apiKey": "!security find-generic-password -ws ofox"

Step 3: Confirm Pi sees the models

pi --list-models ofox
provider  model                       context  max-out  thinking  images
ofox      deepseek/deepseek-v4-flash  1M       384K     no        no
ofox      moonshotai/kimi-k3          1M       1M       no        no
ofox      z-ai/glm-5.2                1M       128K     yes       no

Those columns come from your file, not from the gateway. Delete contextWindow and maxTokens from an entry and the same command prints 128K and 16.4K for it, which are Pi’s documented defaults. If thinking says no on a model that reasons, that is your declaration missing, not the endpoint refusing.

Step 4: Run something that touches the disk

Print mode is the fastest proof, because it exercises the tool loop rather than just the completion endpoint:

pi --provider ofox --model deepseek/deepseek-v4-flash -p \
  "Read buggy.py, run it, and state the one-line bug. Do not edit files."

In a scratch directory holding a two-line file with return a - b in an add function, DeepSeek V4 Flash read the file, ran the interpreter through the bash tool, and answered correctly on the first try. That is the whole integration test: file read, shell execution, answer.

Does openai-responses Work Too?

On our gateway, yes, for the same model, with the protocol name as the only change. Swapping "api": "openai-completions" for "api": "openai-responses" and re-running the same prompt returned the same answer.

Do not generalise that. Responses support is decided per model by whoever hosts it, not per gateway, so an endpoint that answers /v1/responses for one model can have no Responses route at all for the next one. openai-completions is the shape with the widest coverage, and there is no advantage to picking anything else unless a specific model needs it. Codex CLI is the tool that forces the question, because it speaks Responses and nothing else.

Why Does pi auth check Say Ready With a Key That Does Not Work?

Because it checks that a key is present, not that it is valid. We pointed the provider at a deliberately invalid key and asked:

pi auth check --provider ofox
# ready

The same config, one request later:

401: {"message":"Invalid or expired API key","type":"invalid_api_key","code":401}

ready means Pi resolved something into the apiKey slot. Treat it as a spelling check on your environment variable and nothing more. The real readiness test is Step 4.

Why Does Pi Report $0 for Every Session?

Because a custom provider has no price list, and Pi will not invent one. Token accounting is exact. Here is the usage record Pi wrote for the two turns of that first run, straight from the session file under ~/.pi/agent/sessions/:

TurninputoutputcacheReadreasoningtotalcost
12,8401220122,962$0.00
252562,94403,052$0.00

Two things in that table are worth separating. The cacheRead figure is real: the gateway returned prompt_tokens_details.cached_tokens on the second call, and Pi recorded it. The cost column is not real, it is absent. Every field under cost sits at zero because the model entry never declared rates.

Add them and the arithmetic starts working:

{
  "id": "anthropic/claude-sonnet-5",
  "contextWindow": 1000000,
  "maxTokens": 128000,
  "cost": { "input": 2, "output": 10, "cacheRead": 0.2, "cacheWrite": 2.5 }
}

Rates are per million tokens, taken from the provider’s own pricing page rather than from the vendor’s. The next run against Claude Sonnet 5 recorded 4,111 input and 4 output tokens and priced them at $0.008222 plus $0.00004, total $0.008262, which is those counts multiplied by $2 and $10 per million. Pi does that arithmetic locally, so the number is only as honest as the rates you typed. Type the gateway’s rates, not the model maker’s, and re-check them when the page changes.

The same applies to contextWindow. Sonnet 5 is a 1M-context model on this gateway, and the entry has to say so, otherwise the 128K default quietly takes over.

Why Does a Model Fail With “unsupported message role: developer”?

Because reasoning: true makes Pi send the system prompt as a developer role message, and not every upstream accepts that role. The failure is loud and looks like a server fault:

500: {"code":null,"message":"Request error: failed to convert messages:
unsupported message role: developer","param":null,"type":"api_error"}

Nothing in that string points at your config, which is why it is worth isolating properly. Three runs, same gateway, same model, one field at a time:

Model entryResult
reasoning: true500, unsupported message role: developer
reasoning: true plus compat: { supportsDeveloperRole: false }Works
reasoning: falseWorks

So the trigger is the developer role, and there are two fixes with different costs. Running the same three configs against a local server that logs request bodies shows exactly what changes:

Model entrymessages[].rolereasoning_effort sent
reasoning: true["developer", "user"]"medium"
plus supportsDeveloperRole: false["system", "user"]"medium"
reasoning: false["system", "user"]absent

The compat switch moves the system prompt to a system message and leaves reasoning_effort in place, so thinking survives. Setting reasoning: false also clears the error, by dropping reasoning_effort from the request entirely, which is usually the wrong trade.

The same capture answers a question people ask about maxTokensField: on openai-completions Pi sends max_completion_tokens, not max_tokens. If your endpoint only understands the older field, that is the switch to flip.

The role is only rejected on part of the catalogue. Same gateway, same reasoning: true, three model families:

Modelreasoning: true result
DeepSeek V4 FlashWorks
GLM 5.2Works
Claude Sonnet 5500 until supportsDeveloperRole: false

The pattern is upstream shape, not gateway policy. Anthropic’s API has no developer role, so a gateway translating OpenAI-shaped requests into Messages has nothing to map it to. OpenAI-shaped upstreams take it and move on. This is the same class of problem as Codex CLI emitting an empty tool description that some upstreams validate and others ignore, which we hit while testing nine harnesses against one gateway. The lesson repeats: when a client and an endpoint disagree, read the body before you change settings.

Pi’s docs list two more switches in the same family, supportsReasoningEffort for servers that reject reasoning parameters and maxTokensField for servers that want max_completion_tokens instead of max_tokens. If a model 400s the moment thinking is enabled, those are the next two to try.

Do You Have to List Every Model?

No, and on a gateway with 131 of them you should not try. An id Pi has never seen still runs:

pi --provider ofox --model z-ai/glm-5.2 -p "say ok"
# Warning: Model "z-ai/glm-5.2" not found for provider "ofox". Using custom model id.
# ok

That fallback is the difference between a five-line config and a five-hundred-line one. It also hides a cost. An unlisted model inherits Pi’s defaults, documented as 128,000 context and 16,384 max output, and pi --list-models prints exactly those two figures for any entry that omits them. Auto-compaction then fires at contextTokens > contextWindow - reserveTokens, with reserveTokens defaulting to 16,384, so a 1M-context model summarises itself somewhere around 111,600 tokens instead of near a million. Nothing in the output says why. People do report compaction arriving sooner than expected in the project’s community; this is at least one mechanism that produces it, and it is cheap to rule out before blaming the model.

The practical split: let unlisted ids cover exploration, then write a real entry for the two or three models you run daily, with contextWindow, maxTokens, reasoning, input and cost filled in. Everything Pi displays about a model, including whether it will accept an image, comes from that entry rather than from the endpoint.

Can You Point Pi’s Built-In Anthropic Provider at a Gateway?

Yes, and it is the better route for Claude models, as long as you give it the Anthropic base path rather than the OpenAI one. The override is one line and no model list of your own:

{ "providers": { "anthropic": { "baseUrl": "https://api.ofox.ai/anthropic", "apiKey": "$OFOX_API_KEY" } } }
pi --provider anthropic --model claude-sonnet-5 -p "Reply with exactly: ok"
# ok

Pi keeps its entire built-in Claude catalogue, with the windows already correct, Claude Fable 5 at 1M and the Opus and Haiku entries at 200K. Nothing to declare, nothing to keep in sync, and no developer role in sight because Messages is the native shape here. The vendor id works as-is, no gateway prefix.

Getting the base URL wrong produces two errors that both describe the wrong problem. Point it at the OpenAI path:

401 {"error":{"message":"You didn't provide an API key. You need to provide your API key
in an Authorization header using Bearer auth ...","type":"invalid_request_error","code":401}}

There is no auth bug there. Pi is speaking Messages, so it sends x-api-key, and the OpenAI path only accepts Authorization: Bearer. Add a Bearer header through the provider’s headers field and the honest answer appears: 404 Unsupported OpenAI API endpoint. The 401 was a protocol mismatch wearing an auth costume.

The other way to get it wrong is doubling the version segment:

404 {"error":{"message":"Unsupported Anthropic API endpoint. ...","code":404}}

That is .../anthropic/v1 in the config. Pi appends /v1/messages itself, so the base URL stops at /anthropic.

baseUrlResult
https://api.ofox.ai/anthropicWorks, full built-in Claude catalogue
https://api.ofox.ai/anthropic/v1404, Unsupported Anthropic API endpoint
https://api.ofox.ai/v1401 that is really a 404 on the wrong protocol

So Claude has two routes through the same key: the built-in override above, or a custom openai-completions entry with the gateway’s own ids, which is what the cost example used. The override is less typing and avoids the developer role entirely. The custom entry is the one to use when you want per-model cost and contextWindow values Pi does not already know.

Should You Run Claude Models in Pi at All?

They work, and Pi’s own author has documented a schema problem on the newest ones. Writing on 2026-07-04, Armin Ronacher reported that “newer Claude models sometimes call Pi’s edit tool with extra, invented fields in the nested edits[] array”, with the result that “the model invents made-up keys and Pi thus rejects the tool call and asks to try again”. His summary of the trend is the uncomfortable part: “this is getting worse with newer Anthropic models as both Opus 4.8 and Sonnet 5 show it but none of the older models.”

That is a training-and-tooling mismatch, not something a base URL can fix, and it costs a retry each time it fires. It does not make Claude unusable in Pi. It does mean that if you are picking a default model for a harness whose edit tool is its own, the newest Claude is not automatically the safest choice, and it is worth watching your session log for repeated tool calls on the same edit.

How Do You See What Pi Actually Sends?

Reproduce the call with curl, then compare it to what Pi recorded. Two files and one command cover most of what you need, and neither requires a proxy.

The session log is the first stop. Every run writes a JSON Lines file under ~/.pi/agent/sessions/<project>/, one record per event, including a model_change line naming the provider and model id Pi resolved and an assistant message carrying the usage block. If the model id in that file is not the one you meant to run, the problem is your flag or your fallback, and no amount of provider tuning will fix it.

The endpoint is the second. Send the same shape yourself:

curl -s https://api.ofox.ai/v1/chat/completions \
  -H "Authorization: Bearer $OFOX_API_KEY" -H 'Content-Type: application/json' \
  -d '{"model":"deepseek/deepseek-v4-flash","messages":[{"role":"user","content":"hi"}],"max_tokens":8}' \
  | python3 -c 'import sys,json; print(json.load(sys.stdin)["usage"])'

Two things in usage are worth reading closely. prompt_tokens_details.cached_tokens is what Pi maps to its cacheRead column, so if that key never appears, the cache figures in your session log will stay at zero no matter how stable your prefix is. And completion_tokens_details.reasoning_tokens tells you whether thinking is actually running, which is a faster check than reading output and guessing.

Doing this before you edit config is the whole lesson from every harness integration we have written up. An error string is written by whoever raised it, and that is frequently not the component that is wrong.

What Breaks During Setup, and How Do You Fix It?

Six failures, five of them reproduced against a live endpoint on 0.84.1, and one demonstrated with Pi’s own model table.

SymptomCauseFix
401: {"message":"Invalid or expired API key"...}Key resolved but wrong, or the variable is empty in this shellecho $OFOX_API_KEY before blaming the file; pi auth check will not catch this
404 404 page not foundbaseUrl missing the version segmentUse https://host/v1, not https://host
500 ... unsupported message role: developerreasoning: true on a model whose upstream has no developer roleAdd compat: { supportsDeveloperRole: false }
401 ... provide your API key ... using Bearer auth on the anthropic providerBuilt-in override pointed at the OpenAI path, so Pi sends x-api-key where only Bearer is acceptedUse the gateway’s Anthropic base path, ending at /anthropic
404: {"message":"Model 'openai/gpt-5.6' not found","type":"model_not_found"}The id is well formed but not in this gateway’s catalogueRead the id off the gateway’s own model page; a vendor announcing a model does not put it in every catalogue
Session compacts far earlier than the model’s real windowUnlisted model fell back to the 128K defaultDeclare contextWindow and maxTokens for that model

The fourth and fifth rows are the ones that cost the most time, because both error messages describe something other than the actual problem.

How Do Teams Share a Pi Provider Config?

Share the file, never the key. models.json holds no secret when every apiKey is a $VAR or a !command, which makes it safe to commit into a dotfiles repo or a bootstrap script.

A split that survives contact with more than one developer:

  • Commit the provider block: base URL, protocol, and full model entries with contextWindow, maxTokens, reasoning and cost. These are facts about the endpoint, identical for everyone, and getting them wrong is what produces silent early compaction and fake $0 bills.
  • Never commit the key. "apiKey": "$OFOX_API_KEY" in the shared file, real value in each developer’s shell profile or keychain.
  • Pin the version you validated. Pi ships roughly weekly, 0.82.1 through 0.84.2 in under a month. Record the version your config was tested against.
  • Give everyone the same base URL. One endpoint means one model catalogue, one rate limit pool and one place to see spend, instead of a per-developer guess.

That last point is the one teams skip, and it is the one that turns “which model are you on?” from a question into a lookup.

How Do You Point Every Harness at the Same Key?

Every harness stores model access in its own dialect. Claude Code reads ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN. Codex CLI wants a model_providers block in config.toml and refuses anything that is not the Responses API. Cline has a settings pane. DeepSeek Harness wants a custom provider form or DEEPSEEK_BASE_URL. Pi wants the JSON file above. Five tools, five places to rotate a key, five model lists drifting apart.

They all speak HTTP against an OpenAI-compatible or Anthropic-compatible endpoint, so the fix is identical in each: one base URL, one key, and the model string as the only thing that changes. That is the entire reason the custom-provider form in every one of these tools has the same four fields.

On ofox that endpoint is https://api.ofox.ai/v1 with openai-completions, and one key reached 131 models on 2026-08-18, including Kimi K3 and MiniMax M3 alongside the DeepSeek, GLM and Claude entries used above. The equivalent setup for the other tools is in our Codex CLI custom provider guide, the OpenCode configuration walkthrough, and the Cursor, Claude Code and Cline setup.

How Does Pi Compare to the Harness You Already Run?

Same job, much smaller surface, and a config file that is honest about how little it assumes. Pi gives the model four tools and an extension API, where Claude Code gives it hooks, subagents, skills and MCP servers out of the box. Neither is better in the abstract. The question is whether you want assembly or assembly required.

What the custom-provider path shows is where that minimalism has a price. No catalogue fetch, no pricing table, no protocol sniffing. Every one of the three problems in this post is Pi declining to guess something on your behalf, and every fix is you writing the fact down once.

For where Pi sits against the rest of the field, including the OpenRouter usage data on which models people actually run inside each harness, see the nine-harness roundup. For the terminal agents specifically, Claude Code vs Codex CLI vs Cursor covers the trade in more depth.

References

Frequently Asked Questions

What is the Pi coding agent?
A terminal coding agent from Armin Ronacher and Mario Zechner, MIT licensed, now developed under Earendil at github.com/earendil-works/pi. It gives the model four built-in tools (read, write, edit, bash) and ships almost nothing else, exposing an extension API instead of hooks, subagents and skills. As of 2026-08-18 the repository has 92,619 stars and the npm package pulls 1.37 million downloads a week.
Which npm package installs Pi?
@earendil-works/pi-coding-agent, currently 0.84.2, engines node >=22.19.0. The older @mariozechner/pi package stops at 0.70.6 and gets a few hundred downloads a week; it is the pre-acquisition channel and installing it gets you a version from before the move to Earendil.
Does Pi support third-party API endpoints?
Yes, through ~/.pi/agent/models.json. A provider entry takes baseUrl, api, apiKey and a models array, where api is one of openai-completions, openai-responses, anthropic-messages or google-generative-ai. No code changes and no fork are required, and the file reloads every time the /model picker opens.
Can Pi read an API key from an environment variable?
Yes. apiKey accepts $VAR and ${VAR} interpolation, and it also accepts !command, which runs a shell command and takes stdout as the key. That second form is how you read from a system keychain instead of leaving a secret in a JSON file. Use $$ for a literal dollar sign.
Does Pi need every model listed in models.json?
No. Passing a model id that is not in the list prints Warning: Model not found for provider, then runs it as a custom model id. The catch is that an unlisted model inherits the defaults, 128,000 context and 16,384 max output, so a 1M-context model will compact far earlier than it needs to.
Why does Pi show $0 cost for every request?
Because a custom provider carries no pricing metadata. Token counts are recorded correctly in the session file, but every field under cost stays at zero until you add a cost block to the model entry with input, output, cacheRead and cacheWrite rates per million tokens.
Can Pi point its built-in Anthropic provider at a gateway?
Yes, if you give it the gateway's Anthropic Messages base path rather than its OpenAI one. Overriding baseUrl on the built-in anthropic provider keeps Pi's whole Claude catalogue with correct context windows and needs no models array. Stop the URL before the version segment, because Pi appends /v1/messages itself, and pointing it at the OpenAI path returns a 401 that is really a protocol mismatch.
What does unsupported message role: developer mean in Pi?
It means the model entry has reasoning set to true, so Pi sends the system prompt as a developer role message, and something upstream refuses that role. Add compat with supportsDeveloperRole set to false to keep thinking, or set reasoning to false to drop it. On an OpenAI-shaped gateway this shows up only on Anthropic-family models.