Model ids do not belong in TypeScript

· 5 min read

A model id is the fastest-moving fact in this entire system. Vendors rename them, retire them, ship a dated variant, quietly change what the undated alias points at, and gate half of them behind which account is asking. Compile that into a package and you have guaranteed the package is wrong within weeks — not broken in an obvious way, which would at least be a bug report, but wrong in the specific way where a picker offers someone a model their key cannot call.

So the catalog is a JSON file, and the interesting part is not that it is JSON. It is what the layering underneath it admits about certainty.

Five layers, later beating earlier

0. pi-ai's built-in models      whatever it knows about the catalogued vendors
1. the bundled catalog.json     27 providers, 6 verified models
2. ~/.oharness/catalog.json     your edits, and what discovery found
3. a catalog path in config
4. providers / models in config
5. /models refresh              asks the vendor directly

Read that column of numbers as a confidence ranking rather than a load order. Layer 0 is someone else's snapshot of the world. Layer 1 is what we were willing to state outright. Layer 5 is the vendor answering about your account, right now — and it wins, because nothing we shipped three months ago knows more about your entitlements than the endpoint you are about to call.

The bundled layer carries twenty-seven providers and six verified models, and that ratio is the whole design. Provider facts — the endpoint, the environment variable, the wire protocol — are stable enough to ship. Model ids are not, so the only ones stated outright are the ones that could actually be verified, and everything else arrives from discovery asking each vendor's own /models.

Six is an uncomfortable number to publish. It is also the honest one.

Overrides are field level

The reason this matters in daily use is smaller and more boring: pointing Kimi at its mainland endpoint should not mean restating everything else about Kimi.

{ "providers": { "moonshot": { "baseUrl": "https://api.moonshot.cn/v1" } } }

That is the entire edit. Whole-entry overrides are how configuration formats rot — one person copies the shipped block to change a URL, and two releases later their copy is the only thing standing between them and a fix everyone else already got.

Where the seed stops

Layer 0 deserves a note, because it is the one that surprises people: a provider you have never connected still has models to show. Those come from pi-ai, the wire layer, which ships a generated catalog of what it knows. They carry context windows, pricing and reasoning tiers, and they know nothing whatsoever about what your account may call — so a stated entry and anything discovery returns both beat them, and OHARNESS_NO_BUILTIN_MODELS=1 turns the seed off entirely.

Two bounds keep that seed from doing damage.

A model is only seeded onto an entry whose protocol matches the API pi-ai catalogues it against. Mistral's models are listed against pi-ai's own conversations API, so they are left to discovery rather than posted to an OpenAI-compatible endpoint in a shape it does not speak. Sending the wrong shape is a 400 you get to debug, not a degraded answer you can live with.

And where the two catalogues disagree about a vendor's id, they are reconciled by naming the other one rather than by guessing:

{ "providers": { "moonshot": { "piProvider": "moonshotai" } } }

A provider with no seed and no discovery yet — a local server, a gateway nobody bundles names for — still appears in the picker as a single row that opens the panel where you connect it. An empty state that explains itself beats a provider that is simply absent until some invisible condition is met.

The protocol is the only thing worth naming

A catalog entry's protocol names the format its endpoint speaks:

anthropic-messages   openai-chat      openai-responses
gemini               bedrock          vertex
openai-codex

openai-codex is the one that justifies keeping this list explicit rather than inferring it from a vendor name. It is the backend the Codex CLI talks to, and it is a genuinely different wire format from openai-responses rather than the same one on another host: store is refused, max_output_tokens is not sent, and the account id travels in its own header. A system that guessed "it is OpenAI, use the OpenAI shape" would be wrong about it in three separate ways.

The SDKs behind these are imported lazily, so a session that only ever talks to Anthropic never loads the AWS or Google clients.

What did not move

Authentication. Credentials are resolved per request by the harness's own AuthManager, because a subscription token that was valid at startup is not valid an hour later. So pi-ai's API implementations are called directly and handed an already-resolved key and headers, rather than going through its provider auth and credential store.

That is why some entries do not ship at all. Bedrock and Vertex work as protocols, but only with credentials already in the environment — full SigV4 signing and gcloud ADC live in exactly the provider layer this harness declines to use for auth. Azure OpenAI has no fixed base URL to catalogue, because the endpoint is per-deployment. Both are declarable by anyone whose environment is already set up; neither is going to be shipped as a working default that quietly is not one.

The pattern under all of it: state the facts that hold still, ask about the ones that do not, and keep the difference visible in the file rather than buried in a release note.


OHarness is an open source agent harness, MIT licensed. The providers reference documents every layer, override and protocol above, and the provider hub lists what ships in the bundled catalog today.