Next.js · AI costs ·
Count tokens before you call OpenAI or Claude: the count tokens APIs in Next.js
You want to know how many tokens a request will use before you send it: to check it fits the model's context window, to estimate what it will cost, or to stop one user's pasted document from running up a bill. Both OpenAI and Anthropic now have an API endpoint that counts input tokens for the exact payload you're about to send. This post shows both from TypeScript, explains why a local tokenizer or the "4 characters per token" rule can drift, and builds a small pre-call check for a Next.js route.
This guide pairs with Spend Caps Kit, and if you want the next step, read Prompt caching not working? Check it in OpenAI and Anthropic (Next.js).
Checked 17 Oct 2026 with openai 7.36.0, @anthropic-ai/sdk 0.135.0 and js-tiktoken 1.0.21 (Node 20.19, TypeScript strict). What the endpoints do comes from the OpenAI and Anthropic docs, quoted below and listed in Sources.
What we did and didn't test. We had no API keys for this post, so we never called the real count endpoints and we have no real counts from either provider. Every endpoint response below comes from a local mock server that we rebuilt from the documented response shapes, and the token numbers it returns are made up. What the mock does show is real SDK behaviour: which method each SDK exposes, the path and body it sends, how it parses the response, and how it retries. The local tokenizer numbers are real output from our machine. Our helper type-checks with tsc --strict and has 21 unit tests. We aren't affiliated with or endorsed by OpenAI or Anthropic; their product and model names are their trademarks, used here only to identify them.
Quick answer: two endpoints, same payload as the real call
Both providers count input tokens server-side, for the same request body you would send to generate a response:
- OpenAI:
/v1/responses/input_tokens, called from Node withresponses.inputTokens.count(). OpenAI's guide says it "lets you determine how many input tokens a request will use before you send it to the model." - Anthropic:
/v1/messages/count_tokens, called withmessages.countTokens(). Anthropic's guide says it "lets you determine the number of tokens in a message before you send it to Claude."
Both return a number of input tokens. Neither predicts output. OpenAI's help article is direct about that: "An input count does not predict how many output tokens the model will generate." For output, budget with your output limit, as in the helper further down.
Count tokens with the OpenAI API
The OpenAI guide says: "The input token count endpoint accepts the same input format as the Responses API. Pass text, messages, images, files, tools, or conversations—the API returns the exact count the model will receive." In the Node SDK the method sits under responses:
import OpenAI from "openai";
const openai = new OpenAI();
const { input_tokens } = await openai.responses.inputTokens.count({
model: "your-openai-model",
instructions: "You are a concise assistant.",
input: "What's the weather in Paris?",
});
console.log(input_tokens);
A few points from the guide worth knowing:
- It counts structure, not only text. "The count includes formatting tokens used to represent request structure, such as message roles and boundaries. These tokens might not appear in the text or fields you tokenize locally."
- Tools count. Pass the same
toolsarray you'll send with the real call. The guide lists "Tools and schemas add tokens that are hard to count locally" as a reason to use the endpoint. - Images and PDFs count. "File inputs (currently PDFs) are supported." Images go in as they would for
responses.create.
The endpoint is part of the Responses API. We didn't find a separate count method for Chat Completions in the SDK version we installed.
Count tokens with the Anthropic API
Anthropic's guide says: "The token counting endpoint accepts the same structured list of inputs for creating a message, including support for system prompts, tools, images, and PDFs." The TypeScript SDK method is messages.countTokens():
import Anthropic from "@anthropic-ai/sdk";
const anthropic = new Anthropic();
const { input_tokens } = await anthropic.messages.countTokens({
model: "your-claude-model",
system: "You are a concise assistant.",
messages: [{ role: "user", content: "What's the weather in Paris?" }],
});
console.log(input_tokens);
Leave out max_tokens: the count request doesn't take it. Things to plan for:
- It's an estimate. "The token count is an estimate. In some cases, the actual number of input tokens used when creating a message might differ by a small amount."
- Some inputs are refused. The guide says "This endpoint returns an
invalid_request_errorfor a few inputs that the Messages API accepts". Its list covers server tools other than the advisor tool (web search and code execution, for example), the MCP connector, and images or documents passed byurlorfilesource. Its advice for files: "Send images and PDFs as base64 to count them." - It has its own rate limit. The guide says: "Token counting is free to use but subject to requests per minute rate limits based on your usage tier." It adds: "Token counting and message creation have separate and independent rate limits."
- It ignores caching. "No, token counting provides an estimate without using caching logic." If you use prompt caching, the count is the full input; our prompt caching post covers how cached reads show up in usage after the call.
What the SDKs actually send (our mock test)
We pointed both SDKs at a local mock server through baseURL and logged each request. These are the requests and parsed results the SDKs produced. The input_tokens value of 42 is a number our mock returns, not a real count.
| SDK call | Request the SDK sent | Parsed result |
|---|---|---|
responses.inputTokens.count() |
POST /v1/responses/input_tokens, body with model, instructions, input and tools as passed |
input_tokens of 42, and object set to response.input_tokens |
messages.countTokens() |
POST /v1/messages/count_tokens, header anthropic-version: 2023-06-01, body with model, system, messages and tools as passed |
input_tokens of 42 |
We also had the mock return a 429 from the count endpoint, with retry-after: 0 so the run stayed short:
| SDK | maxRetries: 0 |
Default settings |
|---|---|---|
openai 7.36.0 |
1 request, then RateLimitError |
3 requests, then RateLimitError |
@anthropic-ai/sdk 0.135.0 |
1 request, then RateLimitError |
3 requests, then RateLimitError |
So by default a busy count endpoint can add two retries, and their waits, before your real call even starts. The helper below sets maxRetries: 0 and a short timeout on the count call, and falls back to an estimate instead. If you handle 429s from the main call, our 429 errors post covers which ones to retry.
Why tiktoken and "4 characters per token" drift
OpenAI's help article gives a rule of thumb, "1 token is approximately 4 characters", and adds: "These are estimates, not exact counts." For plain text it points to tiktoken and says to "Select the encoding for your target model, such as with tiktoken.encoding_for_model(model)." We ran js-tiktoken (a JavaScript port of tiktoken; not published by OpenAI) with the o200k_base encoding on four fixed samples:
import { getEncoding } from "js-tiktoken";
const enc = getEncoding("o200k_base");
export function localCount(text: string): number {
return enc.encode(text).length;
}
Real output from our run, run with TZ=UTC:
| Sample | Characters | tiktoken (o200k_base) |
Characters / 4 | Rule vs tiktoken |
|---|---|---|---|---|
| English prose | 244 | 50 | 61 | +22% |
| TypeScript | 322 | 79 | 81 | +3% |
| JSON tool schema | 245 | 58 | 61 | +5% |
| German prose | 255 | 56 | 64 | +14% |
On these samples the 4-characters rule came out higher than tiktoken every time, by 3% to 22%. A cruder "1 token per character" guess was about 4 to 5 times the tiktoken count. Neither number is what the API will count, for three reasons:
- Structure and tools. A local count only sees the text you give it. The OpenAI guide says the endpoint count includes formatting tokens that "might not appear in the text or fields you tokenize locally", and that images and files aren't covered by local tokenizers: "Images and files are not supported—estimates like
characters / 4are inaccurate". - It's OpenAI's encoding. tiktoken implements OpenAI's encodings. Anthropic's docs point to the count endpoint for Claude, not to tiktoken.
- Tokenizers change between models. Anthropic's guide says: "Claude 4.7 and later models and Claude Mythos Preview use a newer tokenizer. The same input text produces approximately 30 percent more tokens than on earlier models." Its advice: "Recount prompts against the model you plan to use rather than reusing counts measured against earlier models."
A local count is still handy as a fast fallback, or for trimming text before you count properly.
A pre-call check for Next.js
The helper does two jobs. countOpenAI() and countAnthropic() ask the provider for the exact count, with a 2-second timeout and no retries, and fall back to 1 token per character if the call fails. preflight() checks that input plus your output limit fits the context window and estimates the cost of the call:
// lib/token-count.ts
import type OpenAI from "openai";
import type Anthropic from "@anthropic-ai/sdk";
export type Counted = { tokens: number; source: "api" | "fallback" };
// Ask the provider for an exact input count. On error or timeout, fall
// back to 1 token per character, which errs high for English text.
async function countOrFallback(
call: () => Promise<{ input_tokens: number }>,
fallbackChars: number,
): Promise<Counted> {
try {
const { input_tokens } = await call();
if (Number.isInteger(input_tokens) && input_tokens >= 0) {
return { tokens: input_tokens, source: "api" };
}
} catch {
// fall through to the estimate
}
return { tokens: Math.max(fallbackChars, 1), source: "fallback" };
}
const COUNT_OPTS = { timeout: 2_000, maxRetries: 0 }; // fail fast
export function countOpenAI(
client: OpenAI,
body: OpenAI.Responses.InputTokenCountParams,
fallbackChars: number,
): Promise<Counted> {
return countOrFallback(
() => client.responses.inputTokens.count(body, COUNT_OPTS),
fallbackChars,
);
}
export function countAnthropic(
client: Anthropic,
body: Anthropic.MessageCountTokensParams,
fallbackChars: number,
): Promise<Counted> {
return countOrFallback(
() => client.messages.countTokens(body, COUNT_OPTS),
fallbackChars,
);
}
export type ModelLimits = {
contextWindow: number; // from the model's docs page
inputPerM: number; // USD per 1M input tokens, from the pricing page
outputPerM: number; // USD per 1M output tokens
};
export type Preflight =
| { ok: true; estimateUsd: number }
| { ok: false; reason: "too_long"; overBy: number }
| { ok: false; reason: "bad_input" };
// Input plus the output you allow must fit the context window. The
// estimate assumes the full output budget is used, so it errs high.
export function preflight(
inputTokens: number,
maxOutputTokens: number,
m: ModelLimits,
): Preflight {
const nums = [inputTokens, maxOutputTokens, m.contextWindow,
m.inputPerM, m.outputPerM];
if (!nums.every((n) => Number.isFinite(n) && n >= 0)) {
return { ok: false, reason: "bad_input" };
}
const total = inputTokens + maxOutputTokens;
if (total > m.contextWindow) {
return { ok: false, reason: "too_long", overBy: total - m.contextWindow };
}
const estimateUsd =
(inputTokens * m.inputPerM + maxOutputTokens * m.outputPerM) / 1_000_000;
return { ok: true, estimateUsd };
}
The cost estimate assumes the whole output budget gets used, so it errs high. That's deliberate: OpenAI's guide says "The max_output_tokens and max_completion_tokens parameters limit all tokens generated by the model, including non-visible tokens", and advises: "Leave headroom in these limits when you need a specific amount of visible output."
Our 21 unit tests run the real SDKs against the mock and cover:
- the API count being used when it returns a whole number
- falling back after exactly one request on a 429
- falling back on a 500 or a non-integer count
- falling back at the 2-second timeout when the count takes 3 seconds
- the context-window edge cases, and rejecting
NaN,Infinityand negative inputs
Wiring it into a route
The route counts the exact body it's about to send, refuses a message that won't fit, and then makes the real call with the same body plus max_output_tokens:
// app/api/chat/route.ts
import OpenAI from "openai";
import { createClient } from "@/lib/supabase/server";
import { countOpenAI, preflight, type ModelLimits } from "@/lib/token-count";
const openai = new OpenAI();
const MODEL = process.env.OPENAI_MODEL!; // your model id
const LIMITS: ModelLimits = {
contextWindow: 100_000, // example values: copy yours from the docs
inputPerM: 1,
outputPerM: 5,
};
const MAX_OUTPUT_TOKENS = 2_000;
const INSTRUCTIONS = "You are a concise assistant.";
export async function POST(req: Request) {
const supabase = await createClient();
const { data: auth } = await supabase.auth.getClaims();
if (!auth?.claims.sub) {
return Response.json({ error: "Sign in first" }, { status: 401 });
}
const { message } = (await req.json()) as { message: string };
const body = { model: MODEL, instructions: INSTRUCTIONS, input: message };
// 1. Count the exact payload you're about to send.
const counted = await countOpenAI(openai, body,
INSTRUCTIONS.length + message.length);
// 2. Check it fits, and get a cost estimate for this call.
const check = preflight(counted.tokens, MAX_OUTPUT_TOKENS, LIMITS);
if (!check.ok) {
return Response.json({ error: "Message is too long" }, { status: 413 });
}
// 3. Reserve check.estimateUsd against the user's cap here.
const res = await openai.responses.create({
...body,
max_output_tokens: MAX_OUTPUT_TOKENS,
});
return Response.json({ text: res.output_text, usage: res.usage });
}
Notes on the choices:
- The numbers in
LIMITSare examples, not real prices or limits. Copy your model's context window from its docs page and its prices from the pricing page. - The count is an extra request before every call. With the 2-second timeout, a slow count adds at most about 2 seconds before the fallback kicks in. If that's too much, count only when the message is long.
- Anthropic works the same way. Call
countAnthropic()with the request minusmax_tokens, thenmessages.createwith it. - Step 3 is where a spend cap goes. Our per-user spend limits post estimates with 1 token per character;
check.estimateUsdcan replace that guess with a counted number.
To turn a typical count into a monthly figure, enter it as "Input tokens per request" in our free OpenAI and Claude API cost calculator.
Checklist
- Count the same body you'll send, including
instructionsorsystem, history andtools. - Use
responses.inputTokens.count()for OpenAI andmessages.countTokens()for Anthropic. - Set a short timeout and
maxRetries: 0on the count call, and fall back to an estimate. - Check input plus your output limit against the context window before calling.
- Recount when you change models; don't reuse counts across tokenizers.
- For Anthropic, send images and PDFs as base64 when counting.
Related
- OpenAI and Anthropic spend limits per user in Next.js: a per-user rate limit and a monthly dollar cap in Supabase.
- OpenAI and Anthropic 429 errors in Next.js: which to retry, which to stop: reading the error code before you retry.
- Prompt caching not working? Check it in OpenAI and Anthropic (Next.js): how to confirm cache hits and price cached tokens.
- Claude Code: reduce token usage and make limits last: if you meant Claude Code plan limits rather than the API.
Disclosure: this is our own paid product. Spend Caps Kit ($19 plus VAT where applicable) is copy-paste TypeScript and one Supabase migration for Next.js. It adds per-user rate limits and a monthly dollar cap per user, priced from each response's token usage, and it reserves an up-front cost estimate that you pass in before each AI call. The Spend Caps Kit does not count tokens itself: it doesn't call either count endpoint, so a count like the one in this post is something you supply.
Sources (checked 17 October 2026):