Skip to main content

pi Said "xhigh". The Server Said 400.

Back to Brain Dumps
pi Said "xhigh". The Server Said 400.
September 5, 2026
8 min read
AI coding agentspiself-hosted LLMOpenAI-compatible APIopen sourcebuilding in public

pi is the terminal coding harness I've been living in lately, and the thing I like most about it is how little it assumes. It ships presets for the big hosted providers, they're correct, and then it gets out of your way.

Which is lovely right up until your models don't live at one of those providers. Mine don't, not all of them. Some sit behind a proxy. Some sit on hardware I pay for by the month and would like to actually use.

And for those, the stock answer is opening ~/.pi/agent/models.json in an editor and typing JSON at it. Then typing at it again next week, when the endpoint's model list has changed and you're the last to know.

So I built the thing that stops me doing that. It's called bpx-endpoints, it's one command, /endpoints, and this post is mostly about the bug that turned it from a config convenience into something I'd argue you actually need.

It worked. Right up until it didn't.

The first version did what I wanted. Point it at a base URL, it fetches the model list, pulls parameter metadata, generates the config, registers the models into the running session. Nice. Done.

Ship it.

And then requests started coming back 400.

Not all of them. Not immediately. Not in a pattern that made any sense on the first pass. The same model, on the same endpoint, with the same key.

Fine one minute, refusing me the next. Okay. Cool. Love that for me.

The variable I eventually noticed wasn't the model or the endpoint or the key. It was how hard I'd asked it to think.

The trapdoor is one ??

pi has thinking levels. Seven of them: off, minimal, low, medium, high, xhigh, max. Those are pi's own vocabulary. The wire field they end up in is reasoning_effort, which is the provider's vocabulary, and each model config carries a thinkingLevelMap to translate between the two.

Here's how pi resolves it:

reasoning_effort = model.thinkingLevelMap[level] ?? level

Read that ?? again, because that's the whole bug. If the map has no entry for the level you picked (null, missing, doesn't matter) pi falls through to the fallback. And the fallback is pi's own level name. It sends the string "xhigh" to the server as if that were a real wire value.

And here's what makes it genuinely nasty rather than merely annoying: sometimes the leaked name works. I went and counted across my own model store. max is a real wire value on 39 of my models. xhigh is real on the newer OpenAI ones. So the fallback quietly succeeds often enough that you'd never suspect it.

Then there's off, which is never once the literal string "off". It's either omitted entirely or sent as "none". And my favourite, minimal: sometimes dropped, sometimes translated down to "low", and on two of my models sent as "MINIMAL". Capital letters.

Because of course.

So there is no list to memorise. The legal set depends on the server, the model, and apparently the mood of whoever wrote the schema, and it has been moving for two years. (OpenAI's reasoning guide has today's version, which is worth re-reading occasionally, because it won't be tomorrow's.) The only slice safe everywhere is low, medium, high. Everything outside it is a coin flip you didn't know you were making.

So: my map came from a metadata source, the metadata source had gaps, and every gap was a landmine that only detonated when I turned thinking past the levels the map happened to cover. Low usage, everything fine. Hard problem, crank it to xhigh, 400. The failure mode was inversely correlated with how much I needed it to work.

And OpenAI is the generous end of this. Self-hosted wrappers are stricter, not looser. The sglang wrapper I hit while building this rejects high too, accepting low and medium and nothing else. So even a map that's perfectly valid against OpenAI today will 400 against that server, which is why "just copy the correct map" was never going to be the fix.

There isn't a correct map. There's only a correct map for this endpoint, this week.

What pi sends as reasoning_effort, and which values are actually legalWith no thinkingLevelMap, pi leaks its own level names. Off is never sent as the literal "off" (it is null or "none"), while max is a real wire value on many newer models. Minimal is accepted only by the GPT-5 family and xhigh only from gpt-5.1-codex-max onward, so both fail on older models and on most self-hosted servers. Low, medium and high are the safe intersection. A probed endpoint that accepts only low and medium maps every higher level down to medium.pi thinking levelno map: pi leaks the levelsafe mapprobed: low/medium onlyoff"off"null or "none"lowlowminimal"minimal""low" or "MINIMAL"lowlowlow"low"safelowlowmedium"medium"safemediummediumhigh"high"safe*highmediumxhigh"xhigh"newer OpenAI onlyhighmediummax"max"real on 39 of minehighmedium
Only low, medium and high are safe everywhere. The rest are legal on some models and rejected by others. With a gap in the map, pi sends all of them anyway. *Even high is rejected by some self-hosted wrappers.

