<?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>OpenAI's Codex plugin told me my session transfer failed. It hadn't.</title>
      <dc:creator>Andrew Avery</dc:creator>
      <pubDate>Tue, 25 Aug 2026 22:38:28 +0000</pubDate>
      <link>https://dev.to/andrewavery7/openais-codex-plugin-told-me-my-session-transfer-failed-it-hadnt-43c1</link>
      <guid>https://dev.to/andrewavery7/openais-codex-plugin-told-me-my-session-transfer-failed-it-hadnt-43c1</guid>
      <description>&lt;p&gt;I was two hours into a refactor when Claude Code warned me I was running low on tokens. Annoying, but solvable: OpenAI ships an official plugin, &lt;a href="https://github.com/openai/codex-plugin-cc" rel="noopener noreferrer"&gt;codex-plugin-cc&lt;/a&gt;, with &lt;code&gt;/codex:transfer&lt;/code&gt; for exactly this. Hand the live session to Codex, keep the conversation, keep working.&lt;/p&gt;

&lt;p&gt;I ran it. It said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Codex reported that the Claude import completed, but did not record
an imported thread.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So I opened Codex to start fresh — and the thread was already there. Full turn history. Continuable.&lt;/p&gt;

&lt;p&gt;The transfer had worked. The plugin just didn't know it. This is the story of finding out why, and the three other things I broke my head on along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom, and the shape of it
&lt;/h2&gt;

&lt;p&gt;Once I knew the message was wrong, the question was whether it was wrong &lt;em&gt;sometimes&lt;/em&gt; or &lt;em&gt;always&lt;/em&gt;. On my machine: always. Every transfer, every session size, every project — the thread appeared in Codex, and the plugin reported failure anyway.&lt;/p&gt;

&lt;p&gt;Three issues described the same thing: &lt;a href="https://github.com/openai/codex-plugin-cc/issues/417" rel="noopener noreferrer"&gt;#417&lt;/a&gt;, &lt;a href="https://github.com/openai/codex-plugin-cc/issues/514" rel="noopener noreferrer"&gt;#514&lt;/a&gt;, and &lt;a href="https://github.com/openai/codex-plugin-cc/issues/513" rel="noopener noreferrer"&gt;#513&lt;/a&gt; — since closed, on 2026-08-11, as a duplicate of a newer report, &lt;a href="https://github.com/openai/codex-plugin-cc/issues/618" rel="noopener noreferrer"&gt;#618&lt;/a&gt;. At the time I found them, none had a reply. I've since answered all three myself, with the fix below.&lt;/p&gt;

&lt;p&gt;A consistent failure is a gift. It means the bug is structural, not a race — and structural bugs are findable by reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Following the state
&lt;/h2&gt;

&lt;p&gt;Codex keeps its own records, and both of them turned out to be readable.&lt;/p&gt;

&lt;p&gt;The first is a SQLite database at &lt;code&gt;~/.codex/state_5.sqlite&lt;/code&gt;, with a &lt;code&gt;threads&lt;/code&gt; table carrying &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, and &lt;code&gt;cwd&lt;/code&gt;. That's the same store the Codex UI reads — ground truth for whether a thread got created. I opened it with a read-only URI so I could never corrupt live state while Codex was mid-write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;DB&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;?mode=ro&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SELECT MAX(created_at) FROM threads&lt;/code&gt; before the import, the same query after, and the answer was unambiguous. A new thread, with the right title, every time the plugin claimed nothing had been recorded.&lt;/p&gt;

&lt;p&gt;The second record is a JSON import ledger at &lt;code&gt;~/.codex/external_agent_session_imports.json&lt;/code&gt;. Each entry pairs the Claude transcript it came from with the Codex thread it produced: &lt;code&gt;source_path&lt;/code&gt;, &lt;code&gt;content_sha256&lt;/code&gt;, &lt;code&gt;imported_thread_id&lt;/code&gt;, &lt;code&gt;imported_at&lt;/code&gt;. Mine was in there too, correctly linked to the transcript I'd transferred.&lt;/p&gt;

&lt;p&gt;So Codex had done everything right and written it all down. The failure was in the reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause: four characters, and the one hiding behind them
&lt;/h2&gt;

&lt;p&gt;The plugin's success check lives in &lt;code&gt;scripts/lib/codex.mjs&lt;/code&gt;. Here it is as shipped in 1.0.6, unmodified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;importedThreadIdForSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourcePath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ledgerPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;resolveCodexHome&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;external_agent_session_imports.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ledgerPath&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ledger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readJsonFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ledgerPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canonicalSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;realpathSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourcePath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contentSha256&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sourceContentSha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canonicalSource&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;records&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;records&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;records&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;source_path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;canonicalSource&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;content_sha256&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;contentSha256&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;imported_thread_id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;imported_thread_id&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caller treats &lt;code&gt;null&lt;/code&gt; as a hard error and throws the message I saw.&lt;/p&gt;

