---
title: "OpenRouter free models: stop before a paid fallback"
date: 2026-10-27
description: "Free-model request limits and a Node/fetch adapter that validates free-only routing, preserves provider policy and stops on errors or incomplete answers."
tags:
  - "free-tier"
  - "ai-tools"
  - "side-projects"
---

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](https://openrouter.ai/docs/guides/routing/model-variants/free). 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](https://openrouter.ai/docs/api_reference/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](https://openrouter.ai/docs/guides/routing/routers/free-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](https://openrouter.ai/api/v1/models). 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](https://openrouter.ai/docs/guides/routing/provider-selection#requiring-providers-to-comply-with-data-policies) 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](https://openrouter.ai/docs/guides/privacy/provider-logging) and [OpenRouter's own logging and metadata](https://openrouter.ai/docs/guides/privacy/data-collection) 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.

```js
// 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.

```js
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:

```json
{
  "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.

```js
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](https://openrouter.ai/docs/api_reference/errors-and-debugging), 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](/free-compute/) covers the separate hosting decision.