The fix: stop trusting the map

Once I understood it, the rule wrote itself. Never copy a metadata source's thinkingLevelMap verbatim. Always emit a complete one. All seven levels, no nulls, no gaps, nothing for that ?? to fall through into.

When the endpoint is unknown, that map is the safe intersection: everything collapses onto low, medium, or high, so pi structurally cannot leak a raw level. It's lossy, since xhigh and max both land on high, and on a model that genuinely supports xhigh you're leaving a bit of thinking on the table. Lossy and working beats accurate and rejected.

Which is fine as a floor, but it's still guessing. And I'd rather ask.

Just ask the endpoint

So there's a probe. Turn it on and refresh will fire four tiny one-token completions at the endpoint, one per candidate effort value, and record which ones come back happy. Then the generated map points every pi level at the nearest value the endpoint actually accepts. Results cache per profile, so it's a one-time cost, and a manual list in your config beats the probe if you already know better.

The probe has one detail I enjoy more than I probably should. What do you do with a timeout?

Normally a timeout is a shrug. You learned nothing. But think about the timing here. Rejection is fast: the server validates the field and 400s before it generates a single token.

Generation is slow. So if a probe request times out, that's not silence. That's the request having sailed clean through validation and gone off to actually think about it.

Which means a timeout is evidence of acceptance, as long as something else in the same run proved the server validates eagerly. So the probe checks: did any other value come back fast, either accepted or rejected? If yes, validation is eager, and the timeouts get counted as accepted. If every single request timed out, we learned nothing and it says so.

Slow answer, therefore good answer.

And if the endpoint accepts nothing? Then its reasoning models get registered as non-reasoning, with a warning. Because a model that doesn't think is annoying; a model that 400s halfway through your session is a bad afternoon.

The part that isn't glamorous

Everything else in bpx-endpoints exists because I kept finding smaller versions of the same shape: a thing that looked fine and then quietly did the wrong thing later.

Discovery parses about five different shapes of model-list response, because "OpenAI-compatible" turns out to be a spectrum. It'll probe alternate paths when a URL 404s, but never when it 401s, because an auth failure is an answer, and hammering more URLs with a bad key is how you get rate-limited by your own tooling. Keys and base URLs can be $ENV references or !command calls, expanded at use time so nothing resolved ever touches disk. Doctor will tell you if you've set both an apiKey and a custom Authorization header, because pi appends the bearer token last and your custom header loses silently.

Fun one to debug at midnight.

My favourite fix, though, is the least impressive. If your config file gets malformed, the settings editor now refuses to open. It used to fall back to defaults, and the first save would then write those defaults over the top of your broken file, destroying the exact data the recovery tooling exists to recover from. A settings editor that eats your config while trying to help you is a special kind of unhelpful, and I'd built one without noticing.

And the rule under all of it: bpx-endpoints never writes to pi's own models.json. That file stays yours. Everything it manages lives in its own config beside it, and if you delete the extension tomorrow, pi's config is exactly where you left it.

Install it if you've got the same problem

pi install npm:@booplex/bpx-endpoints

Needs pi 0.80+ and Node 22.19+. MIT, source at gabelul/bpx-mono, more detail on the bpx-endpoints project page. It's the second extension in that repo. The first one, bpx-consult, came out of a similar afternoon of something failing at exactly the wrong moment.

There's a pattern forming there and I've decided not to look at it too closely. The rest of what I've built is on the projects page.

If you're only ever pointing pi at the big hosted providers, you don't need this. The presets already have you covered, and I'm not interested in maintaining a second copy of that list. But the moment your models live somewhere pi hasn't heard of, check your thinkingLevelMap before you check anything else. That ?? is patient.

Topics:AI coding agentspiself-hosted LLMOpenAI-compatible APIopen sourcebuilding in public

Found This Useful?

Share it with someone who might learn from my mistakes!