&lt;p&gt;Now look at the two strings &lt;code&gt;record?.source_path === canonicalSource&lt;/code&gt; has to reconcile.&lt;/p&gt;

&lt;p&gt;Codex writes the ledger from the Rust side, storing the path in Windows extended-length form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;\\?\C:\Users\&amp;lt;you&amp;gt;\.claude\projects\...\session.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node's &lt;code&gt;fs.realpathSync&lt;/code&gt; returns the plain form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;C:\Users\&amp;lt;you&amp;gt;\.claude\projects\...\session.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are the same file. They are not the same string. &lt;code&gt;===&lt;/code&gt; says no, &lt;code&gt;.filter&lt;/code&gt; returns empty, &lt;code&gt;.at(-1)&lt;/code&gt; gives &lt;code&gt;undefined&lt;/code&gt;, the function returns &lt;code&gt;null&lt;/code&gt;, and the plugin reports a failure that never happened. Four characters of prefix.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;\\?\&lt;/code&gt; prefix is the win32 opt-out from &lt;code&gt;MAX_PATH&lt;/code&gt; and normal path parsing. Rust's &lt;code&gt;std::fs::canonicalize&lt;/code&gt; emits it; Node's &lt;code&gt;realpathSync&lt;/code&gt; doesn't. Anything comparing the two needs to normalize first — nothing in &lt;code&gt;codex.mjs&lt;/code&gt; does. I grepped the whole file for a normalizer before writing the patch.&lt;/p&gt;

&lt;p&gt;Now the part I got wrong at first — the more interesting half.&lt;/p&gt;

&lt;p&gt;Look at the filter again: two conditions, not one — the path has to match &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;content_sha256&lt;/code&gt; has to match. My first read was that because the path never matches on Windows, the hash clause is simply unreachable — dead code, harmless, ignore it.&lt;/p&gt;

&lt;p&gt;True about the evaluation. Exactly the wrong conclusion to draw from it. The path bug isn't hiding dead code — it's &lt;em&gt;masking a second live bug&lt;/em&gt;, and the mask is the only reason you can't see it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;contentSha256&lt;/code&gt; here is computed by reading the transcript at lookup time. The ledger's &lt;code&gt;content_sha256&lt;/code&gt; was computed by Codex when it imported. Between those two moments, the transcript keeps growing: the transfer is itself a turn in the live session, so Claude Code is still appending to the very file Codex just read. The hash you compute afterwards trails the content that was imported.&lt;/p&gt;

&lt;p&gt;So there are two independent reasons the lookup returns &lt;code&gt;null&lt;/code&gt; on Windows, and every transfer trips at least the first. On my machine, all 58 ledger records carried the &lt;code&gt;\\?\&lt;/code&gt; prefix, and 12 of 22 surviving source transcripts had already diverged in content from what was recorded. Fix the path and you don't fix the bug — you promote the second failure from theoretical to routine.&lt;/p&gt;

&lt;p&gt;I didn't understand that when I wrote my patch. That's why the patch was only half a fix.&lt;/p&gt;

&lt;p&gt;The path half is small and platform-honest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizeLedgerPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// \\?\UNC\server\share -&amp;gt; \\server\share&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\\\&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;UNC&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="se"&gt;\\\\&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\\\&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;win32&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And at the comparison, keeping the un-normalized real path for the file read so the hash still works on case-sensitive filesystems:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canonicalSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;realpathSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourcePath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canonicalKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalizeLedgerPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canonicalSource&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contentSha256&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sourceContentSha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canonicalSource&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;records&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;records&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;records&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nf"&gt;normalizeLedgerPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;source_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;canonicalKey&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;content_sha256&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;contentSha256&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;imported_thread_id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UNC branch matters because &lt;code&gt;\\?\UNC\server\share&lt;/code&gt; is the extended form of &lt;code&gt;\\server\share&lt;/code&gt;, so stripping a flat four characters there would produce &lt;code&gt;UNC\server\share&lt;/code&gt; — a path that resolves to nothing. Lowercasing is gated on &lt;code&gt;win32&lt;/code&gt; because case-folding paths on Linux is a correctness bug, not a convenience.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that wasn't mine
&lt;/h2&gt;

