Created
April 28, 2026 14:28
-
-
Save AranKomat/6c24d7b47efa21eb3cf658c889436bd4 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, { useState, useEffect, useRef } from 'react'; | |
| export default function TokenCounter() { | |
| const [text, setText] = useState(''); | |
| const [tokenCount, setTokenCount] = useState(null); | |
| const [rawApiCount, setRawApiCount] = useState(null); | |
| const [loading, setLoading] = useState(false); | |
| const [error, setError] = useState(null); | |
| const [overhead, setOverhead] = useState(null); | |
| const [calibrating, setCalibrating] = useState(false); | |
| const debounceRef = useRef(null); | |
| const lastCountedRef = useRef(''); | |
| // Calibrate: send a 1-token input, measure proxy overhead. | |
| // "a" tokenizes to exactly 1 token, so overhead = response_tokens - 1. | |
| const calibrate = async () => { | |
| setCalibrating(true); | |
| setError(null); | |
| try { | |
| const response = await fetch("https://api.anthropic.com/v1/messages", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| model: "claude-sonnet-4-6", | |
| max_tokens: 1, | |
| messages: [{ role: "user", content: "a" }] | |
| }) | |
| }); | |
| if (!response.ok) { | |
| const t = await response.text(); | |
| throw new Error(`Calibration ${response.status}: ${t.slice(0, 200)}`); | |
| } | |
| const data = await response.json(); | |
| const baseline = data?.usage?.input_tokens; | |
| if (typeof baseline !== 'number') { | |
| throw new Error('Calibration: unexpected response shape'); | |
| } | |
| setOverhead(baseline - 1); | |
| lastCountedRef.current = ''; | |
| } catch (err) { | |
| setError(err.message); | |
| setOverhead(null); | |
| } finally { | |
| setCalibrating(false); | |
| } | |
| }; | |
| useEffect(() => { calibrate(); }, []); | |
| const countTokens = async (input) => { | |
| if (overhead === null) return; | |
| if (!input || !input.trim()) { | |
| setTokenCount(null); | |
| setRawApiCount(null); | |
| setError(null); | |
| setLoading(false); | |
| return; | |
| } | |
| if (lastCountedRef.current === input) return; | |
| lastCountedRef.current = input; | |
| setLoading(true); | |
| setError(null); | |
| try { | |
| const response = await fetch("https://api.anthropic.com/v1/messages", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| model: "claude-sonnet-4-6", | |
| max_tokens: 1, | |
| messages: [{ role: "user", content: input }] | |
| }) | |
| }); | |
| if (!response.ok) { | |
| const t = await response.text(); | |
| throw new Error(`${response.status}: ${t.slice(0, 200)}`); | |
| } | |
| const data = await response.json(); | |
| const raw = data?.usage?.input_tokens; | |
| if (typeof raw !== 'number') { | |
| throw new Error(`Unexpected response: ${JSON.stringify(data).slice(0, 200)}`); | |
| } | |
| setRawApiCount(raw); | |
| setTokenCount(Math.max(0, raw - overhead)); | |
| } catch (err) { | |
| setError(err.message); | |
| setTokenCount(null); | |
| setRawApiCount(null); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| useEffect(() => { | |
| if (debounceRef.current) clearTimeout(debounceRef.current); | |
| debounceRef.current = setTimeout(() => countTokens(text), 500); | |
| return () => debounceRef.current && clearTimeout(debounceRef.current); | |
| }, [text, overhead]); | |
| const charCount = text.length; | |
| const wordCount = text.trim() ? text.trim().split(/\s+/).length : 0; | |
| const charsPerToken = typeof tokenCount === 'number' && tokenCount > 0 ? (charCount / tokenCount).toFixed(2) : '—'; | |
| const tokensPerWord = typeof tokenCount === 'number' && wordCount > 0 ? (tokenCount / wordCount).toFixed(2) : '—'; | |
| const CONTEXT_WINDOW = 200000; | |
| const contextPct = typeof tokenCount === 'number' ? Math.min(100, (tokenCount / CONTEXT_WINDOW) * 100) : 0; | |
| const INPUT_COST_PER_MTOK = 3; | |
| const estCost = typeof tokenCount === 'number' ? ((tokenCount / 1_000_000) * INPUT_COST_PER_MTOK) : 0; | |
| const status = calibrating ? 'calibrating' : loading ? 'counting' : overhead === null ? 'error' : 'ready'; | |
| const statusColor = status === 'ready' ? 'bg-emerald-500' : status === 'error' ? 'bg-red-500' : 'bg-amber-400 animate-pulse'; | |
| return ( | |
| <div className="min-h-screen bg-stone-950 text-stone-100 p-4 sm:p-8" style={{ fontFamily: "'JetBrains Mono', 'Fira Code', ui-monospace, monospace" }}> | |
| <div className="max-w-4xl mx-auto"> | |
| <div className="mb-8 border-b border-stone-800 pb-6"> | |
| <div className="flex items-center gap-2 text-amber-500 text-[11px] tracking-[0.2em] uppercase mb-3 flex-wrap"> | |
| <span className={`inline-block w-1.5 h-1.5 rounded-full ${statusColor}`}></span> | |
| <span>{status}</span> | |
| <span className="text-stone-700">·</span> | |
| <span className="text-stone-500">tokenizer</span> | |
| {overhead !== null && ( | |
| <> | |
| <span className="text-stone-700">·</span> | |
| <span className="text-stone-500 normal-case tracking-normal">overhead {overhead.toLocaleString()}</span> | |
| <button | |
| onClick={calibrate} | |
| disabled={calibrating} | |
| className="ml-1 text-stone-500 hover:text-amber-500 transition-colors disabled:opacity-40" | |
| title="Recalibrate proxy overhead" | |
| > | |
| ↻ | |
| </button> | |
| </> | |
| )} | |
| </div> | |
| <h1 className="text-3xl sm:text-4xl font-bold tracking-tight leading-none"> | |
| <span className="text-stone-100">claude 4.6</span> | |
| <span className="text-stone-600"> / </span> | |
| <span className="text-amber-500">token counter</span> | |
| </h1> | |
| <p className="text-stone-500 mt-3 text-sm"> | |
| Paste any text. Get the exact token count from the official Anthropic tokenizer. | |
| </p> | |
| </div> | |
| {calibrating && ( | |
| <div className="bg-amber-950/30 border border-amber-900/50 rounded-lg p-3 mb-4 text-amber-300/80 text-xs"> | |
| Calibrating proxy overhead with a 1-token probe… | |
| </div> | |
| )} | |
| <div className="mb-6"> | |
| <div className="flex justify-between items-center mb-2"> | |
| <label className="text-[10px] uppercase tracking-[0.2em] text-stone-500"> | |
| Input | |
| </label> | |
| {text && ( | |
| <button | |
| onClick={() => setText('')} | |
| className="text-[10px] uppercase tracking-[0.2em] text-stone-500 hover:text-amber-500 transition-colors" | |
| > | |
| clear | |
| </button> | |
| )} | |
| </div> | |
| <textarea | |
| value={text} | |
| onChange={(e) => setText(e.target.value)} | |
| placeholder="Paste text here. Counting happens automatically as you type..." | |
| disabled={overhead === null} | |
| className="w-full h-64 bg-stone-900 border border-stone-800 rounded-lg p-4 text-stone-100 placeholder-stone-600 focus:outline-none focus:border-amber-600/60 focus:ring-1 focus:ring-amber-600/30 transition-all resize-y text-sm leading-relaxed disabled:opacity-50" | |
| spellCheck={false} | |
| /> | |
| </div> | |
| <div className="bg-gradient-to-br from-stone-900 to-stone-950 border border-amber-900/40 rounded-lg p-6 mb-4 relative overflow-hidden"> | |
| <div className="absolute top-0 right-0 w-32 h-32 bg-amber-500/5 rounded-full blur-3xl"></div> | |
| <div className="relative"> | |
| <div className="text-[10px] uppercase tracking-[0.2em] text-amber-600/80 mb-2"> | |
| Tokens | |
| </div> | |
| <div className="text-5xl sm:text-6xl font-bold tabular-nums text-amber-400 leading-none"> | |
| {loading || calibrating ? ( | |
| <span className="text-stone-600">···</span> | |
| ) : typeof tokenCount === 'number' ? ( | |
| tokenCount.toLocaleString() | |
| ) : ( | |
| <span className="text-stone-700">0</span> | |
| )} | |
| </div> | |
| {typeof tokenCount === 'number' && !loading && ( | |
| <div className="mt-4 space-y-2"> | |
| <div className="flex justify-between text-[10px] uppercase tracking-[0.2em] text-stone-500"> | |
| <span>context window</span> | |
| <span className="tabular-nums">{contextPct.toFixed(2)}% of 200k</span> | |
| </div> | |
| <div className="h-1 bg-stone-800 rounded-full overflow-hidden"> | |
| <div | |
| className="h-full bg-gradient-to-r from-amber-600 to-amber-400 transition-all duration-500" | |
| style={{ width: `${Math.max(contextPct, 0.3)}%` }} | |
| ></div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6"> | |
| <StatCard label="Characters" value={charCount.toLocaleString()} /> | |
| <StatCard label="Words" value={wordCount.toLocaleString()} /> | |
| <StatCard label="Chars / Token" value={charsPerToken} /> | |
| <StatCard label="Tokens / Word" value={tokensPerWord} /> | |
| </div> | |
| {typeof tokenCount === 'number' && tokenCount > 0 && !loading && ( | |
| <div className="bg-stone-900 border border-stone-800 rounded-lg p-4 mb-6 flex items-baseline justify-between"> | |
| <div> | |
| <div className="text-[10px] uppercase tracking-[0.2em] text-stone-500 mb-1"> | |
| Estimated input cost | |
| </div> | |
| <div className="text-stone-400 text-xs"> | |
| at $3 / MTok | |
| </div> | |
| </div> | |
| <div className="text-2xl font-bold tabular-nums text-stone-200"> | |
| ${estCost < 0.01 ? estCost.toFixed(6) : estCost.toFixed(4)} | |
| </div> | |
| </div> | |
| )} | |
| {error && ( | |
| <div className="bg-red-950/40 border border-red-900/60 rounded-lg p-4 text-red-300 text-sm mb-6"> | |
| <div className="font-bold mb-1 text-red-400 text-xs uppercase tracking-widest">Error</div> | |
| <div className="text-red-300/80 text-xs break-all">{error}</div> | |
| </div> | |
| )} | |
| <div className="text-[11px] text-stone-600 leading-relaxed border-t border-stone-900 pt-4 space-y-2"> | |
| <p> | |
| The artifact API proxy injects a hidden system prompt into every request, so{' '} | |
| <span className="text-stone-400">usage.input_tokens</span> arrives inflated by a constant | |
| (measured: <span className="text-amber-600/80">{overhead !== null ? overhead.toLocaleString() : '—'}</span>{' '} | |
| tokens). On load this counter probes with a 1-token input to determine that overhead, then | |
| subtracts it from every subsequent count. | |
| </p> | |
| <p> | |
| Result: the displayed number is the tokenization of your text under the shared{' '} | |
| <span className="text-amber-600/80">Claude 4.6</span> tokenizer (used by both{' '} | |
| <span className="text-stone-400">claude-opus-4-6</span> and{' '} | |
| <span className="text-stone-400">claude-sonnet-4-6</span>). Counts here are measured via{' '} | |
| <span className="text-stone-400">claude-sonnet-4-6</span> since the proxy routes there. | |
| </p> | |
| <p className="text-stone-500"> | |
| <span className="text-amber-500">Note on Opus 4.7:</span> Opus 4.7 ships with a different | |
| tokenizer that produces ~1.0–1.35x more tokens for the same text (1.45–1.47x has been | |
| measured on technical content). This counter cannot reach the Opus 4.7 tokenizer from inside | |
| the artifact sandbox. For an exact Opus 4.7 count, use Anthropic's{' '} | |
| <span className="text-stone-400">/v1/messages/count_tokens</span> endpoint directly with{' '} | |
| <span className="text-stone-400">model: "claude-opus-4-7"</span>. | |
| </p> | |
| {typeof rawApiCount === 'number' && ( | |
| <p className="text-stone-700"> | |
| raw api: <span className="tabular-nums">{rawApiCount.toLocaleString()}</span> · overhead:{' '} | |
| <span className="tabular-nums">{overhead?.toLocaleString()}</span> · result:{' '} | |
| <span className="tabular-nums text-stone-500">{tokenCount?.toLocaleString()}</span> | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function StatCard({ label, value }) { | |
| return ( | |
| <div className="bg-stone-900 border border-stone-800 rounded-lg p-3"> | |
| <div className="text-[10px] uppercase tracking-[0.2em] text-stone-500 mb-1"> | |
| {label} | |
| </div> | |
| <div className="text-xl font-bold tabular-nums text-stone-200"> | |
| {value} | |
| </div> | |
| </div> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment