Home /
OpenRouter free models: stop before a paid fallback
Free-model request limits and a Node/fetch adapter that validates free-only routing, preserves provider policy and stops on errors or incomplete answers.
- Use openrouter/free alone or only current :free entries in a model list.
- Validate routing before the inference request and check errors inside HTTP 200 responses.
- Local fixture checks establish code behaviour; no live inference was run.
A request that starts on a free OpenRouter model can become billable if it falls back to a paid one. For a demo that must stay free, use openrouter/free on its own, or a fallback list containing only existing :free model entries. When neither can answer, let the demo stop.
OpenRouter documents that billing boundary directly. The example below puts it into a small server-side adapter: validate the model list before sending, keep the provider data-policy filter, and return an explicit failure instead of substituting a paid model. It also retains the model that actually answered.
The limits and catalogue were checked on 27 October 2026. The code was tested locally with synthetic responses and the real public catalogue. No live inference was run, so these checks establish the adapter's behaviour, not endpoint availability or answer quality.
What the free-model limit covers
The published free-model limits give a no-purchase account 20 requests per minute and 50 per day. These are request limits, not an allowance for each API key or each free model.
| Situation | What to expect |
|---|---|
| No credits purchased | Published baseline of 50 free-model requests/day, within the 20/minute cap. |
| At least 10 credits purchased all-time, per the published table | The published table gives a higher ceiling of 1,000/day; this involves a purchase and is outside this no-spend example. |
| A free endpoint is busy or filtered out | A request can fail before the daily allowance is exhausted. |
Check GET https://openrouter.ai/api/v1/key with your key on the server when you need the account's current state. Its free_model_daily_requests object reports the daily counter, limit and remaining requests where that policy applies. It does not report the per-minute limit. A 429 can come from OpenRouter or an upstream provider, so the status alone does not tell you how much of your daily allowance remains.
The Free Models Router chooses among eligible free models. That is convenient for a low-volume experiment where different models can be acceptable. For a comparison that needs a particular model, choose its actual :free entry from the current catalogue. Adding the suffix to an arbitrary model ID does not create a free version.
Keep the provider filter when requests fail
The example fixes provider.data_collection to deny. OpenRouter uses this provider routing setting to exclude providers it identifies as collecting data. require_parameters: true also excludes providers that do not support the requested parameters.
Those filters can leave no eligible route. The code keeps them in place and reports failure. Removing a data-policy restriction to make an error disappear changes where the prompt may go.
This is a routing control based on OpenRouter's provider information, which its docs describe as their best knowledge. It is not an independent privacy audit or a residency guarantee. Provider logging policies and OpenRouter's own logging and metadata are separate controls. Use public test text here.
A server-side example that refuses paid fallbacks
Use Node 22 or newer. Save this as free-demo.mjs. It accepts only models and dataCollection as configuration, so callers cannot add plugins, tools or extra request options through that object. The output budget is deliberately small: 256 tokens.
// Node 22+. Server-side only. No SDK retries, plugins, tools or paid fallback.
export function requestFor(config, prompt, catalogue) {
if (!config || Array.isArray(config) || typeof config !== 'object' ||
Object.keys(config).some(k => !['models', 'dataCollection'].includes(k))) {
throw new Error('Only models and dataCollection are allowed');
}
const { models, dataCollection } = config;
if (!Array.isArray(models) || !models.length ||
models.some(m => typeof m !== 'string') || dataCollection !== 'deny') {
throw new Error('Use a nonempty model list and dataCollection: deny');
}
const router = models.length === 1 && models[0] === 'openrouter/free';
if (!router && models.some(m => !m.endsWith(':free'))) {
throw new Error('Paid models and mixed router lists are refused');
}
if (!Array.isArray(catalogue?.data)) throw new Error('Invalid catalogue');
for (const id of models) {
const m = catalogue.data.find(m => m.id === id);
if (!m || !['prompt', 'completion'].every(k =>
typeof m.pricing?.[k] === 'string' && m.pricing[k].trim() !== '' &&
Number(m.pricing[k]) === 0) ||
!m.architecture?.input_modalities?.includes('text') ||
!m.architecture?.output_modalities?.includes('text') ||
!m.supported_parameters?.includes('max_tokens')) {
throw new Error(`Not a current zero-token-price text route: ${id}`);
}
}
if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > 2000) {
throw new Error('Use 1–2000 characters of public test text');
}
return {
...(router ? { model: models[0] } : { models: [...models] }),
messages: [{ role: 'user', content: prompt }],
provider: { data_collection: 'deny', require_parameters: true },
max_tokens: 256, stream: false,
};
}
export async function freeChat(config, prompt, { key, catalogue, fetcher = fetch }) {
const body = requestFor(config, prompt, catalogue); // Before any POST.
if (typeof key !== 'string' || !key.trim()) throw new Error('Missing API key');
let res, data;
try {
res = await fetcher('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST', redirect: 'error',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body), signal: AbortSignal.timeout(15000),
});
data = await res.json();
} catch {
return { ok: false, kind: 'transport', message: 'No usable response. Request may have reached the provider.' };
}
if (!res.ok || data?.error) {
const code = Number(data?.error?.code) || res.status;
const kind = code === 429 ? 'limited' : code === 503 ? 'unavailable' : 'rejected';
return { ok: false, kind, status: code, retryAfter: res.headers.get('Retry-After'),
message: 'Free request stopped. Check limits, model availability and routing policy.' };
}
const text = data?.choices?.[0]?.message?.content;
if (typeof data?.model !== 'string' || !data.model.trim() ||
typeof text !== 'string' || !text.trim() ||
data.choices[0].finish_reason !== 'stop') {
return { ok: false, kind: 'incomplete', message: 'No complete text answer to display.' };
}
return { ok: true, model: data.model, text };
}
The catalogue check verifies that each configured route is currently listed, accepts text, returns text, supports max_tokens, and advertises zero prompt and completion token prices. It runs before the inference POST. The suffix/router checks still matter: OpenRouter documents free access through its :free entries and openrouter/free. Zero listed token prices alone do not establish that boundary.
For a live attempt, set OPENROUTER_API_KEY in your server environment and save the following as demo.mjs beside the adapter. Keep the key out of browser code. This runner refreshes the public catalogue each time and sends no inference request if that refresh fails.
import { readFile } from 'node:fs/promises';
import { freeChat } from './free-demo.mjs';
try {
const config = JSON.parse(await readFile(new URL('./config.json', import.meta.url)));
const response = await fetch('https://openrouter.ai/api/v1/models', {
redirect: 'error', signal: AbortSignal.timeout(15000),
});
if (!response.ok) throw new Error('Could not refresh catalogue; no inference sent');
const catalogue = await response.json();
const result = await freeChat(config, process.argv[2] ?? 'Suggest one name for a moon garden.', {
key: process.env.OPENROUTER_API_KEY, catalogue,
});
// Successful output is intentional. Do not put prompts/responses in server logs.
console.log(JSON.stringify(result, null, 2));
if (!result.ok) process.exitCode = 1;
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
Save the configuration as config.json in the same directory:
{
"models": ["openrouter/free"],
"dataCollection": "deny"
}
Then run node demo.mjs from a terminal with the key already set. A successful result contains ok: true, the returned model, and the answer's text. Display the model alongside the answer if users need to know which model they are trying. Do not replace it with the requested alias, openrouter/free.
The adapter can also accept several specific :free IDs in models. Every entry must pass the same catalogue checks. It deliberately rejects a list that mixes openrouter/free with other entries, keeping the choice between router mode and an explicit model list easy to inspect.
Check the stop state without a key
Before using the live runner, save this as check.mjs beside free-demo.mjs and run node check.mjs. The catalogue entry and response below are invented fixtures. They make no network calls and do not represent a model result.
import assert from 'node:assert/strict';
import { freeChat } from './free-demo.mjs';
const config = { models: ['fixture/demo:free'], dataCollection: 'deny' };
const catalogue = { data: [{
id: 'fixture/demo:free', pricing: { prompt: '0', completion: '0' },
architecture: { input_modalities: ['text'], output_modalities: ['text'] },
supported_parameters: ['max_tokens'],
}] };
let calls = 0;
const fetcher = async () => {
calls++;
return new Response(JSON.stringify({ error: { code: 503 } }), { status: 200 });
};
const options = { key: 'fixture-key', catalogue, fetcher };
await assert.rejects(freeChat(
{ ...config, models: ['fixture/demo:free', 'paid/model'] }, 'test', options
));
assert.equal(calls, 0); // Paid fallback refused before sending.
const result = await freeChat(config, 'test', options);
assert.equal(result.kind, 'unavailable'); // Body error despite HTTP 200.
assert.equal(calls, 1); // No client retry.
console.log('Local stop-state checks passed; no inference was run.');
The retained local suite also checked successful returned-model retention, rate limits, missing routes, billing and permission rejections, malformed responses, transport failures, and empty or truncated answers. All 15 tests passed. One test uses the downloaded public catalogue to validate real listed routes; the inference responses throughout the suite are synthetic.
An HTTP 200 can still contain an error, which is why the adapter checks both the status and the body. It also refuses to present a partial or empty answer as success. A reasoning model can consume a small output budget before producing visible text; a stopped answer with finish_reason: "length" needs inspection rather than another automatic attempt.
There are no client retries. OpenRouter can still try eligible free routes internally. If the adapter returns limited or unavailable, show the stopped state, preserve the user's input, and inspect the key's limits or the selected model's availability before offering another attempt. Retain a returned Retry-After hint where supplied; it is not proof that the next request will succeed. A transport timeout also leaves open whether the provider received the request.
This covers only the text inference call. A public demo still needs its own access controls and request limits before visitors can consume the shared allowance. The free-compute guide covers the separate hosting decision.