&lt;p&gt;I submitted the patch above upstream as &lt;a href="https://github.com/openai/codex-plugin-cc/pull/551" rel="noopener noreferrer"&gt;openai/codex-plugin-cc#551&lt;/a&gt;. It didn't land: I deleted the fork a couple of days later, GitHub auto-closed the PR with it, and a PR whose head repo is gone can't be reopened. Two weeks of a maintainer's queue, gone with one housekeeping click.&lt;/p&gt;

&lt;p&gt;But the deletion isn't the real problem with #551. It normalized the path and left the hash comparison sitting untouched right next to it. Merged as written, it would have turned a bug that fails every time into one that fails most of the time — harder to report, and arguably worse than what it replaced.&lt;/p&gt;

&lt;p&gt;The patch worth watching is &lt;a href="https://github.com/openai/codex-plugin-cc/pull/469" rel="noopener noreferrer"&gt;#469&lt;/a&gt;, and it isn't mine. &lt;a href="https://github.com/ayobamiseun" rel="noopener noreferrer"&gt;@ayobamiseun&lt;/a&gt; opened it two weeks before mine, and it's the better design for a reason I'd missed entirely: instead of making the two comparisons succeed, it stops depending on them. It snapshots the ledger before the import and falls back to whatever Codex appended during it — sidestepping the path prefix and the hash divergence at once, and defusing trap two below as a bonus.&lt;/p&gt;

&lt;p&gt;Worth sitting with for a second: I found the bug, described it correctly, explained a stranger's three-week-old issue — and still wrote the wrong fix, because I'd reasoned carefully about one condition in a two-condition filter. Diagnosis and remedy are different skills; being right about the first doesn't earn you the second.&lt;/p&gt;

&lt;p&gt;As of writing, #469 is open, thinly reviewed, and &lt;code&gt;main&lt;/code&gt; still compares those paths with &lt;code&gt;===&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap two: a re-transfer that legitimately creates nothing
&lt;/h2&gt;

&lt;p&gt;No longer trusting the plugin's message, I built my own check: snapshot &lt;code&gt;MAX(created_at)&lt;/code&gt;, run the import, poll for a thread newer than the snapshot. Clean, evidence-based, and wrong in one case.&lt;/p&gt;

&lt;p&gt;Transfer the same session twice with nothing new said in between, and Codex creates no new thread. It hashes the transcript content — that's what &lt;code&gt;content_sha256&lt;/code&gt; in the ledger is for — recognizes the re-import, and reuses the existing thread. Correct behavior. My newest-thread-after-snapshot detector polls until it times out and calls that a failure.&lt;/p&gt;

&lt;p&gt;So I added a second lookup for the timeout case: find the source path in the ledger (normalizing the prefix, same as above) and reuse the thread it already points at, saying so out loud. Note what that lookup keys on — the path alone, never the hash. That was luck, not foresight, at the time — but it's why my wrapper survives the divergence problem that sank my own upstream patch. Unchanged transcript gives you the same thread; changed transcript gives you a new one. Both are success, and they read differently to the user.&lt;/p&gt;

&lt;p&gt;The general shape of this mistake is worth keeping: I had built a detector for &lt;em&gt;state change&lt;/em&gt; when the thing I actually wanted to know was &lt;em&gt;state&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap three: the app-container PATH trap
&lt;/h2&gt;

&lt;p&gt;This one produced the most misleading error message of the batch.&lt;/p&gt;

&lt;p&gt;I had installed the Codex CLI with &lt;code&gt;npm i -g @openai/codex&lt;/code&gt; from a shell inside a packaged (MSIX) desktop app. Inside that container, &lt;code&gt;codex&lt;/code&gt; resolved fine. Every check I ran passed.&lt;/p&gt;

&lt;p&gt;Then the transfer printed a &lt;code&gt;codex resume&lt;/code&gt; command, a terminal opened to run it, and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;codex&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;The&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'codex'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;is&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;recognized&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cmdlet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="kr"&gt;function&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;or&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;operable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;program.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MSIX packages get a virtualized view of the file system. A global npm install from inside the container lands in &lt;code&gt;%LOCALAPPDATA%\Packages\&amp;lt;app&amp;gt;\LocalCache\Roaming\npm\...&lt;/code&gt;, and a shell spawned for the user runs &lt;em&gt;outside&lt;/em&gt; the container. For that shell, the PATH entry isn't there and, depending on virtualization, neither are the files. The CLI existed and did not exist, depending on who was asking.&lt;/p&gt;

