Providers and models

The catalog is data, not code

Vendor model ids change faster than this package ships, so compiling them into TypeScript guarantees they are wrong within weeks. The catalog is a JSON file: packages/engine/src/providers/catalog.json.

Layers, later overriding earlier:

0. pi-ai's built-in models      whatever it knows about the catalogued vendors
1. the bundled catalog.json     25 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

Overrides are field level, so pointing Kimi at its mainland endpoint does not mean restating everything else:

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

Provider facts are stable; model ids are not

Endpoint, environment variables and protocol are worth shipping. Model names are not — so the only entries this package states outright are the ones it could verify, and the rest come from discovery, which asks each vendor's own /models.

Between the two sits layer 0. pi-ai ships a generated catalog of the models it knows, and since the wire layer is pi-ai already, those entries are read at startup and seeded under everything else — which is why a provider you have never connected still has models to show. They carry context windows, pricing and reasoning tiers, but they are a snapshot of someone else's data and know nothing about what your account may call, so a stated entry and anything discovery returns both win over them. OHARNESS_NO_BUILTIN_MODELS=1 turns the seed off.

Two things bound it. 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 sent to an OpenAI-compatible endpoint in the wrong shape. And vendors whose ids differ across the two catalogues are reconciled by naming the other one, not 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 model picker, as a single row that opens the panel where you connect it.

The wire layer is pi-ai

A catalog entry's protocol names the format its endpoint speaks, and the implementation comes from [@earendil-works/pi-ai][pi-ai]:

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

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

What did not move is authentication. Credentials are resolved per request by the harness's own AuthManager — a subscription token valid at startup is not valid an hour later — so pi-ai's API implementations are called directly and handed the resolved key and headers, rather than going through its provider auth and credential store.

[pi-ai]: https://github.com/earendil-works/pi

Not included, and why

Azure OpenAIThe endpoint is per-deployment, so there is no fixed base URL to catalogue
Bedrock, VertexThe protocols work, but only with credentials already in the environment. Full SigV4 signing and gcloud ADC live in pi-ai's provider layer, which this harness deliberately does not use for auth — so no entries ship; declare one yourself if your environment is set up
A custom OpenAI-compatible serverAlready a first-class config feature: openAiCompatible

The official plan

official is the entry the pickers recommend and the one to point anyone at. It signs in with an OHarness account and asks for nothing else.

oharness auth login official    # "Sign in to your OHarness account"

Two credentials reach it, and they are the same account either way: the session from signing in, and an oh- key the account holder created on the site. The key is not a second wallet, only a second key to the same one — CI and SDKs need something that does not expire, and OHARNESS_API_KEY is how that key is carried into a process rather than a separate thing issued to a machine.

What no subscriber ever holds is an upstream credential. The OpenAI, Anthropic and Google keys stay server-side, and requests go to a metering endpoint that reads the account, checks the balance, writes the ledger entry and only then forwards. Which gateway sits behind that stays an implementation detail: replacing it is a change to one constant, not to anyone's setup.

VariableEffect
OHARNESS_API_KEYUse a key made on the site instead of signing in — how CI runs
OHARNESS_BASE_URLMove the metering endpoint (staging, self-hosted)
OHARNESS_SUPABASE_URL, OHARNESS_SUPABASE_ANON_KEYPoint at another account project; the defaults are the OHarness site's, and the anon key is a publishable browser credential

Credentials resolve in the order OHARNESS_API_KEY, session, stored key. GoTrue rotates its refresh token on every use, so each refresh is persisted — keeping the original would end the session at the next expiry.

Signing in

The flow is an emailed one-time code rather than a redirect: a terminal cannot receive a redirect over SSH or inside a container, and a magic link opens a page with no way to hand the session back. A code works even when the browser is on a different machine.

Sign-in requests create_user: false. Paying is the website's job, and quietly creating an account here would hand out a login that cannot call anything — an address with no account is told so instead of waiting for mail that never comes.

Both the web frontend and the desktop app run a local server, so both offer the shorter route: Sign in with the OHarness website, in the Models panel. It opens the site's own login in the browser — the page that has a password, Google, and the way to create an account — and the session comes back to a loopback callback the app is listening on.

Nothing is pasted either way. The account token is the credential for official, which is why the API key field on that card is folded away behind the sign-in and only there for OHARNESS_API_KEY in CI.

