<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Andrew Avery</title>
    <description>The latest articles on DEV Community by Andrew Avery (@andrewavery7).</description>
    <link>https://dev.to/andrewavery7</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4075264%2F63b19723-283e-41a7-b04d-da2890adc0c3.png</url>
      <title>DEV Community: Andrew Avery</title>
      <link>https://dev.to/andrewavery7</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/andrewavery7"/>
    <language>en</language>
    <item>
      <title>My AI said the PDF was empty. The PDF was not empty.</title>
      <dc:creator>Andrew Avery</dc:creator>
      <pubDate>Wed, 19 Aug 2026 14:51:30 +0000</pubDate>
      <link>https://dev.to/andrewavery7/my-ai-said-the-pdf-was-empty-the-pdf-was-not-empty-1b1l</link>
      <guid>https://dev.to/andrewavery7/my-ai-said-the-pdf-was-empty-the-pdf-was-not-empty-1b1l</guid>
      <description>&lt;p&gt;I asked Claude Code to pull the key dates out of a PDF I had saved from a&lt;br&gt;
webpage. It came back immediately:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The document appears to be empty — it contains no extractable text.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which was strange, because I had the PDF open on my other monitor and it was&lt;br&gt;
four and a half megabytes of perfectly legible text.&lt;/p&gt;

&lt;p&gt;The interesting part is not that it was wrong. The interesting part is that&lt;br&gt;
nothing had failed. Every component in that chain did exactly what it was&lt;br&gt;
designed to do, reported success, and stacking those successes together&lt;br&gt;
produced a lie I believed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Reproducing it
&lt;/h2&gt;

&lt;p&gt;I was preprocessing documents with &lt;a href="https://github.com/microsoft/markitdown" rel="noopener noreferrer"&gt;markitdown&lt;/a&gt;,&lt;br&gt;
Microsoft's file-to-markdown converter, so I ran it by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;markitdown screenshot.pdf &lt;span class="nt"&gt;-o&lt;/span&gt; out.md
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="go"&gt;0
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; out.md
&lt;span class="go"&gt;0 out.md
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 0. Zero-byte output file. No warning, no stderr, nothing on the&lt;br&gt;
console at all.&lt;/p&gt;

&lt;p&gt;My integration did what integrations do: checked the return code, saw success,&lt;br&gt;
cached the result, and handed the model a path to a file with nothing in it. The&lt;br&gt;
model read the file, found nothing in it, and told me the document was empty.&lt;br&gt;
From its position that was a reasonable conclusion. It had been given an empty&lt;br&gt;
file and told the conversion worked.&lt;/p&gt;
&lt;h2&gt;
  
  
  This is not a bug
&lt;/h2&gt;

&lt;p&gt;My first instinct was to file an issue. I am glad I did not, because markitdown&lt;br&gt;
is behaving correctly and I would have been publicly wrong.&lt;/p&gt;

&lt;p&gt;The PDF was a full-page browser screenshot exported to PDF. It contains raster&lt;br&gt;
images and &lt;strong&gt;no text layer whatsoever&lt;/strong&gt;. &lt;code&gt;pdfminer&lt;/code&gt; reports 0 characters, and so&lt;br&gt;
does PyMuPDF when you ask it. markitdown's PDF backend extracts embedded text&lt;br&gt;
and does not OCR — that is a documented design decision, not an oversight.&lt;/p&gt;

&lt;p&gt;So there was genuinely nothing to find. And &lt;em&gt;finding nothing is not an error.&lt;/em&gt;&lt;br&gt;
A converter that exited non-zero every time a document happened to be empty&lt;br&gt;
would be wrong in a much more annoying way.&lt;/p&gt;

&lt;p&gt;The bug is somewhere else entirely, and it is worth naming precisely:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The bug is in every integration that treats exit code as evidence of yield.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Exit code answers "did the process complete?" I was reading it as an answer to&lt;br&gt;
"did we get the text?" Those are different questions, and for a document&lt;br&gt;
converter meeting a scanned page they have different answers. Mine was one of&lt;br&gt;
those integrations. Probably yours is too — &lt;code&gt;pdftotext&lt;/code&gt;, &lt;code&gt;pandoc&lt;/code&gt; and most&lt;br&gt;
extraction tooling have the same shape, because they should.&lt;/p&gt;