&lt;p&gt;The fix is to stop emitting a bare command name that another process has to resolve. My resolver prefers a genuinely global install when there is one, and otherwise embeds an absolute path to the standalone vendored &lt;code&gt;codex.exe&lt;/code&gt; in the container's &lt;code&gt;LocalCache&lt;/code&gt; backing store — a real directory on disk, readable by any process, with no Node on PATH required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap four: two skill roots, every skill listed twice
&lt;/h2&gt;

&lt;p&gt;Smaller, but it cost me a confused afternoon. Codex discovers skills from &lt;strong&gt;both&lt;/strong&gt; &lt;code&gt;~/.codex/skills&lt;/code&gt; and &lt;code&gt;~/.agents/skills&lt;/code&gt;, the cross-tool &lt;a href="https://agentskills.io" rel="noopener noreferrer"&gt;Agent Skills&lt;/a&gt; root. Put a skill in both — easy to do accidentally, for instance by symlinking one into the other — and it appears twice in the picker. Three copies, three listings.&lt;/p&gt;

&lt;p&gt;The useful discovery was the verification command rather than the bug: &lt;code&gt;codex debug prompt-input&lt;/code&gt; renders the prompt the model actually sees. Skill list, instructions, all of it. That turns "I think Codex can see my skills" into something checkable. The skill index also caches until the surface restarts, which is its own source of phantom results.&lt;/p&gt;

&lt;h2&gt;
  
  
  A bonus: the deep-link routes
&lt;/h2&gt;

&lt;p&gt;Once I reliably have a thread id, printing a resume command stops being the best ending. So I went looking for whether Codex could open directly on a thread.&lt;/p&gt;

&lt;p&gt;Two routes, both found by inspection rather than documentation. The &lt;code&gt;OpenAI.Codex&lt;/code&gt; package manifest declares a &lt;code&gt;codex&lt;/code&gt; URL protocol, and the app bundle contains &lt;code&gt;codex://threads/&lt;/code&gt; route strings — so &lt;code&gt;codex://threads/&amp;lt;thread-id&amp;gt;&lt;/code&gt; opens the desktop app on that conversation. Separately, the VS Code extension's &lt;code&gt;handleUri&lt;/code&gt; forwards the URI path into its webview router, and that router serves local threads at &lt;code&gt;/local/&amp;lt;id&amp;gt;&lt;/code&gt; — so &lt;code&gt;vscode://openai.chatgpt/local/&amp;lt;thread-id&amp;gt;&lt;/code&gt; opens the panel on it.&lt;/p&gt;

&lt;p&gt;Both work. Neither is documented, which is exactly the caveat you should attach to them.&lt;/p&gt;

&lt;p&gt;One honest limitation: a URL can't carry a model choice. The &lt;code&gt;-m&lt;/code&gt; flag applies to the terminal resume path; the desktop app and the VS Code panel each use their own in-UI model selector.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this ended up
&lt;/h2&gt;

&lt;p&gt;The workarounds became a Claude Code plugin: &lt;a href="https://github.com/AndrewAvery7/claude-codex-bridge" rel="noopener noreferrer"&gt;claude-codex-bridge&lt;/a&gt;, MIT. It wraps the official plugin's importer rather than replacing it, verifies the outcome against Codex's own state instead of the plugin's message, and deep-links you into the thread.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude plugin marketplace add AndrewAvery7/claude-codex-bridge
claude plugin &lt;span class="nb"&gt;install &lt;/span&gt;codex-bridge@claude-codex-bridge
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;/to-codex&lt;/code&gt; in any session.&lt;/p&gt;

&lt;p&gt;The limitations, plainly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cross-platform, with one honest asterisk.&lt;/strong&gt; The engine is a single Python file and CI runs its test suite on Windows, macOS and Linux. The complete flow is verified end-to-end on Windows; the macOS and Linux paths (the protocol handler and terminal launch) are implemented and CI-checked but not yet confirmed against a real Codex install on those platforms. Reports either way are welcome.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It depends on undocumented Codex internals&lt;/strong&gt;: the &lt;code&gt;state_5.sqlite&lt;/code&gt; threads schema, the import ledger format, and both deep-link routes. A Codex update could move any of them. That is why every transfer also prints the plain &lt;code&gt;codex resume&lt;/code&gt; command, which is the fallback that survives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verified against&lt;/strong&gt; Codex CLI 0.145, the VS Code extension 26.721, and codex-plugin-cc 1.0.6, on Windows 11 with PowerShell 5.1. Other combinations are untested by me.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thing I'd take away from this one isn't the prefix. It's that a tool telling you it failed is a claim, not evidence — and when the tool is manipulating state you can read yourself, checking the state is usually cheaper than believing the message.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>openai</category>
      <category>windows</category>
      <category>debugging</category>
    </item>
    <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>
