LLM gateway
Call OpenRouter models from a run with its run token: endpoints, pricing, holds, limits, and errors.
Actors can call AI models without bringing their own API key. Scrape.ly runs an OpenRouter-compatible gateway with access to every model OpenRouter lists. A run calls it with its own run token as the API key, and each request is billed to the account that started the run as run usage.
const client = new OpenAI({ baseURL: process.env.SCRAPELY_LLM_BASE_URL, apiKey: process.env.SCRAPELY_TOKEN });Connect from an Actor
Every run gets the gateway's base URL in SCRAPELY_LLM_BASE_URL (it ends in /v2/llm/api/v1). Use the run token in SCRAPELY_TOKEN (also APIFY_TOKEN) as the API key. The gateway accepts it as Authorization: Bearer <token> or as x-api-key: <token>, so OpenAI, OpenRouter, and Anthropic SDKs all work.
import OpenAI from 'openai';
import { Actor } from 'scrapely';
await Actor.main(async () => {
const client = new OpenAI({
baseURL: process.env.SCRAPELY_LLM_BASE_URL,
apiKey: process.env.SCRAPELY_TOKEN,
});
const completion = await client.chat.completions.create({
model: 'openai/gpt-4o-mini',
messages: [{ role: 'user', content: 'Summarize this page in one sentence: ...' }],
max_tokens: 500,
});
await Actor.pushData({ summary: completion.choices[0].message.content });
});Model IDs are OpenRouter's, in the form author/model, such as openai/gpt-4o-mini. List them with GET /models.
The Anthropic SDK adds /v1/messages to its base URL itself, so give it the URL without the trailing /v1:
import os
from anthropic import Anthropic
client = Anthropic(
base_url=os.environ["SCRAPELY_LLM_BASE_URL"].removesuffix("/v1"),
api_key=os.environ["SCRAPELY_TOKEN"],
)Actors built for Apify
Apify Actors call https://openrouter.apify.actor/api/v1 with their run token. Scrape.ly serves the same paths, so every build replaces that address with the Scrape.ly gateway. This happens whatever the Transform actors setting says, so a run token is never sent to Apify:
- Only the build's temporary copy of the source changes. Your stored source is never modified.
- Files scanned:
.py,.js,.mjs,.cjs,.ts,.mts,.cts,.tsx,.jsx,.json,.yaml,.yml,.toml,.env,.ini, and.cfg, up to 5 MB each.node_modules,.git, virtual environments, and symlinks are skipped. - Only the literal text
https://openrouter.apify.actoris replaced. An address built at runtime, for example from pieces or from an input field, is left alone. - When anything changed, the build log shows
[Scrapely Build] LLM URL rewrite: N file(s) (openrouter.apify.actor → Scrapely LLM gateway).
For an address built at runtime, use SCRAPELY_LLM_BASE_URL as the base URL.
Endpoints
All paths are relative to SCRAPELY_LLM_BASE_URL, and they match OpenRouter's paths after /api/v1.
| Method and path | What it does | Billed |
|---|---|---|
POST /chat/completions | OpenAI-style chat completions, streaming or not | Yes |
POST /responses | OpenAI Responses API | Yes |
POST /messages | Anthropic Messages API | Yes |
POST /embeddings | Embeddings | Yes |
GET /models, GET /models/count | Model catalogue with prices | No |
GET /models/{author}/{slug}/endpoints | Providers serving one model | No |
GET /embeddings/models | Embedding models | No |
GET /providers | Provider list | No |
Any other path returns 404 not-found. Free catalogue calls still count toward the per-minute limit.
Streaming responses are passed through as they arrive. Request bodies can be up to 50 MB, so inline images and PDFs as base64 fit.
What the gateway changes in a request
The gateway forwards your request almost as is. It changes only what it needs to bill safely:
| Field | Change |
|---|---|
max_tokens, max_completion_tokens, max_output_tokens | Capped at 65,536 output tokens (free plan: 8,192), or lower if the model's own maximum is lower. With no limit set, the cap is set for you, reduced if needed so the prompt plus the output still fits the model's context window (never below 1,024). The limit applied is returned in the X-Scrapely-Max-Output-Tokens response header |
thinking.budget_tokens, reasoning.max_tokens | Lowered to one below the output limit when they reach or exceed it |
n | At most 8 choices |
models | Removed. Model fallbacks would let a cheap model front an expensive one, so send one model |
user | Set to your Scrape.ly user ID |
usage, stream_options.include_usage | usage is turned on for chat completions, plus stream_options.include_usage when streaming, so the cost comes back with the response |
Reasoning tokens count against the output limit, so leave room for thinking plus the answer.
For openrouter/auto and other routers whose price depends on the model they pick, a request with no output limit gets 8,192 tokens.
Pricing
Each request costs exactly what OpenRouter charges for it, times a markup, rounded up only to the next nanodollar ($0.000000001):
| Plan | Price |
|---|---|
| Paid | OpenRouter's cost × 1.25 |
| Free | OpenRouter's cost × 1.2, with a daily limit (see Free plan) |
Your plan's markup is shown in Billing → Pricing under AI models, and in GET /v2/users/me as plan.planPricing.llmGatewayMarkup.
OpenRouter's cost covers everything it bills for: input and output tokens, cached tokens, reasoning, web search, images, and PDF parsing. The price per token depends on the model; GET /models lists it. Failed requests that OpenRouter doesn't charge for aren't billed, and neither are free models (:free).
Where it shows up:
| Where | What you see |
|---|---|
| Billing → Current period | AI model tokens under Actors, with the token count and cost |
| Runs → run → Cost breakdown | AI model tokens, with the token count and cost. It's included in the run's total cost in the runs list |
| Run object in the API | usage.LLM_TOKENS (input plus output tokens) and usageUsd.LLM_TOKENS |
GET /v2/users/me/plan | usage.usageUsd.llm, also included in usageTotalUsd |
GET /v2/users/me/usage/monthly | LLM_TOKENS in monthlyServiceUsage and in each day's breakdown. Prices differ by model, so its baseUnitPriceUsd is the average price per token across the models used |
How a request is paid for
- Hold. Before the request goes to OpenRouter, the gateway holds its worst-case cost from your balance: the prompt, the full output limit for every choice, and any web search, per-image, or PDF-parsing charges, plus a 25% margin. For
openrouter/auto, the hold uses the 95th-percentile price of all listed models for each price type (prompt, output, per request), not the most expensive model's. If the hold doesn't fit, the request is refused with402and nothing is sent. - Charge. After the response ends, the run is charged OpenRouter's reported cost × the markup.
- Release. The hold is released once the charge is booked, so the money stays covered the whole time.
Holds count as spent while they last. Several large requests in parallel can therefore get a 402 even when the actual costs would fit your balance, and starting a new run sees less spendable balance while LLM requests are in flight. The run's own compute reservation counts as available for its LLM calls.
Cancelled and cut-off streams
A request without stream: true is always read to the end and billed in full, even if your code stops waiting for it.
If your code stops reading a stream (closes the connection, times out, or the run ends), the gateway cancels the request upstream. Providers that support cancellation stop generating, and you pay only for what was generated. Some providers keep generating to the end, and OpenRouter bills the full output.
In both cases the gateway bills from OpenRouter's record of the request, not from what reached your code. That record can take a while to appear, up to the full request time for providers that keep generating. Meanwhile the hold stays in place, and the charge can show up on the run up to about 30 minutes later. If the record never appears, the gateway bills its own estimate.
Spending cap and balance
- LLM charges count toward the run's
maxTotalChargeUsd. A request whose hold would go past the run's remaining budget is refused with402 charge-limit-exceededbefore anything is sent. If a charge still reaches the cap, it's cut to what fits and the run is stopped. - If your balance can't pay a charge when it's booked, the run is aborted with
Run aborted: Insufficient credits to pay for LLM usage. Please add credits.The charge stays recorded, and the platform retries it for about 10 minutes, so adding credits right away pays it.
Free plan
Free-plan accounts can use the gateway with tighter limits:
- $2.50 per day of LLM charges (after the markup), counted per UTC day across all your runs. In-flight holds count toward it. Past it, requests get
402 llm-free-daily-limituntil the next day. - Output capped at 8,192 tokens per request. The first time a run's request is cut down to this limit, or sent with no limit and given it, the run log shows
[Scrapely] LLM output capped at 8,192 tokens per request (free plan). Upgrade to raise it.Every chat, responses, and messages response also carries the limit in theX-Scrapely-Max-Output-Tokensheader, and an answer cut off by it ends withfinish_reason: "length". - 1.2× markup instead of 1.25×.
GET /v2/users/me/limits returns the limit and today's settled spend as llmFreeDailyCapUsd ({ "used", "limit" } in USD). It's null on a paid plan.
A paid plan removes the daily limit. Paid accounts are limited only by their balance, each run's maxTotalChargeUsd, and the rate limits.
Limits
| Limit | Default | Response when exceeded |
|---|---|---|
| Requests per minute, per account | 600 | 429 rate-limit-exceeded with Retry-After |
| Requests running at the same time, per account | 20 | 429 rate-limit-exceeded: Too many concurrent LLM requests |
| Requests with an invalid run token, per IP address | 1000 / minute | 429 rate-limit-exceeded |
| Request body | 50 MB | 413 request-too-large |
| Time without a byte from the model | 10 minutes | Request ends |
| Total time per request, streaming included | 30 minutes | Request ends |
The first two limits are per account and can be raised. They're shown in Billing → Limits as LLM Gateway Rate Limit and LLM Gateway Concurrent Requests, and in GET /v2/users/me/limits as maxLlmRateLimit and maxLlmConcurrency. Gateway requests don't count toward the general API limit.
A request stops counting toward the concurrency limit as soon as its response ends.
Errors
Errors from the gateway use the OpenAI shape, so SDKs show the message:
{ "error": { "type": "insufficient-balance", "message": "Insufficient balance for this LLM request. Please add credits to your account." } }| Status | type | Cause |
|---|---|---|
| 400 | invalid-request | Body isn't a JSON object, is invalid JSON, or has no model |
| 400 | model-not-found | The model isn't in GET /models or GET /embeddings/models |
| 401 | token-not-provided | No API key sent |
| 401 | token-not-valid | The run token is invalid, or the run has finished |
| 402 | insufficient-balance | The request's hold doesn't fit your balance |
| 402 | charge-limit-exceeded | The request could go past the run's maxTotalChargeUsd |
| 402 | llm-free-daily-limit | The free plan's daily LLM limit is reached |
| 403 | llm-gateway-runs-only | The key isn't a run token, for example your account API key |
| 404 | not-found | The path or method isn't served by the gateway |
| 413 | request-too-large | Body over 50 MB |
| 429 | rate-limit-exceeded | Per-minute or concurrency limit, or too many invalid tokens |
| 500 | internal-error | The gateway failed. Retry |
| 502 | llm-upstream-error | OpenRouter couldn't be reached or is unavailable, on any endpoint, including the model list. Retry |
| 503 | llm-gateway-disabled | The gateway is turned off on this platform |
Other errors from the model provider, such as an invalid parameter or a provider-side rate limit, are passed through with OpenRouter's status and body.
What's next
- Security: how run tokens work
- Run Actors: set
maxTotalChargeUsdto cap a run's LLM spend - Account & billing: balance, usage, and limits
- Publish and monetize: LLM charges count toward a user's budget