&lt;p&gt;That reframing is the whole story. Everything below is consequences.&lt;/p&gt;
&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Never measure success by exit code. Measure it by yield.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Simple to say. The trouble starts immediately, because "measure the yield" needs&lt;br&gt;
a threshold, and thresholds are where honest engineering goes to become&lt;br&gt;
arbitrary. Anyone can write &lt;code&gt;if len(text) == 0: fail&lt;/code&gt;. That catches the&lt;br&gt;
screenshot. It does not catch the case that actually cost me time.&lt;/p&gt;
&lt;h2&gt;
  
  
  The case that is actually hard
&lt;/h2&gt;

&lt;p&gt;A course completion certificate. One page, a decorative graphic, and a title&lt;br&gt;
line rendered as real text. It converts to this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Certificate of Completion
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thirty-nine characters. Not zero. It sails through an emptiness check, gets&lt;br&gt;
cached as a successful conversion, and the model dutifully reports that your&lt;br&gt;
certificate says "Certificate of Completion" and nothing else — which is, again,&lt;br&gt;
technically what it was given.&lt;/p&gt;

&lt;p&gt;That is the shape of the real problem. The fully-empty case is easy and any&lt;br&gt;
check catches it. The expensive failures are the near-misses: a certificate,&lt;br&gt;
a slide deck exported as page images with a footer on every slide, a contract&lt;br&gt;
scanned at an angle with a header that happened to OCR at some point. They all&lt;br&gt;
return &lt;em&gt;some&lt;/em&gt; characters.&lt;/p&gt;

&lt;p&gt;So the question becomes: how do you tell "extraction failed" from "this document&lt;br&gt;
is legitimately short"?&lt;/p&gt;
&lt;h2&gt;
  
  
  Byte count is the wrong instrument
&lt;/h2&gt;

&lt;p&gt;The obvious move is a minimum size — reject anything under, say, 500 bytes. It&lt;br&gt;
does not work, and the reason it does not work is worth being precise about:&lt;br&gt;
&lt;strong&gt;raw byte count conflates document length with extraction quality.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;800 bytes is a complete and correct conversion of a one-page memo. 800 bytes&lt;br&gt;
from a 200-page report is a catastrophic extraction failure. The same number&lt;br&gt;
means opposite things and the measure cannot distinguish them.&lt;/p&gt;

&lt;p&gt;What you want is &lt;strong&gt;density&lt;/strong&gt;, not volume. Characters per page normalises&lt;br&gt;
document length away and leaves only the question you actually care about: on&lt;br&gt;
each page, did we recover a page's worth of text?&lt;/p&gt;
&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;I ran four real documents — a deliberately mixed set, including the near-miss&lt;br&gt;
that started this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Document&lt;/th&gt;
&lt;th&gt;Pages&lt;/th&gt;
&lt;th&gt;Chars&lt;/th&gt;
&lt;th&gt;Chars/page&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Webpage screenshot saved as PDF&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Course certificate (graphic + title)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;39&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two-page text document&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1,864&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;932&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twenty-page slide deck&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;13,289&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;664&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two populations. No overlap. An order of magnitude between them.&lt;/p&gt;

&lt;p&gt;That gap is the finding. It is not a subtle statistical separation requiring a&lt;br&gt;
tuned classifier — a text extractor meeting a page of text produces hundreds of&lt;br&gt;
characters per page, and a text extractor meeting a picture produces tens or&lt;br&gt;
zero. There is nothing in between, because there is no such thing as a document&lt;br&gt;
that is 40% made of text.&lt;/p&gt;

&lt;p&gt;A threshold of &lt;strong&gt;100 characters per page&lt;/strong&gt; sits in that gap with roughly a 6×&lt;br&gt;
margin on both sides. The certificate is 2.5× below it; the sparsest real&lt;br&gt;
document is 6.6× above it. Small variations in document style — bigger fonts,&lt;br&gt;
more whitespace, a title slide — cannot cross a gap that wide.&lt;/p&gt;

&lt;p&gt;That margin is the entire justification for the number. I would not defend 100&lt;br&gt;
as optimal. I would defend it as &lt;em&gt;comfortably inside a gap where nothing lives&lt;/em&gt;,&lt;br&gt;
which is a much better property for a threshold than being finely tuned.&lt;/p&gt;
&lt;h2&gt;
  
  
  Which direction to fail
