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 with responses.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 with messages.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 tools array 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_error for 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 by url or file source. 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.

What the SDKs actually send (our mock test)
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:

What the SDKs actually send (our mock test)
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:

Why tiktoken and "4 characters per token" drift
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 / 4 are 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, Infinity and 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 LIMITS are 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 minus max_tokens, then messages.create with it.
  • Step 3 is where a spend cap goes. Our per-user spend limits post estimates with 1 token per character; check.estimateUsd can 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 instructions or system, history and tools.
  • Use responses.inputTokens.count() for OpenAI and messages.countTokens() for Anthropic.
  • Set a short timeout and maxRetries: 0 on 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


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):