Show HN: Tokensift, an open-sourced token-efficiency linter for LLM prompts Tokensift, an open-source token-efficiency linter for LLM prompts, has been released on GitHub by developer ritenv. The tool performs deterministic, local, tokenizer-level static analysis of prompt strings, Message[] arrays, and tool schemas, with 20 rules and real dollar cost per finding. It supports OpenAI models with exact token counts and Claude with estimates, and can be used as a library or CLI. Token-efficiency linter for LLM prompts and payloads. Deterministic, local, tokenizer-level static analysis of prompt strings, Message arrays, and tool schemas. Status : early, actively developed. Core engine, 20 rules, real dollar cost per finding, a CLI, and tokensift/matchers for vitest/jest all work today. OpenAI models are exact; Claude support is estimate-based, see Cost and pricing cost-and-pricing below. See DESIGN.md /ritenv/tokensift/blob/main/DESIGN.md for tradeoffs made along the way. LLM APIs charge per token, and token counts don't line up with characters or words as cleanly as you'd expect. A UUID, a base64-encoded file, an indented JSON blob: all of these cost more tokens than their length suggests, because the tokenizer can't find any reusable pattern in them. tokensift reads a prompt or a whole message array, or a tool schema and points out exactly where that's happening: this UUID cost 18 tokens and a short id would've cost 3, this block of instructions got pasted twice, this JSON would tokenize the same minified. It does this by actually tokenizing the text with the encoder the provider uses, not by estimating from character count. For OpenAI models that means the real BPE vocabulary, so counts are exact. For Claude, where no public tokenizer exists, it uses a calibrated estimate and says so on every finding confidence: "estimate" vs "exact" . If code linters are a useful comparison: this is that, but for token cost instead of style. Same idea as ESLint flagging an unused variable, just aimed at a different kind of waste: text that costs money and context-window space without doing anything for the model. Two ways to use it: as a library, called from your own code or test suite, or as a CLI, pointed at prompt files and wired into CI. Both run the same rules and produce the same findings. pnpm add tokensift A support-ticket classifier prompt with two few-shot examples, a ticket id, and an output schema, the kind of thing that grows by copy-paste. analyze runs every builtin rule by default: js import { analyze } from "tokensift"; const prompt = You are a support ticket classifier. Classify each ticket into one of: billing, technical, account. Remember to respond with only the category name, nothing else. Example 1: Ticket: "I was charged twice this month" Classification: billing Remember to respond with only the category name, nothing else. Example 2: Ticket: "I can't reset my password" Classification: account Remember to respond with only the category name, nothing else. Ticket 550e8400-e29b-41d4-a716-446655440000, from a customer: "My account was charged twice and I need a refund" Output using this schema: { "category": "string", "confidence": "number" } ; const report = analyze prompt, { model: "gpt-4o" } ; console.log report.findings ; Pass rules: ... to run a specific subset instead, or rules: to just tokenize with no findings at all. builtinRules is still exported if you want to reference or filter the full list explicitly. Three rules catch three different problems in this prompt. Full output, unedited: { ruleId: "uuid-bloat", severity: "warn", message: "UUID '550e8400-e29b-41d4-a716-446655440000' costs 18 tokens 2.0 chars/token ", why: "hex-with-dashes has no merges in BPE vocabularies, so UUIDs tokenize close to 1 token per 1-2 characters", loc: { input: { kind: "string" }, range: 445, 481 }, tokens: { current: 18, afterFix: 3, saved: 15 }, suggestion: "map '550e8400-e29b-41d4-a716-446655440000' to a short id like 'id-1' before prompting, and restore it in your own code after the response", confidence: "exact", cost: { perCall: { amount: 0.0000375, currency: "USD" }, per1000Calls: { amount: 0.0375, currency: "USD" }, }, }, { ruleId: "pretty-json", severity: "warn", message: "pretty-printed JSON costs 16 tokens, minified costs 9", why: "indented JSON spends tokens on newlines and leading spaces at every nesting level; the model doesn't need pretty-printing to parse structured data", loc: { input: { kind: "string" }, range: 578, 630 }, tokens: { current: 16, afterFix: 9, saved: 7 }, fix: { description: "minify JSON region", range: 578, 630 , replacement: '{"category":"string","confidence":"number"}', }, suggestion: "minify the JSON region", confidence: "exact", cost: { perCall: { amount: 0.0000175, currency: "USD" }, per1000Calls: { amount: 0.0175, currency: "USD" }, }, }, { ruleId: "repeated-block", severity: "warn", message: "a 12-token span repeats 3 times, costing 36 tokens total", why: "verbatim spans repeated across a prompt boilerplate headers, re-pasted examples are paid every time they appear; the model doesn't need the repetition to use them", loc: { input: { kind: "string" }, range: 100, 164 }, tokens: { current: 36, afterFix: 12, saved: 24 }, suggestion: "state this block once and refer back to it instead of repasting it", confidence: "exact", cost: { perCall: { amount: 0.00006, currency: "USD" }, per1000Calls: { amount: 0.06, currency: "USD" }, }, }, ; Same three findings, condensed: js report.findings.map f = ${f.ruleId}: ${f.message} ; "uuid-bloat: UUID '550e8400-e29b-41d4-a716-446655440000' costs 18 tokens 2.0 chars/token ", "pretty-json: pretty-printed JSON costs 16 tokens, minified costs 9", "repeated-block: a 12-token span repeats 3 times, costing 36 tokens total", ; 150 tokens total, 46 of them wasted. report.summary.cost : $0.000115 per call, $0.115 per 1,000 calls, real money once this runs at any volume. dyn marks a placeholder for a real value that fills in per request, a ticket body, a user's history, whatever changes each time. Build the prompt with it directly and pass the real value, .text is the actual prompt you send: js import { t, dyn, analyze } from "tokensift"; function buildTicketPrompt ticketBody: string { return t You are a support agent. Ticket: ${dyn "ticketBody", { value: ticketBody } } ; } const live = buildTicketPrompt realTicketBody .text; It matters for token analysis too. Without dyn , that region gets mis-tokenized as static text. With it, analyze splits static cost from dynamic budget, pass a representative value when you don't have real data yet, offline or in CI: js const report = analyze buildTicketPrompt "my billing failed twice" , { model: "gpt-4o" } ; report.summary.staticTokens; report.summary.dynamicBudget; defineRule gives you the same shape the 20 builtin rules use. A rule reads AnalysisContext the tokenized text, JSON regions, slots, and so on and returns Finding : js import { defineRule, createLinter, defineConfig } from "tokensift"; const noAllCaps = defineRule { id: "no-all-caps", defaultSeverity: "info", why: "SHOUTING wastes tokens the same as any other verbose phrasing", check ctx, severity { // ...scan ctx.text, return Finding return ; }, } ; const linter = createLinter defineConfig { model: "gpt-4o", customRules: noAllCaps } , ; const report = linter.analyze prompt ; customRules runs alongside every builtin rule, not instead of them. Severity overrides in rules: { ... } match a custom rule's id the same way they match a builtin's. Works out of the box. Supabase Edge Functions run on Deno, and the analyze / budget / tokenize path has no Node-specific code anywhere in it node:fs / node:path only show up in the CLI and tokensift/matchers , neither of which you'd import in a function , so it resolves cleanly via Deno's npm: specifier: js import { analyze } from "npm:tokensift"; const report = analyze prompt, { model: "gpt-4o" } ; No config, no import map, no shims. Works out of the box. Netlify Edge Functions run on Deno, and the analyze / budget / tokenize path has no Node-specific code anywhere in it, so it resolves cleanly via Deno's npm: specifier: js import { analyze } from "npm:tokensift"; const report = analyze prompt, { model: "gpt-4o" } ; No config, no import map, no shims. Works out of the box, no nodejs compat flag needed: js import { analyze } from "tokensift"; const report = analyze prompt, { model: "gpt-4o" } ; Cloudflare's compressed-size limit is 3MB on Free, 10MB on Paid. tokensift gzips to about 1.6MB, well under either. Use the regular Node.js runtime on Vercel, not the Edge Runtime. It works fully there, no bundle-size limit to think about. Vercel is moving away from Edge Runtime anyway, as of Next.js 16.3, runtime = "edge" isn't supported anymore. The default import { analyze } from "tokensift" path loads both OpenAI tokenizer families, convenient, but real weight if you only ever use one model. Worth trimming on Supabase or Netlify Edge Functions, both enforce a compressed bundle-size limit. Import the family you need directly and pass it via options.encoder to skip loading the other one: js import { analyze } from "tokensift"; import { O200kBaseEncoder } from "tokensift/encoders/o200k"; // gpt-4o, gpt-4o-mini, gpt-4.1 // import { Cl100kBaseEncoder } from "tokensift/encoders/cl100k"; // gpt-4, gpt-4-turbo, gpt-3.5-turbo const report = analyze prompt, { model: "gpt-4o", encoder: new O200kBaseEncoder "gpt-4o" , } ; These are separate build entries, not just separate exports, importing one subpath skips the other family's data. Measured with esbuild: everything gzips to about 1.6MB, one family gzips to about 1.13MB. Same engine, from a terminal. Point it at a file, a glob, or stdin: Scaffolds a project in one command: tokensift init --model gpt-4o Writes tokensift.config.json at the project root auto-discovered by every other command , plus three reference snippets under .tokensift/ : a GitHub Action github-action-snippet.yml , a pre-commit check pre-commit-snippet.sh , and a test-matcher setup snippet matcher-setup-snippet.ts . The snippets aren't installed automatically, copy the ones you want into .github/workflows/ , your existing pre-commit hook, or your test setup, since those are places your own tooling owns. Refuses to overwrite an existing file unless you pass --force . echo "You are an incident triage assistant. Summarize the error below for the on-call engineer, and repeat the trace id so they can search the logs. trace id: 550e8400-e29b-41d4-a716-446655440000 error: payment gateway timeout after 30s, 3 consecutive failures" | tokensift --stdin --model gpt-4o