&lt;/h2&gt;

&lt;p&gt;Every threshold gets some cases wrong. What you get to choose is which way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A false positive&lt;/strong&gt; — a genuinely sparse PDF gets flagged as image-based — means&lt;br&gt;
the model reads the original document with vision instead. That costs more&lt;br&gt;
tokens. It loses nothing. The user gets a correct answer at a higher price.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A false negative&lt;/strong&gt; — an empty conversion is presented as real — means the model&lt;br&gt;
confidently reports an empty document. The content is lost entirely and &lt;em&gt;the&lt;br&gt;
user has no signal anything went wrong.&lt;/em&gt; That is the failure I started with.&lt;/p&gt;

&lt;p&gt;Those costs are not remotely symmetric, so the threshold is deliberately set to&lt;br&gt;
prefer the first. When it is wrong, it is wrong in the direction that costs&lt;br&gt;
money instead of the direction that costs truth.&lt;/p&gt;

&lt;p&gt;One more asymmetry, discovered by getting it wrong: &lt;strong&gt;the density test has to be&lt;br&gt;
PDF-only.&lt;/strong&gt; Applying it to every format looks consistent and is a mistake. A&lt;br&gt;
one-line email, a ten-second voice memo, a spreadsheet with four cells — all&lt;br&gt;
convert correctly to very little text. Rejecting those would discard good&lt;br&gt;
conversions to guard against a failure mode they cannot have. Only PDFs carry&lt;br&gt;
the specific hazard of a text extractor meeting a picture. Everything else is&lt;br&gt;
rejected only at literally zero.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three more traps in the same family
&lt;/h2&gt;

&lt;p&gt;Once you start looking for "success that isn't", it turns out to be a genre.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caching a bad result is worse than producing one.&lt;/strong&gt; My first version cached by&lt;br&gt;
modification time. A zero-byte conversion was therefore served for every future&lt;br&gt;
reference to that document — permanently, with no retry, even after I had fixed&lt;br&gt;
the underlying cause. A transient failure had been promoted to a permanent one&lt;br&gt;
by the cache. Conversions are now re-graded before reuse, unusable results are&lt;br&gt;
deleted rather than stored, and zero-byte artifacts from older versions get&lt;br&gt;
swept on the next run so an upgrade heals the cache without anyone intervening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silence is a failure mode.&lt;/strong&gt; The hook must never block a prompt, so unexpected&lt;br&gt;
errors exit 0 quietly. That is correct for almost everything and catastrophic&lt;br&gt;
for one case: if markitdown is not installed at all, a silent no-op is&lt;br&gt;
indistinguishable from "this document is empty" — the exact failure the whole&lt;br&gt;
project exists to prevent. Missing dependencies are now the one error reported&lt;br&gt;
loudly, with the install command.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bug that produced no output at all.&lt;/strong&gt; On Windows, PowerShell 5.1 prepends&lt;br&gt;
a UTF-8 BOM when piping to a native command. &lt;code&gt;json.load&lt;/code&gt; raises on the BOM. That&lt;br&gt;
exception hit the never-block-a-prompt handler and was swallowed, so the hook&lt;br&gt;
did nothing, silently, on every prompt, on an entire platform. Two invisible&lt;br&gt;
failure modes composing into a third. The input parsing is BOM-tolerant now, but&lt;br&gt;
the lesson is the one above: an error handler that guarantees silence will&lt;br&gt;
eventually guarantee it for something you needed to hear about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A licence is a dependency decision.&lt;/strong&gt; PyMuPDF reads some PDFs pdfminer cannot,&lt;br&gt;
so it is used when present — but it is never required. It is dual-licensed&lt;br&gt;
AGPL-3.0/commercial, which does not belong in the dependency set of an MIT&lt;br&gt;
project. Page counting, which the grading needs, uses pdfminer instead, which&lt;br&gt;
markitdown already depends on. The core path adds no dependency and no copyleft.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it looks like when it works
&lt;/h2&gt;

