DEV Community

Cleo Cliona
Cleo Cliona

Posted on Originally published at cleosnine.com

Why free model tiers look dead when they aren't

Why free model tiers look dead when they aren't

I keep a small probe script that walks a list of OpenAI-compatible endpoints and asks each one the same two questions: what do you have, and will you answer a chat right now. For months I only looked at the first answer. That was the mistake.

On 09.10.2026 I finally logged both. Groq answered 200 on openai/gpt-oss-20b and qwen/qwen3.8-27b — catalog and chat both clean. Google Gemini returned 62 models from the catalog and then 429 on the chat call. Mistral returned 46 models and 429 on chat. NVIDIA NIM returned 80 models, and the IDs I tried came back 410 Gone. SambaNova returned 402 with balance_units: 0. Four failures that look identical in a dashboard and are four completely different things.

A 200 from the catalog only means your key parsed

The /v1/models endpoint is an authentication check with a list stapled to it. It confirms the header parsed, the key resolved to an account, and maybe that the account is allowed to see the catalog. It says nothing about whether the model behind an ID is currently served, whether the account has quota left, or whether a chat request would ever succeed.

curl -sS -o /dev/null -w '%{http_code}\n' \
  -H "Authorization: Bearer $KEY" \
  https://api.example.com/v1/models
Enter fullscreen mode Exit fullscreen mode

A 200 here is the sound of a door unlocking. I had wired that into a health check, so every provider in my rotation looked green while half of them refused to answer. The fix wasn't a better status code. It was treating the catalog call as a footnote in the log and the chat call as the actual test.

410 Gone is a model being honest

NVIDIA NIM gave me 80 entries and then 410 on the IDs I picked. My first reading was "this provider is dead," which was wrong — the provider was answering fine, it just wasn't serving those particular models anymore. Retired models linger in catalogs long after they stop being routed. Some catalogs are static documents nobody prunes, and the 410 on the chat call is the only place where the retirement is stated and dated.

I nearly dropped NIM from the rotation over that. What I actually needed was to stop guessing IDs from a list and let each provider's own error decide. Of the entries I tried, the ones that worked were the ones the catalog itself flagged as current, not the ones I had hardcoded months earlier. A 410 on an old ID tells you about that ID. It tells you nothing about the provider.

The ':free' suffix belongs to the router, not the model

This one cost me the most time. My filter was:

free = [m["id"] for m in models if m["id"].endswith(":free")]
Enter fullscreen mode Exit fullscreen mode

That convention comes from aggregator endpoints that route to many upstreams and need a way to mark the zero-price variant inside one flat namespace. First-party endpoints don't need it, because the free-ness lives on the account, not on the string. Groq serves its models under their plain IDs. Gemini's catalog lists models/gemini-… with a supported-method list and no suffix anywhere. So my filter, which was supposed to find free capacity, silently excluded the providers that were actually answering 200.

Account-based free tiers and model-named tiers are two different shapes, and a scanner that understands only one shape reports the other as absent. I had a whole class of working providers filed under "no free models" for weeks because I trusted a naming convention as if it were a taxonomy.

A 429 is a budget talking, not a tombstone

Gemini's 62 models and Mistral's 46 both ended the same way: catalog fine, chat 429. That is not a down provider. It is a quota being spent by everyone else hitting the same shared free pool at the same time. Free tiers are contended by definition — that is the deal you accepted in exchange for the price.

The useful thing about a 429 is that most implementations say why in the body, not in the status line: Retry-After, the rate-limit headers, sometimes a plan or bucket name. I had been throwing the body away and keeping only the code, which flattened "come back in a minute" and "you are on the wrong plan" into the same integer. Once I started logging bodies, the same 429s became readable.

A 402 with balance_units 0 is a ledger, not an outage

SambaNova's answer was the clearest of the four, and the one I misread longest. 402 is neither throttling nor retirement — it says the account is out of whatever unit it counts, and the body handed me balance_units: 0. That is a statement about one key, not about the service. Free credits lapse. Trial balances empty. Hold two keys to the same provider and one can be 402 while the other answers 200, which is the tell that provider-level health is the wrong granularity for your dashboard. Key-level is the right one.

So the probe now records three things per attempt — status, response body, timestamp — and stores them per key. The catalog line stays in the log as context, never as a verdict. Reread against the 09.10.2026 run, the same data that made me want to write off four providers says something narrower: one is healthy, two are merely busy, one wants a different model ID, and one needs a top-up. Only one of those is a reason to remove anything.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.