The site seals the session into a short-lived code and the app trades it back over HTTPS, so no token travels in a URL. A deploy of the site running more than one instance has to set OHARNESS_HANDOFF_SECRET, or the instance that seals a code will not be the one asked to open it. OHARNESS_SITE_URL moves the app to a different site — a staging deploy, or a local next dev.

The metering endpoint

What official points at lives in this repository, as packages/gateway. It does three things and refuses to grow a fourth: it attributes a request to an account, refuses it when the balance is gone, and — once the answer is in — writes what it cost to the ledger. In between it forwards, unchanged, with a master key the caller never sees.

Prices come from the upstream catalog's pricing field, so a model added upstream is priced the day it appears. What is ours is the pair of numbers that turn a dollar into a credit: OHARNESS_CREDIT_USD and OHARNESS_MARKUP. A model with no published price is not charged for, and says so in the log — inventing a price is the one failure that cannot be noticed later.

Streaming needs care that non-streaming does not. Usage arrives in the final SSE chunk, and only when the request asked for it, so a stream that did not ask has stream_options.include_usage set on the way out and the resulting chunk withheld on the way back. A client that never asked for usage should not start receiving it.

Subscriptions and top-ups

They are the same thing by the time anything reads them: both end as credits in the ledger. A top-up writes one purchase row; a subscription writes a grant row on every invoice it pays. So "can this account afford the call?" stays a sum, and no part of the service has to know which of the two paid.

What the subscription row is for is not permission but advice. An account out of credits is told to top up if it has a plan and to subscribe if it does not, and those are the opposite instruction to give the wrong person.

What is for sale lives in OHARNESS_PLANS, where each plan ties a Stripe price to the credits it grants. The credits are stated rather than derived from the amount: a plan is a promise that this much money buys this much, and inferring it from Stripe would silently reprice everything the day a currency or a discount changed.

Five webhook events are handled — a completed payment checkout, the customer.subscription.* mirror, invoice.paid, and the two ways money goes back out, charge.refunded and charge.dispute.created. Every write is keyed on a Stripe id, because a webhook that is not retried loses money and one that is retried without a key grants it twice. Grants follow invoices rather than renewal dates: an invoice is the event that means money arrived, and a renewal that fails to charge should grant nothing.

A reversal takes back exactly what the ledger says the payment granted, read back rather than recomputed from a plan that may have been repriced since. If the credits were already spent the balance goes negative and the account settles before it calls anything again — the alternative is that refunding a top-up is a way to buy free inference. Partial refunds are logged for a human: deciding how many of a shared pool of credits a fraction of a payment bought is a policy question, not a mechanical one.

An invoice that settled at zero grants nothing. Stripe raises one for a free trial and for a fully discounted month, and an invoice is only handled here because it means money arrived — so a Stripe-native trial starts empty. That fails visibly, to whoever configured the trial, rather than invisibly to whoever reads the bill; sell a trial as its own cheap plan instead.

The account page renders the ledger, not just the balance. The rows carry the model and the token counts, which is what lets someone tell a runaway loop from a price change — and a balance nobody can check is a balance nobody trusts.

Cancelling, changing a card and reading past invoices are Stripe's own billing portal, reached through POST /billing/portal. Those screens are where being wrong means charging someone who cancelled, and Stripe's are already correct in every currency it sells in.

The site holds no credential that can move money. Checkout goes site → gateway, carrying the reader's own session token, and the gateway holds the Stripe key, the Supabase service role and the upstream key together.

npm run smoke:gateway stands Stripe and the account project up as fakes and checks the parts that touch money: the charge against the price list, the withheld usage chunk, the refusal of an empty account, that the caller's credential never reaches upstream, that a replayed webhook lands once, and that an invoice grants exactly one month.

Bringing your own OttoPort account

ottoport is an ordinary vendor entry, no different in kind from OpenAI or OpenRouter: you hold an OttoPort account, create an op- key in its console, and your usage is billed against your own credit balance there.

oharness auth login ottoport    # paste an op- key
VariableEffect
OTTOPORT_API_KEYUse a key without the prompt
OTTOPORT_BASE_URLMove the gateway (staging, self-hosted)

It is not the official plan. That plan happens to route through this gateway today, but the subscriber never holds an OttoPort key and never sees an OttoPort account — which is what leaves the plan's billing, quota and choice of upstream ours to change.

Credentials

Resolved on every request, not once at construction — a subscription token expires, and an implementation that computes headers once breaks an hour in. Concurrent refreshes are de-duplicated, because providers that rotate the refresh token on use would otherwise invalidate each other.