&lt;p&gt;When a conversion recovers real content, the model gets a pointer rather than&lt;br&gt;
the text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[markitdown] /path/report.pdf was converted to markdown at
~/.claude/markitdown-cache/report-a1b2c3d4.md (14973 bytes, 304 lines,
14472 chars, 20 page(s)). If this document's content is needed, Read or
Grep the .md file (not the original).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pointer matters more than it first looks. Prompts mention documents&lt;br&gt;
speculatively — "compare these three reports" may only genuinely need one of&lt;br&gt;
them. Loading all three into context costs those tokens on every subsequent turn&lt;br&gt;
of the conversation, used or not. A pointer costs about 400 characters and is&lt;br&gt;
paid once. The model reads or greps the file, with offset and limit for big&lt;br&gt;
ones, only if the content turns out to matter.&lt;/p&gt;

&lt;p&gt;And when extraction recovers nothing, no file is written, nothing is cached, and&lt;br&gt;
the model is told the truth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[markitdown] NO USABLE TEXT extracted from /path/screenshot.pdf (no text
extracted). This is an image-based/scanned PDF -- text extraction cannot
see into it and no .md was written. Read the ORIGINAL file natively with
the Read tool (use the `pages` parameter for long PDFs); Claude's vision
can read it. Do NOT report the document as empty.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is there because without it the model does exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that generalises
&lt;/h2&gt;

&lt;p&gt;None of this is really about PDFs.&lt;/p&gt;

&lt;p&gt;Any pipeline that hands one tool's output to another has this hazard the moment&lt;br&gt;
the first tool can succeed at doing nothing. Exit codes are a claim about&lt;br&gt;
process completion. They were never a claim about yield, and we have all been&lt;br&gt;
reading them as one because for most tools, most of the time, the two happen to&lt;br&gt;
coincide.&lt;/p&gt;

&lt;p&gt;The habit worth taking from it is small and cheap: &lt;strong&gt;after any extraction step,&lt;br&gt;
measure what came back and decide whether it is plausible for the input.&lt;/strong&gt; Not&lt;br&gt;
"did it error" — errors are the easy case, they announce themselves. The&lt;br&gt;
dangerous outcome is the one that returns 0, writes a file, and is empty.&lt;/p&gt;

&lt;p&gt;And when it is empty, say so. A downstream model handed an empty file will not&lt;br&gt;
wonder whether something went wrong. It will tell your user their document is&lt;br&gt;
blank, in the same assured voice it uses when it is right.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The hook I built out of this is MIT and runs on Windows, macOS and Linux:&lt;br&gt;
&lt;a href="https://github.com/AndrewAvery7/claude-markitdown-hook" rel="noopener noreferrer"&gt;claude-markitdown-hook&lt;/a&gt;.&lt;br&gt;
The measurements behind the threshold, including the fixtures used to calibrate&lt;br&gt;
it, are in &lt;a href="https://github.com/AndrewAvery7/claude-markitdown-hook/blob/main/docs/DESIGN.md" rel="noopener noreferrer"&gt;docs/DESIGN.md&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>debugging</category>
      <category>claude</category>
    </item>
    <item>
      <title>What LLM Cost Calculators Get Wrong</title>
      <dc:creator>Andrew Avery</dc:creator>
      <pubDate>Wed, 12 Aug 2026 21:04:19 +0000</pubDate>
      <link>https://dev.to/andrewavery7/what-llm-cost-calculators-get-wrong-2334</link>
      <guid>https://dev.to/andrewavery7/what-llm-cost-calculators-get-wrong-2334</guid>
      <description>&lt;p&gt;Is the number these calculators show you actually right? Not whether the&lt;br&gt;
model is in the catalog — whether the arithmetic behind that number matches&lt;br&gt;
what a provider will actually bill. Below are nine specific ways it usually&lt;br&gt;
doesn't, one story about how a wrong-but-plausible number hid from the&lt;br&gt;
people who built the tool, and what it takes to catch either failure before&lt;br&gt;
a reader does.&lt;/p&gt;

&lt;p&gt;Every figure below carries a source and a date, the same way the underlying&lt;br&gt;
catalog does. Don't take our word for it — the same provenance is one&lt;br&gt;
request away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://promptspend.dev/v1/models/gpt-5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response carries &lt;code&gt;provenance.source&lt;/code&gt;, &lt;code&gt;provenance.lastVerified&lt;/code&gt;, and&lt;br&gt;
&lt;code&gt;provenance.verifiedUrl&lt;/code&gt; on every model. If a number in this article and the&lt;br&gt;
number that endpoint returns ever disagree, the endpoint is right — this&lt;br&gt;
article just goes stale first.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Output is priced separately
&lt;/h2&gt;

&lt;p&gt;Every major provider charges more for what a model writes than for what you&lt;br&gt;
send it. Generation is the expensive part, not comprehension. Claude Sonnet&lt;br&gt;
4.6 bills input at $3 and output at $15 per million tokens (Anthropic,&lt;br&gt;
verified 2026-08-03) — output costs five times input, not some blended&lt;br&gt;
average of the two. A calculator that reports one "per-token" figure for a&lt;br&gt;
model is wrong before you've typed a prompt: it's picking one side of a 5x&lt;br&gt;
gap and hoping your workload matches it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Chat history compounds
&lt;/h2&gt;

&lt;p&gt;A multi-turn conversation doesn't cost turn-count times one-turn-cost.&lt;br&gt;
Every provider requires the full conversation to be resent as input on each&lt;br&gt;
turn, because none of them hold state for you between requests. Take a&lt;br&gt;
support exchange running 200 tokens per turn: a flat estimate for ten turns&lt;br&gt;
says 2,000 tokens. What actually gets billed — because turn N resends&lt;br&gt;
turns 1 through N-1 in full — is 9,000 tokens of resent history before a&lt;br&gt;
single new word of turn ten is counted. That's more than 4x the flat&lt;br&gt;
estimate, and it's before adding what each turn actually contributes. The&lt;br&gt;
shape is quadratic in turn count. A ten-message support chat and a&lt;br&gt;
ninety-message one don't scale the same way, and a calculator that&lt;br&gt;
multiplies instead of summing gets more wrong the longer the conversation&lt;br&gt;
runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Tokenizers differ per family
&lt;/h2&gt;

&lt;p&gt;"How many tokens is this prompt" doesn't have one answer across vendors,&lt;br&gt;
because it isn't one algorithm. OpenAI-family models use a published,&lt;br&gt;
runnable tokenizer (&lt;code&gt;o200k_base&lt;/code&gt;), so an exact count is possible&lt;br&gt;
client-side. Anthropic doesn't ship a public tokenizer at all — counting&lt;br&gt;
happens server-side — so anything outside OpenAI's family is necessarily a&lt;br&gt;
calibrated estimate: roughly 3.6 characters per token for English prose, 1.5&lt;br&gt;
for CJK text, tuned against real samples rather than the model's own code.&lt;br&gt;
A calculator that applies one ratio to every model it lists is applying an&lt;br&gt;
OpenAI-shaped assumption to providers that never agreed to it, and it won't&lt;br&gt;
say which of its numbers are exact and which are guesses, because it isn't&lt;br&gt;
tracking the difference itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Caching is not free
&lt;/h2&gt;

&lt;p&gt;"Prompt caching" sounds like a discount with no downside. The read side is:&lt;br&gt;
cached input on GPT-5.6 Terra runs $0.20 per million against a $2 standard&lt;br&gt;
rate, a 10x saving. The write side is where that framing falls apart. The&lt;br&gt;
first time a prefix enters the cache, both OpenAI and Anthropic charge&lt;br&gt;
more than the standard input rate to store it — 1.25x across every model&lt;br&gt;
we've checked, from Claude Haiku 4.5 ($1.25 cache-write against a $1 input&lt;br&gt;
rate, Anthropic, verified 2026-08-01) to GPT-5.6 Terra ($2.50 against $2,&lt;br&gt;
OpenAI, verified 2026-08-01). A calculator that only ever shows you the 10x&lt;br&gt;
read discount is showing you the number that makes the feature look best,&lt;br&gt;
not the number your first request of the day will actually cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Long context costs more
&lt;/h2&gt;

&lt;p&gt;Cross a provider's context threshold and the entire request reprices, not&lt;br&gt;
just the tokens past the line. OpenAI's GPT-5.4 bills $2.50 input / $15&lt;br&gt;
output per million tokens under 272,000 input tokens, and $5 / $22.50 — 2x&lt;br&gt;
input, 1.5x output — the instant a request crosses that line (OpenAI,&lt;br&gt;
verified 2026-08-03). That threshold applies per request, which means a&lt;br&gt;
conversation can start under the tier and cross it mid-thread as history&lt;br&gt;
accumulates: the same chat that priced cheaply on turn three can silently&lt;br&gt;
double its input rate by turn twelve, and a calculator that quotes one flat&lt;br&gt;
rate per model has no way to tell you when that happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Reasoning tokens are billable
&lt;/h2&gt;

&lt;p&gt;What you see as "the answer" and what you're billed for are not the same&lt;br&gt;
text. Reasoning models like OpenAI's o3 (billed at $2 input / $8 output per&lt;br&gt;
million tokens, OpenAI, verified 2026-08-03) generate hidden thinking tokens&lt;br&gt;
before the visible response, and those tokens are billed as output whether&lt;br&gt;
or not you ever see them. A calculator that counts only the words on screen&lt;br&gt;
is pricing a request that doesn't exist — the invoice includes tokens the&lt;br&gt;
interface never showed you, and the gap between "visible answer" and&lt;br&gt;
"billed output" is exactly the part a screenshot can't reveal.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Promotional pricing expires
&lt;/h2&gt;

&lt;p&gt;Introductory rates are real, and they're also temporary. A calculator that&lt;br&gt;
hard-codes them stops being honest the day the window closes. As of this&lt;br&gt;
writing, Claude Sonnet 5 is priced at $2 input / $10 output through&lt;br&gt;
2026-08-31 (Anthropic, verified 2026-08-01) — the standard rate that takes&lt;br&gt;
over the day after is $3 / $15. An estimate built today and still quoted in&lt;br&gt;
September is quoting a rate that no longer exists, unless the tool doing the&lt;br&gt;
estimating knows the window has a date and checks it against the date it's&lt;br&gt;
actually being asked. Most don't carry a date at all; they carry the number&lt;br&gt;
that was true when someone last edited the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Assumptions are visible
&lt;/h2&gt;

&lt;p&gt;Every calculator makes assumptions the provider hasn't published: what share&lt;br&gt;
of a conversation is a cache hit, how large the hidden reasoning share&lt;br&gt;
usually runs, whether the batch API is in play. Those assumptions decide the&lt;br&gt;
number on screen as much as the published rates do. The difference is&lt;br&gt;
whether they're printed next to the answer or buried in a methodology page&lt;br&gt;
nobody reads — a promotional rate in effect, a reasoning multiplier applied,&lt;br&gt;
a batch discount assumed, each stated in plain language under the total&lt;br&gt;
rather than folded silently into it. A number with its assumptions attached&lt;br&gt;
can be argued with. A number without them can only be believed or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Impossible scenarios are named
&lt;/h2&gt;

&lt;p&gt;GPT-5 has a 272,000-token context window and a 128,000-token output ceiling&lt;br&gt;
(OpenAI, verified 2026-08-03). Paste a 300,000-token document in and the&lt;br&gt;
request can't be sent at all — it's 28,000 tokens over the line before&lt;br&gt;
generation even starts. A calculator that just multiplies token count by&lt;br&gt;
rate will return a confident dollar figure for a request the API would&lt;br&gt;
reject outright, because multiplication doesn't know the difference between&lt;br&gt;
an expensive request and an impossible one. The honest answer to "what would&lt;br&gt;
this cost" is sometimes "nothing, because it can't happen" — and that answer&lt;br&gt;
requires checking the ceiling, not just the rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The proof nobody asked for
&lt;/h2&gt;

&lt;p&gt;Everything above is a claim about pricing engines in the abstract. Here's&lt;br&gt;
what it looked like to get one wrong in public, because we did, twice, on&lt;br&gt;
our own product.&lt;/p&gt;

&lt;p&gt;Building an editor extension that prices the model on the line of code that&lt;br&gt;
calls it meant running the same cost engine outside a browser, inside VS&lt;br&gt;
Code, for the first time. Six defects came out of that work. Every one of&lt;br&gt;
them passed 735 tests and six CI gates cleanly — verified, all green — and&lt;br&gt;
two of them reached the Marketplace before a person looked at the screen and&lt;br&gt;
noticed something was off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A call with no token cap of its own silently inherited the cap from the&lt;br&gt;
call above it, and priced it at that neighbor's rate.&lt;/strong&gt; The extension scans&lt;br&gt;
a file for model IDs and their nearby &lt;code&gt;max_tokens&lt;/code&gt; values; the backward&lt;br&gt;
search for "which cap belongs to this call" stopped at the previous model&lt;br&gt;
ID. That sounds like the right boundary. It isn't — a cap written between&lt;br&gt;
two model IDs actually belongs to the earlier call's forward-looking window.&lt;br&gt;
So a call that never specified an output ceiling displayed one anyway: a&lt;br&gt;
real number, correctly multiplied, describing a request nobody had written.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exact token counting quietly degraded to an estimate&lt;/strong&gt;, and reported a&lt;br&gt;
number either way, so nothing on screen said it had happened. The tokenizer&lt;br&gt;
ships as four external files rather than bundled into the extension — bundle&lt;br&gt;
it, and the download goes from 19.7 KB to 3.4 MB, because you'd be shipping&lt;br&gt;
a full rank table to every editor window whether or not anyone ran the&lt;br&gt;
estimate command. The packaging step that decides which files actually ship&lt;br&gt;
diverged from the module resolution that looks for them at runtime, so a&lt;br&gt;
release could pass every test and still leave one required file behind.&lt;br&gt;
When that happens, the code catches the failed import and falls back to the&lt;br&gt;
same calibrated-ratio estimate section 3 describes above — silently,&lt;br&gt;
correctly formatted, indistinguishable on screen from an exact count. Not an&lt;br&gt;
error. Not a crash. A plausible number, standing in for a real one, with&lt;br&gt;
nothing to tell you which you were looking at.&lt;/p&gt;

&lt;p&gt;Both defects share that shape, and so does every row above: the failure&lt;br&gt;
mode of a pricing tool is not crashing. A crash is loud, and someone fixes&lt;br&gt;
it that afternoon. The failure mode is returning a real-looking figure —&lt;br&gt;
clean formatting, correct arithmetic, plausible magnitude — for a scenario&lt;br&gt;
that isn't the one you asked about. Nothing about a wrong-but-plausible&lt;br&gt;
number announces itself. The only defense is being able to check where it&lt;br&gt;
came from, which is the same defense this article has been asking for from&lt;br&gt;
every calculator in the category, including — it turns out — our own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring the promise, not the machinery
&lt;/h2&gt;

&lt;p&gt;Fixing those two defects made the extension correct on the day it shipped.&lt;br&gt;
It says nothing about whether the published catalog stays correct on every&lt;br&gt;
day after. Those are different failure modes, and the second one has its own&lt;br&gt;
watcher: a daily job that fetches &lt;code&gt;promptspend.com/data/pricing.json&lt;/code&gt; — the&lt;br&gt;
live site, not the repository — four hours after the sync runs, and opens an&lt;br&gt;
alert if the catalog it finds is more than two days old.&lt;/p&gt;

&lt;p&gt;The live site, deliberately, not the repository. Between a price moving at&lt;br&gt;
a vendor and a reader seeing the update, there are four places the chain can&lt;br&gt;
break: the sync can error, the sync can raise a review flag that sits&lt;br&gt;
unmerged, the deploy can fail after a clean sync, or the CDN can serve a&lt;br&gt;
stale build. Checking the file in git catches exactly one of those four.&lt;br&gt;
Fetching what the site actually serves catches all of them, because it's&lt;br&gt;
asking the question from where the answer matters — the reader's browser,&lt;br&gt;
not a folder on a build server.&lt;/p&gt;

&lt;p&gt;Monitor the promise you made to the reader, not the machinery you built to&lt;br&gt;
keep it.&lt;/p&gt;

&lt;p&gt;Every calculator in this category, including this one, is a pile of&lt;br&gt;
assumptions wearing a clean interface. The nine on this page are the ones&lt;br&gt;
that quietly favor a low number. The tenth is what happens when the people&lt;br&gt;
who wrote the code stop trusting their own tests to catch the eleventh —&lt;br&gt;
and start checking the thing they actually promised, instead of the thing&lt;br&gt;
they built to promise it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
