<?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: Pavel Espitia</title>
    <description>The latest articles on DEV Community by Pavel Espitia (@pavelespitia).</description>
    <link>https://dev.to/pavelespitia</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%2F337213%2Fb21fb081-ae15-4041-9ab6-829aea593a28.jpeg</url>
      <title>DEV Community: Pavel Espitia</title>
      <link>https://dev.to/pavelespitia</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pavelespitia"/>
    <language>en</language>
    <item>
      <title>Streaming Long AI Jobs to the Browser: SSE Patterns From Building an Audit Tool</title>
      <dc:creator>Pavel Espitia</dc:creator>
      <pubDate>Sun, 26 Jul 2026 15:12:56 +0000</pubDate>
      <link>https://dev.to/pavelespitia/streaming-long-ai-jobs-to-the-browser-sse-patterns-from-building-an-audit-tool-2lc8</link>
      <guid>https://dev.to/pavelespitia/streaming-long-ai-jobs-to-the-browser-sse-patterns-from-building-an-audit-tool-2lc8</guid>
      <description>&lt;p&gt;The first version of the spectr-ai web frontend had a spinner. You uploaded a contract, the spinner spun, and several minutes later results appeared, or didn't. My test users (friends, so they were honest) all did the same thing: around the ninety-second mark they refreshed the page, killing the audit that was about to finish. A spinner with no progress is indistinguishable from a hang, and users act accordingly.&lt;/p&gt;

&lt;p&gt;So I rebuilt the pipeline around Server-Sent Events, and most of what I learned wasn't in the SSE tutorials, because tutorials stream a chat completion for ten seconds and call it a day. Multi-minute jobs with real state are a different problem. Here's what actually mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why SSE and not WebSockets
&lt;/h2&gt;

&lt;p&gt;Short version: the browser never needs to talk back mid-job. The client uploads a contract, then listens. That's exactly the shape SSE was built for, one-directional server-to-client over plain HTTP. No connection upgrade, no socket lifecycle management, and the browser's &lt;code&gt;EventSource&lt;/code&gt; gives you automatic reconnection for free, which turns out to be the most valuable feature of the whole protocol. WebSockets would work, but I'd be maintaining bidirectional machinery to use ten percent of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design the event vocabulary before writing any code
&lt;/h2&gt;

&lt;p&gt;My first attempt streamed whatever the pipeline felt like emitting: raw model tokens, log lines, half-thoughts. The frontend became a parser for an undocumented format that changed whenever I touched the backend. Bad.&lt;/p&gt;

&lt;p&gt;Second attempt: a fixed vocabulary of typed events, treated as a real API contract. Four types cover everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;AuditEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;progress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;partial-finding&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;finding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Finding&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// validated, complete finding&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AuditSummary&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;recoverable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decisions inside that shape that earned their place:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;progress&lt;/code&gt; carries semantic steps, not percentages.&lt;/strong&gt; "Analyzing withdraw(), function 3 of 7" keeps a user at the screen through minute four. A bar crawling from 41% to 43% doesn't, and honest percentages are impossible anyway when you don't know how long each model call takes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;partial-finding&lt;/code&gt; is the retention feature.&lt;/strong&gt; The pipeline finds issues one at a time, so I ship each one the moment it's validated. Users start reading the first finding while the model is still chewing on the rest, and the perceived wait collapses even though total time is unchanged. If your long job produces incremental results, streaming them beats any progress bar you could design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Findings stream only after validation.&lt;/strong&gt; Early on I forwarded findings as raw model output and occasionally streamed garbage straight into the UI. Now everything passes schema validation server-side first. Stream results, never stream your parsing problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;error&lt;/code&gt; distinguishes recoverable from fatal.&lt;/strong&gt; A single model call failing means retrying and telling the user, the audit continues. Out of budget or malformed input means the job is dead. The frontend does very different things with those, so the event must say which it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reconnection: the part everyone skips
&lt;/h2&gt;

&lt;p&gt;Laptops sleep. Phones switch networks. Proxies kill idle connections. Over several minutes, disconnection is a certainty at scale, and &lt;code&gt;EventSource&lt;/code&gt; will reconnect automatically, sending a &lt;code&gt;Last-Event-ID&lt;/code&gt; header with the last event it received.&lt;/p&gt;

&lt;p&gt;That header is only useful if you built for it. Two requirements: every event gets a monotonic ID, and the server keeps a replayable log of events per job, independent of any connection. Which forces the real architectural insight: &lt;strong&gt;the job must not live inside the HTTP request.&lt;/strong&gt; The audit runs somewhere durable and appends events to a log. The SSE endpoint is just a cursor over that log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/audits/[id]/events/route.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&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;const&lt;/span&gt; &lt;span class="nx"&gt;lastId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;last-event-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;encoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&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;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ReadableStream&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;controller&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;send&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AuditEvent&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;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nx"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`id: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\nevent: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\ndata: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;\n\n`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="c1"&gt;// 1. Replay whatever this client missed&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eventsAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="c1"&gt;// 2. Then follow the live log&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unsubscribe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recoverable&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nf"&gt;unsubscribe&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
          &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;

      &lt;span class="c1"&gt;// 3. Heartbeat comment so proxies don't kill an idle connection&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heartbeat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&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;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`: hb\n\n`&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abort&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;heartbeat&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;unsubscribe&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/event-stream&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-cache, no-transform&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keep-alive&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="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;Replay-then-follow is the whole trick. A client that drops at event 12 reconnects, replays 13 through wherever the job is now, and continues live. Refresh the page mid-audit and you lose nothing. The heartbeat comment line (SSE ignores lines starting with &lt;code&gt;:&lt;/code&gt;) keeps intermediaries from declaring the connection dead during a long model call, and &lt;code&gt;no-transform&lt;/code&gt; stops well-meaning proxies from buffering your stream into uselessness.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serverless will fight you on this
&lt;/h2&gt;

&lt;p&gt;Everything above assumes the job outlives the request, and default serverless assumes the opposite. A function that dies at its time limit takes your four-minute audit with it, and "keep the function alive by streaming" only stretches the ceiling, it doesn't remove it, and now a client disconnect can kill the job for every other viewer of that audit.&lt;/p&gt;

&lt;p&gt;What I landed on: separate the worker from the stream. The job runs in something with a long lifetime (a worker process, a queue consumer, a container, whatever your platform offers), writes events to shared storage, and the SSE function does nothing but read and forward. SSE endpoints become cheap and stateless, the job becomes durable, and reconnection falls out naturally because the log is the source of truth. If you're on a platform where a long-lived worker is genuinely unavailable, chunk the pipeline into resumable stages and accept the added complexity, but know that you're paying it to avoid a worker, not because streaming requires it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backpressure, or when the model outruns the browser
&lt;/h2&gt;

&lt;p&gt;Local models on a decent GPU can emit events faster than a busy tab renders them, especially token-level progress. Two things saved me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coalesce chatty events server-side.&lt;/strong&gt; Nobody needs 40 progress updates a second. I batch progress events on a short interval and send only the latest state per tick. Findings are never coalesced, every one ships. Classify your events as "latest value wins" or "every one matters" and throttle only the first kind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Respect the stream's own signals.&lt;/strong&gt; &lt;code&gt;controller.enqueue&lt;/code&gt; piles into a buffer if the consumer is slow, and unbounded buffering on a multi-minute job is a slow memory leak. Check &lt;code&gt;controller.desiredSize&lt;/code&gt; before enqueueing low-priority events and drop stale progress ticks when it goes negative. The client that skipped some progress frames catches up instantly at the next one, and nobody notices.&lt;/p&gt;

&lt;p&gt;The pattern that ties all of this together: treat the event log as the product and the SSE connection as a disposable view of it. Every hard problem (reconnection, serverless limits, backpressure, even multiple tabs watching one audit) gets easy once the connection stops being where state lives.&lt;/p&gt;

&lt;p&gt;What's the longest-running job you've had to keep a browser honest about, and what broke first?&lt;/p&gt;

</description>
      <category>node</category>
      <category>nextjs</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A Typosquat Package Almost Got My Keys: Dissecting the Attack Safely</title>
      <dc:creator>Pavel Espitia</dc:creator>
      <pubDate>Sat, 25 Jul 2026 15:12:20 +0000</pubDate>
      <link>https://dev.to/pavelespitia/a-typosquat-package-almost-got-my-keys-dissecting-the-attack-safely-4pbn</link>
      <guid>https://dev.to/pavelespitia/a-typosquat-package-almost-got-my-keys-dissecting-the-attack-safely-4pbn</guid>
      <description>&lt;p&gt;A recruiter DM led to a "take-home" repo. Standard stuff, or so it looked. I cloned it, opened package.json, and one line stopped me: a dependency named clx-cookieparser. The real package is cookie-parser. That extra clx- prefix and the missing hyphen were the whole attack. I never installed it. Here is how I read it apart without running a single line, and the checklist that has saved me twice now.&lt;/p&gt;

&lt;p&gt;I do smart contract security, but the boring truth is most attacks against developers do not touch the chain at all. They touch your machine, your environment variables, and your wallet files. This one wanted all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bait
&lt;/h2&gt;

&lt;p&gt;The repo was framed as a coding assessment for a "client." Clean README, a couple of real features, tests that passed. The kind of thing you skim and trust because it looks like work, not like a trap. That framing is the point. You are in "let me finish this task" mode, not "let me audit a stranger's code" mode.&lt;/p&gt;

&lt;p&gt;The dependency list had mostly normal packages. Express, a test runner, a couple of utilities. And then clx-cookieparser, sitting in the middle like it belonged. Typosquatting works because your eyes autocorrect. You read "cookie parser," your brain fills in the canonical name, and you move on.&lt;/p&gt;

&lt;p&gt;I did not move on, mostly out of habit. I keep npm configured so that installing is not a one-command reflex, which buys me time to look before anything executes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I could read it without running it
&lt;/h2&gt;

&lt;p&gt;Two settings do the heavy lifting here, and I recommend both to everyone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# never run install/postinstall scripts automatically&lt;/span&gt;
npm config &lt;span class="nb"&gt;set &lt;/span&gt;ignore-scripts &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# with pnpm, add a cooldown so brand-new versions can't hit you instantly&lt;/span&gt;
pnpm config &lt;span class="nb"&gt;set &lt;/span&gt;minimumReleaseAge 1440   &lt;span class="c"&gt;# 24 hours&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ignore-scripts&lt;/code&gt; matters because the classic move is a &lt;code&gt;postinstall&lt;/code&gt; hook that fires the moment you run install. Turn that off and cloning plus reading is safe, because nothing runs on its own. The cooldown matters because a lot of these malicious versions get yanked within hours of publication once someone reports them, so a 24-hour delay quietly dodges the freshest poison.&lt;/p&gt;

&lt;p&gt;So I read. Reading is not running. That distinction is the entire safety model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the payload was shaped like
&lt;/h2&gt;

&lt;p&gt;I want to be careful here, so I am describing structure, not handing anyone a recipe. No real payload code.&lt;/p&gt;

&lt;p&gt;The package had two layers. The first layer, the code visible in the published tarball, was almost boring. It wrapped a real cookie-parsing function so the thing actually worked if you used it. That is camouflage. If the library breaks your app, you rip it out and the attacker loses. If it works, you keep it and stop looking.&lt;/p&gt;

&lt;p&gt;The interesting part was a second dependency the first package pulled in, and here is the tell that made my neck prickle: that second dependency was present in the resolved dependency tree but did not appear in the lockfile the repo shipped. In other words, the code referenced a package that the committed lockfile did not account for. A supply chain that does not reconcile with its own lockfile is lying to you about something.&lt;/p&gt;

&lt;p&gt;That second-stage package is where the real behavior lived. Reading its structure (not executing it), the intent was obvious from the surface it reached for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It looked for environment variables and process env, the place people stash API keys, tokens, and RPC URLs.&lt;/li&gt;
&lt;li&gt;It probed common wallet and keystore file locations in the home directory.&lt;/li&gt;
&lt;li&gt;It assembled that data and prepared to ship it outbound to a remote endpoint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two-stage is a deliberate design. Stage one is quiet and passes a casual glance. Stage two carries the theft and hides behind an install-time or first-run trigger, one step removed from the package you actually named in your file. You have to follow the thread to see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signals that gave it away
&lt;/h2&gt;

&lt;p&gt;I did not need a fancy tool for the first pass. I needed to read like the code was guilty until proven innocent. The signals, roughly in the order they hit me:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The name. clx-cookieparser is not cookie-parser. Any prefix, swapped hyphen, or singular/plural flip on a popular package name is a five-alarm reason to stop.&lt;/li&gt;
&lt;li&gt;Lockfile mismatch. A dependency resolving in the tree that the committed lockfile does not describe means the manifest and reality disagree. Legitimate projects reconcile.&lt;/li&gt;
&lt;li&gt;Obfuscation and entropy. The second stage had chunks of high-entropy strings, the dense base64-looking blobs and hex that normal utility code just does not carry. Human-written cookie parsing is low entropy and readable. A wall of encoded bytes is a place to hide behavior from a reader.&lt;/li&gt;
&lt;li&gt;Reach that does not match purpose. A cookie parser has no business reading your home directory or touching env beyond what it is handed. Capability that exceeds the stated job is intent.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For the entropy check you do not need to run anything either. You can eyeball it, or score strings statically. A rough sketch of the idea:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Counter&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;shannon_entropy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="n"&gt;counts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="c1"&gt;# high entropy long string literals in a "utility" package are a smell
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;suspicious.js&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'"'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;shannon_entropy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;4.5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high-entropy blob:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is reading, scoring, and flagging. Never executing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist that saved me
&lt;/h2&gt;

&lt;p&gt;This is the routine now, and it takes maybe five minutes on a fresh repo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read package.json dependencies out loud. Any name that is close-but-not-exact to a popular package gets verified against the real registry name before anything else.&lt;/li&gt;
&lt;li&gt;Diff the dependency tree against the lockfile. Anything present in one and missing from the other is a stop sign.&lt;/li&gt;
&lt;li&gt;Never let install scripts run by default. &lt;code&gt;ignore-scripts true&lt;/code&gt; globally, opt in per project only when you trust it.&lt;/li&gt;
&lt;li&gt;Grep for high-entropy string literals and unexpected network or filesystem calls in dependencies before install, not after.&lt;/li&gt;
&lt;li&gt;Assume any repo from an unsolicited recruiter is hostile until proven otherwise. The social framing is part of the exploit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ended up feeding the repo through Argus Lens, the static repo scanner I built (lens.noctis.biz) partly because I got tired of doing this by hand. It flags build-time execution, lockfile-missing dependencies, and obfuscation without cloning or installing. But the tool just automates the checklist above. The checklist is the actual defense, and you can run it with your eyes.&lt;/p&gt;

&lt;p&gt;I did not lose anything this time. The uncomfortable part is how close normal, get-the-task-done behavior came to running it. One &lt;code&gt;npm install&lt;/code&gt; on autopilot and I would have shipped my env vars to someone.&lt;/p&gt;

&lt;p&gt;What is your default posture when a stranger sends you a repo to run: trust and clone, or hostile until proven safe?&lt;/p&gt;

</description>
      <category>security</category>
      <category>npm</category>
      <category>node</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I'm Entering My First Sherlock Audit Contest: The Setup, the Plan, the Fear</title>
      <dc:creator>Pavel Espitia</dc:creator>
      <pubDate>Fri, 24 Jul 2026 20:25:15 +0000</pubDate>
      <link>https://dev.to/pavelespitia/im-entering-my-first-sherlock-audit-contest-the-setup-the-plan-the-fear-5d4a</link>
      <guid>https://dev.to/pavelespitia/im-entering-my-first-sherlock-audit-contest-the-setup-the-plan-the-fear-5d4a</guid>
      <description>&lt;p&gt;I have shipped software for 18 years. Roughly 8 of those in crypto. And this week I signed up for my first Sherlock audit contest, and my hands were a little cold when I clicked in. That gap tells you something about how competing in public feels versus building things.&lt;/p&gt;

&lt;p&gt;I write secure code for a living. I built spectr-ai, an open-source AI smart contract auditor. I have read more Solidity than I can remember. None of that is the same as putting my name on a public leaderboard next to people who do this full time and win five figures per contest. So this post is me being honest about the setup, the plan, and the fear, before I have any results to brag about or hide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a contest at all
&lt;/h2&gt;

&lt;p&gt;There are a few ways to make money auditing. Firms hire you. Bug bounties pay you when you find a live bug. And contest platforms like Sherlock run time-boxed competitions where a protocol puts its code in scope, a pile of auditors (Watsons, in their language) hunt for bugs at the same time, and the prize pool gets split based on what you find.&lt;/p&gt;

&lt;p&gt;The model is the part that pulled me in. Payouts are severity based. A valid High is worth more than a valid Medium, and the pot for a given issue gets shared among everyone who found it. If five people report the same High, they split that issue's reward. So the incentive is not just to find bugs, it is to find the bugs other people miss.&lt;/p&gt;

&lt;p&gt;There is also a gate that matters if you care about ranking, not just cash. To climb from the entry tier you generally need to land 2 valid issues and clear a points threshold (around 20% of the top performer on a contest, from what I have read). That is a real bar. It stops the leaderboard from filling up with people who got lucky once. It also means my goal for the first contest is not "win." It is "submit 2 issues I can fully defend." Everything else is noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I prepared
&lt;/h2&gt;

&lt;p&gt;I did not open the code first. I spent the first two days reading old contest reports. Sherlock publishes them, and they are gold. You get to see what actually counted as a High, how judges reasoned about severity, which reports got downgraded to informational and why, and the shape of the mistakes that recur across protocols.&lt;/p&gt;

&lt;p&gt;A few patterns jumped out fast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rounding and precision bugs in share/asset math show up constantly.&lt;/li&gt;
&lt;li&gt;Access control that looks fine until you trace who can call an internal admin path through a proxy.&lt;/li&gt;
&lt;li&gt;Oracle assumptions that hold on mainnet but break on an L2 or during sequencer downtime.&lt;/li&gt;
&lt;li&gt;Reentrancy that is not the classic kind, but cross-function or cross-contract.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reading reports also recalibrated my sense of what a "real" finding looks like. A lot of things I would flag in a code review ("this could be clearer," "add a check here") are not valid contest issues unless I can show funds at risk or an invariant broken. Severity is about impact plus likelihood, not tidiness.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workflow: AI proposes, Foundry proves
&lt;/h2&gt;

&lt;p&gt;Here is the rule I set for myself, and I am not breaking it: nothing gets submitted without a working Foundry proof of concept.&lt;/p&gt;

&lt;p&gt;I use LLMs heavily, but as an idea generator, not an oracle. I run Ollama locally on WSL2 with qwen2.5-coder for the cheap fast passes, and I reach for a bigger model when a path looks promising. The loop looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I feed the model a contract plus the invariants I wrote down (more on that in another post) and ask it to enumerate attack paths, not to "find bugs." Open-ended "is this safe" prompts produce garbage. Specific "how could an attacker make totalAssets diverge from the sum of balances" prompts produce leads.&lt;/li&gt;
&lt;li&gt;The model gives me candidate attacks. Most are wrong. Some are hallucinated functions that do not exist. I throw those out immediately.&lt;/li&gt;
&lt;li&gt;For anything that survives, I write a Foundry test that either triggers the bug or fails to. If I cannot make it fail, it is not a finding.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A skeleton PoC looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// test/PocShareInflation.t.sol
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import {Vault} from "../src/Vault.sol";
import {MockToken} from "./mocks/MockToken.sol";

contract PocShareInflation is Test {
    Vault vault;
    MockToken token;
    address attacker = makeAddr("attacker");
    address victim = makeAddr("victim");

    function setUp() public {
        token = new MockToken();
        vault = new Vault(address(token));
        token.mint(attacker, 100 ether);
        token.mint(victim, 100 ether);
    }

    function test_firstDepositorInflation() public {
        // attacker deposits 1 wei, mints 1 share
        vm.startPrank(attacker);
        token.approve(address(vault), type(uint256).max);
        vault.deposit(1);
        // then donates directly to inflate share price
        token.transfer(address(vault), 50 ether);
        vm.stopPrank();

        // victim deposits and gets rounded down to 0 shares
        vm.startPrank(victim);
        token.approve(address(vault), type(uint256).max);
        vault.deposit(50 ether);
        vm.stopPrank();

        assertEq(vault.balanceOf(victim), 0, "victim minted zero shares");
        // attacker withdraws everything, including victim funds
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that assert holds, I have something. If it does not, the model was wrong and I move on. The PoC is also what I paste into the report, because a judge should be able to run &lt;code&gt;forge test --match-test test_firstDepositorInflation&lt;/code&gt; and watch it happen.&lt;/p&gt;

&lt;p&gt;This is the same discipline I use everywhere. spectr-ai flags things, but a flag is a hypothesis, not a verdict. The PoC is the verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fear, handled honestly
&lt;/h2&gt;

&lt;p&gt;The impostor feeling is real, and pretending it is not would be dishonest. Eighteen years of shipping does not translate to "I will place well against people who audit for a living." Those are different sports.&lt;/p&gt;

&lt;p&gt;What settled me down was reframing the goal. I am not trying to top the board. I am trying to submit two issues I can defend line by line, learn how the judging actually works from the inside, and read every other Watson's report after the contest closes to see what I missed. The reports of a closed contest are the best paid course in the space, except it is free.&lt;/p&gt;

&lt;p&gt;There is also a floor to the downside. The worst case is I submit nothing valid, lose a week, and learn a lot from the post-contest reports. That is a cheap tuition. The upside is a payout and a data point that I can actually do this.&lt;/p&gt;

&lt;p&gt;I picked the contest this week. I am not naming it because I do not want to color anyone else's read of the same code, and honestly because I want to talk about method here, not about one protocol. Next posts will be about how I read the scope and how I turn invariants into findings.&lt;/p&gt;

&lt;p&gt;If you have competed on a contest platform, what is the one thing you wish someone had told you before your first one?&lt;/p&gt;

</description>
      <category>security</category>
      <category>web3</category>
      <category>solidity</category>
      <category>career</category>
    </item>
    <item>
      <title>Reading an Audit Contest Scope Like an Auditor: Invariants First, Code Second</title>
      <dc:creator>Pavel Espitia</dc:creator>
      <pubDate>Fri, 24 Jul 2026 15:36:10 +0000</pubDate>
      <link>https://dev.to/pavelespitia/reading-an-audit-contest-scope-like-an-auditor-invariants-first-code-second-4edo</link>
      <guid>https://dev.to/pavelespitia/reading-an-audit-contest-scope-like-an-auditor-invariants-first-code-second-4edo</guid>
      <description>&lt;p&gt;The first time I audited seriously, I opened the biggest contract in the repo and started reading line one. Two hours later I had a headache and zero findings. I had memorized how the code worked without ever asking what it was supposed to guarantee. That is backwards, and it took me a while to unlearn it.&lt;/p&gt;

&lt;p&gt;Now I do not read Solidity first. I read the scope, and before I look at a single function body I write down what must always be true. Bugs are violations of those truths. If you do not know the truths, you are just admiring the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step one: write the invariants before you read
&lt;/h2&gt;

&lt;p&gt;An invariant is a property the protocol claims will always hold, no matter who calls what in what order. For a contest, I start with money and control, because that is where severity lives.&lt;/p&gt;

&lt;p&gt;Two questions cover most of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who can move funds, and under what conditions?&lt;/li&gt;
&lt;li&gt;What must always hold about the accounting?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a lending-pool-shaped protocol my starting invariant list looks like this, written in plain language before I care how any of it is implemented:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The sum of all user deposits minus all borrows equals the pool's available liquidity plus outstanding debt. Accounting must reconcile.&lt;/li&gt;
&lt;li&gt;A user can only withdraw up to their own balance, never more, never someone else's.&lt;/li&gt;
&lt;li&gt;A position can only be liquidated when it is actually under the health threshold.&lt;/li&gt;
&lt;li&gt;Interest accrues monotonically, it never goes backwards in a way that lets someone repay less than they owe.&lt;/li&gt;
&lt;li&gt;Only the borrower, or a liquidator on an unhealthy position, can reduce a debt.&lt;/li&gt;
&lt;li&gt;Nobody except governance can change interest rate parameters or the oracle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice none of that mentions a function name. These are the promises. Now my job for the rest of the contest is simple to state: find an ordering of calls that breaks one of these.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step two: map the external entry points
&lt;/h2&gt;

&lt;p&gt;Funds do not teleport. Something has to be called from outside for state to change. So I list every externally reachable function, because the attack surface is exactly that set and nothing else.&lt;/p&gt;

&lt;p&gt;I do this fast with grep before reading anything carefully:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# every external / public function in scope&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="s2"&gt;"function .*&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="s2"&gt;(external|public)&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; src/ &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"view&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;pure"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I drop the views and pures because they cannot change state. What is left is the list of levers an attacker can pull. For a lending pool that is usually the familiar set: deposit, withdraw, borrow, repay, liquidate, and whatever admin setters exist.&lt;/p&gt;

&lt;p&gt;Then I annotate each one with the invariant it could threaten. deposit and withdraw threaten the accounting reconciliation. withdraw threatens the "only your own balance" rule. liquidate threatens the "only when unhealthy" rule. The setters threaten the governance-only rules. Now I have a grid: entry points down one side, invariants across the top, and cells where they intersect are where I go hunting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step three: draw the trust boundaries
&lt;/h2&gt;

&lt;p&gt;Most real findings live at the seams where the protocol trusts something it should not fully trust. Before reading logic I mark every boundary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Oracles. Where does price come from? Can it be manipulated in a single transaction (spot price from an AMM is the classic footgun)? What happens if it returns stale data, or zero, or reverts?&lt;/li&gt;
&lt;li&gt;Admin and roles. What can a privileged role do, and is any privileged path reachable through a proxy or a delegatecall in a way the code did not intend? "Admin can rug" is often out of scope, but "a non-admin can reach an admin-only effect" is a High.&lt;/li&gt;
&lt;li&gt;Cross-contract calls. Every external call is a place where control leaves the contract and can come back (reentrancy), or where the callee can behave adversarially (a malicious token with a weird transfer, a fee-on-transfer token, a rebasing token).&lt;/li&gt;
&lt;li&gt;Token assumptions. Does the code assume 18 decimals? Assume transfer returns a bool? Assume no fee on transfer? Each assumption is a boundary that a hostile token crosses.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the lending pool, the oracle boundary is where I would look first, because "manipulate price, borrow against inflated collateral, walk away" is the shape of a lot of Highs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step four: now read the code, hunting for violations
&lt;/h2&gt;

&lt;p&gt;Only now do I open function bodies. And I do not read them as "what does this do." I read them as "which invariant does this touch, and can I break it here."&lt;/p&gt;

&lt;p&gt;Here is a mini worked example. Pseudocode, lending-pool-shaped, deliberately buggy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// invariant at risk: a user can only withdraw up to their own balance
function withdraw(uint256 amount) external {
    uint256 shares = amountToShares(amount);
    // BUG: no check that balanceOf[msg.sender] &amp;gt;= shares
    balanceOf[msg.sender] -= shares;   // underflows? or does it?
    totalShares -= shares;
    token.transfer(msg.sender, amount);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading invariant-first, I do not ask "does this transfer tokens." I ask "does this enforce that you only take your own balance." The answer is no, there is no bound check before the subtraction. In old Solidity that underflows to a giant balance. In 0.8+ it reverts, so maybe safe... unless amountToShares rounds in a way that lets shares come out to zero while amount is nonzero, in which case you withdraw tokens while decrementing nothing. That is the crack. I go verify it with a Foundry PoC, because a hypothesis is not a finding until it fails a test.&lt;/p&gt;

&lt;p&gt;Compare that to the oracle boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// invariant at risk: only genuinely unhealthy positions can be liquidated
function liquidate(address user) external {
    uint256 price = ammPair.getSpotPrice();   // single-block manipulable
    uint256 collateralValue = collateral[user] * price;
    require(collateralValue &amp;lt; debt[user], "healthy");
    // seize collateral, repay debt
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The invariant says liquidation only happens when a position is truly unhealthy. But price comes from a spot AMM read, which a well-funded attacker can push within a single transaction using a flash loan. So they can make a healthy position look unhealthy, liquidate it, and profit. The bug is not in the arithmetic, it is in trusting a manipulable source. Invariant-first thinking finds it because I already flagged the oracle as a boundary in step three, before I ever read this function.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this order matters
&lt;/h2&gt;

&lt;p&gt;If you read code first, you get anchored on how it works and you start believing it. The author's mental model leaks into yours. You end up checking that the code does what it appears to intend, which is the opposite of auditing. Auditing is checking whether the code can be made to do what it must never do.&lt;/p&gt;

&lt;p&gt;Invariants first flips the frame. You decide what the protocol promised, independent of the implementation. Then every function is a suspect measured against those promises. When I built spectr-ai, this is the structure I tried to bake into how it reasons: state the properties, then check the code against them, rather than free-associating about "vulnerabilities."&lt;/p&gt;

&lt;p&gt;It is also faster. On the contest I picked this week, writing the invariant list took maybe 40 minutes and it turned a 2,000-line codebase into a short list of "here are the six things worth breaking, and here are the three seams where they probably break." That is a map. Reading line one to line two thousand is just wandering.&lt;/p&gt;

&lt;p&gt;When you sit down with an unfamiliar codebase, do you write down what must always be true before you read it, or do you dive into the code and reconstruct the rules as you go?&lt;/p&gt;

</description>
      <category>security</category>
      <category>solidity</category>
      <category>web3</category>
      <category>smartcontracts</category>
    </item>
    <item>
      <title>What 67 Dev.to Posts in Three Months Taught Me About Writing for Developers</title>
      <dc:creator>Pavel Espitia</dc:creator>
      <pubDate>Thu, 23 Jul 2026 15:58:59 +0000</pubDate>
      <link>https://dev.to/pavelespitia/what-67-devto-posts-in-three-months-taught-me-about-writing-for-developers-2j62</link>
      <guid>https://dev.to/pavelespitia/what-67-devto-posts-in-three-months-taught-me-about-writing-for-developers-2j62</guid>
      <description>&lt;p&gt;Since the middle of April 2026 I have published 67 posts on dev.to. My best one has 576 views. That is not a humble brag, it is the honest ceiling of a security developer who decided to write in public and hit publish every day for three months. I want to lay out what actually happened, with the real numbers I have, because most "how to blog" advice comes from people who skip the unglamorous middle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that surprised me
&lt;/h2&gt;

&lt;p&gt;My top performer is a practical Ollama guide. A how-to. Run this model locally, here is the command, here is the gotcha on WSL2. 576 views.&lt;/p&gt;

&lt;p&gt;Meanwhile the posts I was proudest of, the deep smart contract security pieces where I actually know things most people do not, consistently pulled roughly a fifth of the traffic. Not once. As a pattern. The local-AI how-tos beat the deep security content something like five to one on views, over and over.&lt;/p&gt;

&lt;p&gt;That stung a little, and then it taught me something. My expertise is not what most readers came for. They came for a Tuesday problem: "I want to run a model on my machine without paying for tokens and it is not working." I happen to be able to answer that clearly. The fact that I would rather talk about reentrancy is my preference, not the reader's need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson one: write for the reader's Tuesday problem
&lt;/h2&gt;

&lt;p&gt;The security posts assume you already care about smart contract security. That is a small room. The Ollama post assumes you have a laptop and a mild frustration, which is basically everyone in the building.&lt;/p&gt;

&lt;p&gt;I did not stop writing security content. I write it because it is who I am and it is the work I want. But I stopped expecting it to travel. Content that solves a concrete, common, right-now problem travels. Content that requires you to already be in the niche does not, no matter how good it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson two: niche expertise needs a bridge topic
&lt;/h2&gt;

&lt;p&gt;Here is the move that started to work. Instead of choosing between "the thing people search for" and "the thing I know deeply," I bridge them. A post like "running a local model to triage code smells" sits on top of the popular Ollama topic but carries my security angle inside it. The Ollama part is the door. The security part is the room I actually wanted to show you.&lt;/p&gt;

&lt;p&gt;That reframes the whole strategy. The popular topic is not a distraction from my expertise, it is the delivery vehicle for it. Nobody clicks "advanced oracle manipulation patterns." Plenty click "cheap local AI setup," and if I can teach one security idea while they are there, that is a win that would never have happened in a pure security post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson three: nobody comments until you ask
&lt;/h2&gt;

&lt;p&gt;For a long stretch I got near-zero comments. Views, sometimes reactions, but silence. I assumed that was just how it goes for technical writing.&lt;/p&gt;

&lt;p&gt;Then I started ending every post with one genuine question. Not "let me know what you think," which is noise. A specific question tied to the post: what is your default when a stranger sends you a repo, do you write invariants before you read code, that kind of thing. Comments started showing up. Not a flood, but real ones, from people with opinions.&lt;/p&gt;

&lt;p&gt;The lesson is dumb in hindsight. A blog post is a monologue unless you explicitly hand the reader the mic. People will engage if you give them a small, concrete, low-effort opening. A big open-ended "thoughts?" is too much work to answer. A pointed question is easy to answer, so people do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson four: constancy beats motivation, so automate it
&lt;/h2&gt;

&lt;p&gt;I publish daily, and I do not rely on feeling like it. I have a queue of drafts and a cron script that publishes the next one. The decision "should I post today" is where most blogging dies, so I deleted the decision.&lt;/p&gt;

&lt;p&gt;The setup is not fancy. Drafts live in a folder, a script picks the next one and pushes it via the dev.to API, and cron runs it on a schedule. Roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;POSTS_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/projects/pavelEspitia.github.io/posts"&lt;/span&gt;
&lt;span class="nv"&gt;NEXT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$POSTS_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.md'&lt;/span&gt; &lt;span class="nt"&gt;-not&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'.*'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEXT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"queue empty"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# publish $NEXT via the dev.to API, then move it out of the queue&lt;/span&gt;
publish_to_devto &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEXT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEXT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$POSTS_DIR&lt;/span&gt;&lt;span class="s2"&gt;/published/"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"published: &lt;/span&gt;&lt;span class="nv"&gt;$NEXT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# one post a day at 9am, pulled from the queue
0 9 * * * /home/pavel/projects/pavelEspitia.github.io/publish-next.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point is not the code. The point is that motivation is a terrible scheduler. A queue plus automation means I write in batches when I have energy, and publishing happens whether or not today-me feels inspired. Three months of daily posts did not come from discipline. They came from removing the daily choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson five: distribution beats polish
&lt;/h2&gt;

&lt;p&gt;I spent real time polishing early posts. Rewriting sentences, agonizing over structure. Those are not my top posts. My top posts are the ones on topics people actually search for, published consistently, cross-posted to more than one platform. The polish barely moved the numbers. The topic and the consistency moved the numbers.&lt;/p&gt;

&lt;p&gt;That does not mean write badly. It means the marginal hour is better spent on "is this a thing people are looking for" and "did I actually ship it to where they are" than on the fourth pass of a sentence nobody will notice. Clear and published beats perfect and queued.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell myself in April
&lt;/h2&gt;

&lt;p&gt;Write for the problem someone has on a normal Tuesday. Use the popular topic as the door to the room where your real expertise lives. End with a real question so the thing becomes a conversation. Put publishing on a script so your mood is not in charge. And accept that your favorite posts and your most-read posts will often be different posts, and that is fine.&lt;/p&gt;

&lt;p&gt;Sixty-seven posts in, the compounding is subtle but real. Not viral, not a huge following, but a body of work that exists, a habit that runs on rails, and a much clearer sense of what readers actually want from me versus what I assumed they wanted.&lt;/p&gt;

&lt;p&gt;If you write technical posts: what topic of yours quietly outperforms the stuff you are proudest of, and have you leaned into it or fought it?&lt;/p&gt;

</description>
      <category>writing</category>
      <category>blogging</category>
      <category>career</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The Cheap Way to Add AI Review to CI: Small Local Models Plus Prompt Caching</title>
      <dc:creator>Pavel Espitia</dc:creator>
      <pubDate>Wed, 22 Jul 2026 15:46:53 +0000</pubDate>
      <link>https://dev.to/pavelespitia/the-cheap-way-to-add-ai-review-to-ci-small-local-models-plus-prompt-caching-4748</link>
      <guid>https://dev.to/pavelespitia/the-cheap-way-to-add-ai-review-to-ci-small-local-models-plus-prompt-caching-4748</guid>
      <description>&lt;p&gt;I wired an AI code reviewer into CI, felt clever, and then looked at the bill after a busy week of PRs. Every push, every commit, sending full diffs to a big frontier model. It added up faster than I expected, and most of what it was reviewing was formatting changes and README edits that did not need a genius reading them. So I rebuilt it as two tiers, and the cost dropped hard without losing the catches that mattered.&lt;/p&gt;

&lt;p&gt;The idea is simple. Do not send everything to the expensive model. Use a cheap model as a bouncer that decides what is even worth escalating, and reserve the big model (with caching turned on) for the files that could actually hurt you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tier one: a cheap triage pass
&lt;/h2&gt;

&lt;p&gt;The first tier looks at the diff and answers one question: is anything here risky enough to justify a real review? That is a classification task, and classification does not need a frontier model.&lt;/p&gt;

&lt;p&gt;I run this tier on a small local model. On my own machine that is Ollama with qwen2.5-coder, but in CI it can be a self-hosted small model or the cheapest cloud tier your provider offers. The job is triage, not judgment.&lt;/p&gt;

&lt;p&gt;The triage prompt is deliberately narrow. I do not ask it to review. I ask it to route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;You are a triage filter for code review. For the diff below, output JSON only:
{ "risk": "low" | "high", "reason": "&lt;span class="nt"&gt;&amp;lt;one&lt;/span&gt; &lt;span class="na"&gt;short&lt;/span&gt; &lt;span class="na"&gt;phrase&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;" }

Mark "high" if the diff touches: auth, access control, money/token math,
cryptography, SQL or shell string building, deserialization, file paths,
network calls, or anything that changes who can do what.
Mark "low" for formatting, comments, docs, tests, renames, and config bumps.

DIFF:
&lt;span class="nt"&gt;&amp;lt;diff&lt;/span&gt; &lt;span class="na"&gt;here&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A small model is perfectly good at "does this touch auth or money." When it says low, CI posts a one-line "no high-risk changes detected" and stops. No expensive call. In practice that shortcuts the majority of PRs, because most PRs really are docs, tests, and plumbing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tier two: the big model, but only on the risky files, with caching
&lt;/h2&gt;

&lt;p&gt;When tier one says high, the risky files get escalated to the strong model. This is where the real review happens, and this is where prompt caching earns its keep.&lt;/p&gt;

&lt;p&gt;The trick is that most of what you send the big model does not change between reviews. The system prompt (your review rubric, your severity definitions, your house rules) is identical every time. And your repo context (the surrounding files, the conventions, the interfaces the changed code depends on) is stable across the many small PRs that touch the same area. Caching that stable prefix means you pay full price for it once and a small fraction on every subsequent review that reuses it.&lt;/p&gt;

&lt;p&gt;So I structure the request as: cached system prompt, then cached repo context, then the fresh diff last. Anthropic-style caching keys on a stable prefix, so the ordering matters. Stable stuff first, volatile stuff last.&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="c1"&gt;# pseudocode, provider-agnostic shape
&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;big-model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;REVIEW_RUBRIC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;# never changes
&lt;/span&gt;            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_control&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ephemeral&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;repo_context_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;# changes rarely
&lt;/span&gt;            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_control&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ephemeral&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;build_review_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;  &lt;span class="c1"&gt;# changes every time
&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 rubric and the repo context ride the cache. Only the diff is fresh each run. On a repo where lots of small PRs hit the same modules, the cache hit rate is high and the per-review cost of the big model drops to mostly the fresh tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  The GitHub Actions sketch
&lt;/h2&gt;

&lt;p&gt;Wiring it together is not exotic. One job, two steps, conditional escalation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ai-review&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pull_request&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;review&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683&lt;/span&gt;  &lt;span class="c1"&gt;# v4.2.2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
          &lt;span class="na"&gt;persist-credentials&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Triage (cheap model)&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;triage&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;git diff origin/${{ github.base_ref }}...HEAD &amp;gt; diff.patch&lt;/span&gt;
          &lt;span class="s"&gt;python scripts/triage.py diff.patch &amp;gt; triage.json&lt;/span&gt;
          &lt;span class="s"&gt;echo "risk=$(jq -r .risk triage.json)" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deep review (big model, cached)&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.triage.outputs.risk == 'high'&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;MODEL_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.MODEL_API_KEY }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python scripts/deep_review.py diff.patch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;if:&lt;/code&gt; on the second step is the whole cost story. Low-risk PRs never reach the expensive model. High-risk ones get the full treatment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catch that diff-only review misses
&lt;/h2&gt;

&lt;p&gt;Here is the part people get wrong, and it is the reason a naive AI reviewer gives you false confidence. If you only send the diff, the model cannot see cross-file bugs.&lt;/p&gt;

&lt;p&gt;Say a PR changes a function's return type or the meaning of one of its parameters. The diff looks locally fine. The reviewer, seeing only those lines, approves it. But three other files call that function and now pass the old assumptions, and those files are not in the diff at all. The bug is real, it is severe, and diff-only review is structurally blind to it.&lt;/p&gt;

&lt;p&gt;The cheap fix is to include the callers of changed functions in the context you send. You do not need to send the whole repo. You need the changed symbols plus everyone who touches them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# find who calls the functions changed in this PR&lt;/span&gt;
&lt;span class="nv"&gt;changed_funcs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git diff origin/main...HEAD &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oP&lt;/span&gt; &lt;span class="s1"&gt;'^\+.*function \K\w+'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;fn &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$changed_funcs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rl&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$fn&lt;/span&gt;&lt;span class="s2"&gt;("&lt;/span&gt; src/ &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; caller_files.txt
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; caller_files.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You feed those caller files into the tier-two context (the part that gets cached, since callers change slowly). Now when a change alters an interface, the model can see the code that relied on the old behavior and flag the mismatch. That single addition catches a class of bug that pure diff review will never see, and because callers are stable, they cost almost nothing after the first cached run.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this actually buys you
&lt;/h2&gt;

&lt;p&gt;The economics come from routing, not from a cheaper model doing the hard job. The small model is a filter, and filtering is cheap and easy. The big model does the real work, but only on the small slice that deserves it, and it pays full token price only on the changing diff while the rubric and context ride the cache.&lt;/p&gt;

&lt;p&gt;I use pieces of this in how spectr-ai reasons about cost too: a fast local pass to decide what is worth a careful, expensive look, then the expensive look only where it counts. Running qwen2.5-coder locally for the triage tier means that stage is effectively free on hardware I already own.&lt;/p&gt;

&lt;p&gt;The failure mode to avoid is treating the cheap tier as the reviewer. It is not. It is the bouncer. If your triage model starts writing review comments, you have given a hard job to the wrong tool. Keep tier one narrow, keep tier two well-fed with callers and cached context, and the bill stays small while the catches stay real.&lt;/p&gt;

&lt;p&gt;If you run AI review in CI, are you sending bare diffs, or do you pull in the callers of what changed?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>ci</category>
      <category>devops</category>
      <category>github</category>
    </item>
    <item>
      <title>Run Qwen Coder &amp; DeepSeek Locally: The 2026 Free AI Pair-Programmer Setup</title>
      <dc:creator>Pavel Espitia</dc:creator>
      <pubDate>Thu, 16 Jul 2026 15:42:24 +0000</pubDate>
      <link>https://dev.to/pavelespitia/run-qwen-coder-deepseek-locally-the-2026-free-ai-pair-programmer-setup-jo4</link>
      <guid>https://dev.to/pavelespitia/run-qwen-coder-deepseek-locally-the-2026-free-ai-pair-programmer-setup-jo4</guid>
      <description>&lt;p&gt;You're paying $10 to $20 a month for Copilot. You don't have to. A 2024-era laptop can run a coding model good enough for autocomplete, refactors, and "explain this function" entirely offline. No API key, no telemetry, no per-token bill. Here's the exact 2026 setup I run on a 16GB machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why local in 2026
&lt;/h2&gt;

&lt;p&gt;Two years ago, local coding models were a toy. The autocomplete was slow and the suggestions were noise. That changed. &lt;code&gt;qwen2.5-coder&lt;/code&gt; and &lt;code&gt;deepseek-coder-v2&lt;/code&gt; are genuinely useful now, and the tooling caught up: Ollama serves them, Continue.dev wires them into your editor, and the whole thing runs on hardware you already own.&lt;/p&gt;

&lt;p&gt;The pitch is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free. No subscription, no usage caps.&lt;/li&gt;
&lt;li&gt;Private. Your proprietary code never leaves the machine. This matters if you work on smart contracts or anything under NDA.&lt;/li&gt;
&lt;li&gt;Offline. Works on a plane, in a basement, behind a corporate firewall.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff is quality and latency. We'll be honest about both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick a model (and match it to your RAM)
&lt;/h2&gt;

&lt;p&gt;This is the decision that makes or breaks the experience. Pick a model your machine can actually hold in memory, or it spills to disk and crawls.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Fast, fits anywhere (8GB+)&lt;/span&gt;
ollama pull qwen2.5-coder:1.5b    &lt;span class="c"&gt;# ~1.0GB&lt;/span&gt;
ollama pull qwen2.5-coder:3b      &lt;span class="c"&gt;# ~1.9GB&lt;/span&gt;

&lt;span class="c"&gt;# The sweet spot for most laptops (16GB)&lt;/span&gt;
ollama pull qwen2.5-coder:7b      &lt;span class="c"&gt;# ~4.7GB&lt;/span&gt;

&lt;span class="c"&gt;# Quality tier, needs headroom (32GB+ comfortable)&lt;/span&gt;
ollama pull deepseek-coder-v2     &lt;span class="c"&gt;# ~8.9GB (16b MoE)&lt;/span&gt;
ollama pull qwen2.5-coder:14b     &lt;span class="c"&gt;# ~9.0GB&lt;/span&gt;
ollama pull qwen2.5-coder:32b     &lt;span class="c"&gt;# ~20GB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rough rule: the model file size is the floor, then add a few GB for context and the OS. A 4.7GB model on a 16GB machine is comfortable. A 20GB model on the same machine is not.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;RAM I'd want&lt;/th&gt;
&lt;th&gt;Use it for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen2.5-coder:1.5b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.0GB&lt;/td&gt;
&lt;td&gt;8GB&lt;/td&gt;
&lt;td&gt;Autocomplete, fast iteration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen2.5-coder:7b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4.7GB&lt;/td&gt;
&lt;td&gt;16GB&lt;/td&gt;
&lt;td&gt;Daily driver: chat, refactors, explain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;deepseek-coder-v2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8.9GB&lt;/td&gt;
&lt;td&gt;32GB&lt;/td&gt;
&lt;td&gt;Harder reasoning, multi-file context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qwen2.5-coder:32b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;20GB&lt;/td&gt;
&lt;td&gt;64GB&lt;/td&gt;
&lt;td&gt;Near-cloud quality, if you have the RAM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;deepseek-coder-v2&lt;/code&gt; is a 16b mixture-of-experts model, so it punches above what its file size suggests, only a couple billion parameters are active per token. It's the one I reach for when &lt;code&gt;qwen2.5-coder:7b&lt;/code&gt; gives a shallow answer.&lt;/p&gt;

&lt;p&gt;A note on quantization: those file sizes are the default 4-bit quants Ollama ships. They're the right call for a laptop. You can pull a higher-precision tag like &lt;code&gt;qwen2.5-coder:7b-instruct-q8_0&lt;/code&gt; for slightly better output, but it roughly doubles the memory and the speed cost, and on everyday coding tasks I can't tell the difference. Start with the defaults.&lt;/p&gt;

&lt;p&gt;My actual setup on 16GB: &lt;code&gt;qwen2.5-coder:1.5b&lt;/code&gt; for inline autocomplete (it has to be fast or it's useless), &lt;code&gt;qwen2.5-coder:7b&lt;/code&gt; for the chat sidebar where I can wait two seconds. I keep &lt;code&gt;deepseek-coder-v2&lt;/code&gt; pulled but unloaded for the occasional gnarly problem, and let Ollama swap it in on demand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install and run Ollama
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Linux / WSL&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://ollama.com/install.sh | sh

&lt;span class="c"&gt;# macOS&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;ollama

&lt;span class="c"&gt;# Windows: download from https://ollama.com/download&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start the server. It listens on &lt;code&gt;localhost:11434&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama serve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm it's alive and a model responds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama run qwen2.5-coder:7b &lt;span class="s2"&gt;"Write a TypeScript debounce function"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that prints code, you have a working local LLM. Everything else is wiring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire it into your editor with Continue.dev
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://continue.dev" rel="noopener noreferrer"&gt;Continue.dev&lt;/a&gt; is the open-source extension that turns Ollama into an editor assistant. It does chat, inline edits (highlight code, Cmd/Ctrl+I, describe the change), and tab autocomplete. Install it from the VS Code or JetBrains marketplace, then point it at your local models.&lt;/p&gt;

&lt;p&gt;Edit &lt;code&gt;~/.continue/config.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Local pair programmer&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.0.0&lt;/span&gt;
&lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;

&lt;span class="na"&gt;models&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Qwen Coder 7B&lt;/span&gt;
    &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ollama&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;qwen2.5-coder:7b&lt;/span&gt;
    &lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;chat&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;edit&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Qwen Coder 1.5B (autocomplete)&lt;/span&gt;
    &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ollama&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;qwen2.5-coder:1.5b&lt;/span&gt;
    &lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;autocomplete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two models, two jobs. The 1.5b handles the tight feedback loop of tab autocomplete where every millisecond shows. The 7b handles chat and multi-line edits where you'll tolerate a short wait for a better answer.&lt;/p&gt;

&lt;p&gt;Restart VS Code, open the Continue sidebar, and ask it something about the file you have open. It reads your editor context and answers against your actual code, locally.&lt;/p&gt;

&lt;p&gt;If you'd rather skip Continue and use the official Copilot-style hook, recent VS Code versions let you add Ollama as a custom model provider in the chat panel pointing at &lt;code&gt;http://localhost:11434&lt;/code&gt;. Continue is still the more flexible option for autocomplete tuning.&lt;/p&gt;

&lt;p&gt;One thing worth knowing: autocomplete and chat use different prompt formats under the hood. Continue handles this for you when you assign the &lt;code&gt;autocomplete&lt;/code&gt; role, picking the fill-in-the-middle template the Qwen Coder models were trained on. If your inline suggestions come out garbled, it's almost always because a chat-only model got assigned the autocomplete role. The &lt;code&gt;qwen2.5-coder&lt;/code&gt; family supports fill-in-the-middle at every size, which is why I use it for both jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30-second sanity check with fetch
&lt;/h2&gt;

&lt;p&gt;Before trusting any editor integration, hit the server directly. Ollama exposes an OpenAI-compatible endpoint, so this works with zero SDK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:11434/v1/chat/completions&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="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;qwen2.5-coder:7b&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You are a senior TypeScript reviewer.&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="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Find the bug:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;function sum(a, b) { return a - b }&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="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&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;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No API key. No account. If this returns "it subtracts instead of adds," your local pair programmer is online and you can build anything on top of it.&lt;/p&gt;

&lt;p&gt;For a streaming UI (the token-by-token effect), flip &lt;code&gt;stream: true&lt;/code&gt; and read the response body as a stream. Same endpoint shape as OpenAI, so any client library that targets OpenAI works by just changing the base URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Latency: what to actually expect
&lt;/h2&gt;

&lt;p&gt;Numbers depend entirely on your hardware, so here's what I see on my machine (16GB RAM, integrated GPU, WSL2 on Windows) rather than invented benchmarks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;First call after startup is slow.&lt;/strong&gt; The model loads into memory once. On &lt;code&gt;qwen2.5-coder:7b&lt;/code&gt; that's a few seconds. After that it stays warm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autocomplete with &lt;code&gt;1.5b&lt;/code&gt; feels instant enough&lt;/strong&gt; to leave on. That's the whole reason to use the small model for that role.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chat with &lt;code&gt;7b&lt;/code&gt;&lt;/strong&gt; starts streaming in roughly a second or two and reads at a comfortable pace. Long multi-file prompts are slower.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real GPU changes everything.&lt;/strong&gt; On a machine with a discrete NVIDIA card, the same models run several times faster and the bigger models become practical. On pure CPU, stick to &lt;code&gt;1.5b&lt;/code&gt; and &lt;code&gt;3b&lt;/code&gt; or you'll be waiting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tip: keep &lt;code&gt;ollama serve&lt;/code&gt; running in the background all day. Don't start and stop it per request, you pay the load cost every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance tips
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One small model for autocomplete, one bigger for chat.&lt;/strong&gt; Don't make &lt;code&gt;deepseek-coder-v2&lt;/code&gt; do tab completion, the latency kills the flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;temperature: 0&lt;/code&gt;&lt;/strong&gt; for code. You want deterministic, not creative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match the model to RAM.&lt;/strong&gt; If Ollama is swapping to disk you'll feel it instantly. Drop a size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trim the context window&lt;/strong&gt; if you don't need 32k tokens. Smaller context means less memory and faster responses. Set it in the model's Continue config or with a custom &lt;code&gt;Modelfile&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warm the model on login.&lt;/strong&gt; A throwaway &lt;code&gt;ollama run qwen2.5-coder:7b ""&lt;/code&gt; at startup preloads it so your first real prompt isn't the slow one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate any structured output.&lt;/strong&gt; Smaller models occasionally botch JSON. Parse with Zod and retry on failure.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  When local is good enough (and when it isn't)
&lt;/h2&gt;

&lt;p&gt;I use local models for most of the day and reach for Claude only when the problem is genuinely hard. Here's the honest split:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Local (&lt;code&gt;7b&lt;/code&gt; / &lt;code&gt;deepseek&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Reach for cloud&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Inline autocomplete&lt;/td&gt;
&lt;td&gt;Great&lt;/td&gt;
&lt;td&gt;Overkill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Explain this function"&lt;/td&gt;
&lt;td&gt;Great&lt;/td&gt;
&lt;td&gt;No need&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Boilerplate, tests, docstrings&lt;/td&gt;
&lt;td&gt;Great&lt;/td&gt;
&lt;td&gt;No need&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refactor within one file&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Marginal gain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-file architecture reasoning&lt;/td&gt;
&lt;td&gt;Hit or miss&lt;/td&gt;
&lt;td&gt;Better&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subtle security review&lt;/td&gt;
&lt;td&gt;Use as first pass&lt;/td&gt;
&lt;td&gt;Better&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latest framework APIs (2026)&lt;/td&gt;
&lt;td&gt;Stale&lt;/td&gt;
&lt;td&gt;Better&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cloud still wins on hard reasoning and on knowledge of the newest APIs. But for the volume of small, repetitive coding questions that make up most of a day, local is not "good enough as a fallback," it's just good enough. The quality gap that existed in 2024 has mostly closed for everyday work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I run this on
&lt;/h2&gt;

&lt;p&gt;When I built &lt;a href="https://github.com/pavelEspitia/spectr-ai" rel="noopener noreferrer"&gt;spectr-ai&lt;/a&gt;, my AI smart-contract auditor, I made the engine provider-agnostic for exactly this reason. The same analysis runs against Claude or against Ollama:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Local, free, no API key, contract never leaves the machine&lt;/span&gt;
pnpm &lt;span class="nt"&gt;--filter&lt;/span&gt; @spectr-ai/engine dev &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--model&lt;/span&gt; ollama:qwen2.5-coder:1.5b examples/vulnerable.sol
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For smart-contract work, that "never leaves the machine" part isn't a nice-to-have. Audit clients don't want their unreleased code shipped to a third-party API. Local models make a privacy-preserving first pass possible, then I escalate the interesting findings to Claude.&lt;/p&gt;

&lt;p&gt;Get &lt;code&gt;qwen2.5-coder:7b&lt;/code&gt; running, wire up Continue, and use it for a week before you renew any Copilot subscription. The setup costs you twenty minutes and zero dollars a month after that.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>ollama</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Fable 5 Just Shipped: What Anthropic's Newest Model Means for Developers</title>
      <dc:creator>Pavel Espitia</dc:creator>
      <pubDate>Wed, 15 Jul 2026 15:40:21 +0000</pubDate>
      <link>https://dev.to/pavelespitia/fable-5-just-shipped-what-anthropics-newest-model-means-for-developers-3cc0</link>
      <guid>https://dev.to/pavelespitia/fable-5-just-shipped-what-anthropics-newest-model-means-for-developers-3cc0</guid>
      <description>&lt;p&gt;On June 9, 2026, Anthropic shipped Claude Fable 5, a model in a new tier that sits above Opus. I have been building on the Claude API for over a year, and this is the first release that made me stop and re-read my whole prompt stack before touching the model string. Here is what actually changed and what it means if you ship software.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Fable 5 is the public release of the Mythos line, the family that earlier in the year unsettled the security world with how well it found and exploited vulnerabilities. The version you and I get is the same underlying model with safeguards bolted on. Anthropic calls the safe one Fable and the unrestricted one Mythos, and only a small group of cyberdefenders gets Mythos.&lt;/p&gt;

&lt;p&gt;The numbers, for context:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1M token context window, 128K max output, knowledge cutoff January 2026.&lt;/li&gt;
&lt;li&gt;Priced at $10 per million input tokens and $50 per million output. That is double Opus 4.8 ($5 / $25).&lt;/li&gt;
&lt;li&gt;State of the art on nearly every benchmark they tested: 95% SWE-bench Verified, 80% SWE-bench Pro.&lt;/li&gt;
&lt;li&gt;Adaptive thinking is always on. There is no "disabled" mode.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point matters more than the benchmarks. You do not tune a thinking budget anymore. The model decides.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pricing reframes the decision
&lt;/h2&gt;

&lt;p&gt;At $10/$50, Fable 5 is not your default model. It is your "this task is hard and getting it wrong is expensive" model. Opus 4.8 at $5/$25 remains the workhorse for most application traffic, and Haiku 4.5 at $1/$5 still wins on classification and routing.&lt;/p&gt;

&lt;p&gt;The way I think about it now is a three-tier ladder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Haiku 4.5   →  routing, classification, cheap extraction
Opus 4.8    →  default for app traffic, agentic loops, coding
Fable 5     →  long-horizon agentic work where correctness pays for itself
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "longer and more complex the task, the larger Fable's lead" framing from the announcement is the actual buying signal. A one-shot summarization does not justify 2x the cost. A multi-hour autonomous refactor that would otherwise need human correction might.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API surface is the same as Opus 4.7/4.8, with one catch
&lt;/h2&gt;

&lt;p&gt;If your code already runs on Opus 4.7 or 4.8, moving to Fable 5 is mostly a model-string swap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-fable-5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;thinking&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;adaptive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;output_config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;effort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch: on Fable 5, an explicit &lt;code&gt;thinking: { type: "disabled" }&lt;/code&gt; returns a 400. On Opus 4.8 you can disable thinking. On Fable 5 you cannot, so just omit the param if you do not want to set it. Sampling params (&lt;code&gt;temperature&lt;/code&gt;, &lt;code&gt;top_p&lt;/code&gt;, &lt;code&gt;top_k&lt;/code&gt;) are gone too, same as the rest of the 4.7+ family. If you still pass them, you get a 400.&lt;/p&gt;

&lt;p&gt;And remember to stream anything with a high &lt;code&gt;max_tokens&lt;/code&gt;. 128K output through a non-streaming request will hit SDK HTTP timeouts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The safeguards are a real product decision, not marketing
&lt;/h2&gt;

&lt;p&gt;Here is the part that I find genuinely interesting as someone who works in security. Fable 5 has hard safeguards in cybersecurity, biology, chemistry, and health. If you ask it something high-risk in those areas, the request does not just refuse. It falls back to Opus 4.8 to answer safely.&lt;/p&gt;

&lt;p&gt;So if you run a security tool on Fable 5 and feed it something that trips a safeguard, you are silently getting Opus 4.8 output for that request. For my smart-contract auditing work, that means I cannot assume Fable-tier reasoning on every prompt. Some auditing prompts that look adversarial may quietly downgrade. I now log &lt;code&gt;response.model&lt;/code&gt; on every call to know which model actually answered.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&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;used&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="s2"&gt;claude-fable-5&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;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-fable-5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;served&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;model fell back&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I am actually doing about it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;I left my default on Opus 4.8. Most of what I ship does not need Fable.&lt;/li&gt;
&lt;li&gt;I added Fable 5 as an opt-in tier for the hardest auditing passes in spectr-ai, gated behind a config flag, with cost logging.&lt;/li&gt;
&lt;li&gt;I am watching the fallback behavior closely. A security model that silently downgrades on the exact prompts I care about is a sharp edge, not a footnote.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The headline is "most capable model ever released." The practical reality is more nuanced: a powerful, expensive tier with guardrails that change behavior on the prompts security people send most. Read the model field. Log it. Do not assume.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>security</category>
    </item>
    <item>
      <title>Adaptive Thinking Killed My Token Budget Code: Migrating Off budget_tokens</title>
      <dc:creator>Pavel Espitia</dc:creator>
      <pubDate>Tue, 14 Jul 2026 15:36:52 +0000</pubDate>
      <link>https://dev.to/pavelespitia/adaptive-thinking-killed-my-token-budget-code-migrating-off-budgettokens-2f49</link>
      <guid>https://dev.to/pavelespitia/adaptive-thinking-killed-my-token-budget-code-migrating-off-budgettokens-2f49</guid>
      <description>&lt;p&gt;I had a tidy little helper that computed a thinking budget based on input size. Something like "give the model 30% of the context as thinking room." It worked great on Opus 4.5. Then I tried to point it at Opus 4.8 and got a 400. The whole concept I had built around is gone in the current models. Here is what replaced it and how I migrated.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;p&gt;The old pattern looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Opus 4.5 and earlier&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-opus-4-5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;thinking&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;enabled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;budget_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;messages&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;On Opus 4.7, 4.8, and Fable 5, &lt;code&gt;thinking: { type: "enabled", budget_tokens: N }&lt;/code&gt; returns a 400. The fixed token budget is dead. The replacement is adaptive thinking, where the model decides how much to think, plus an &lt;code&gt;effort&lt;/code&gt; knob that controls overall token spend.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Opus 4.8&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-opus-4-8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;thinking&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;adaptive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;output_config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;effort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// low | medium | high | xhigh | max&lt;/span&gt;
  &lt;span class="nx"&gt;messages&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;h2&gt;
  
  
  Why this is actually better (after I got over it)
&lt;/h2&gt;

&lt;p&gt;My old budget code was a guess dressed up as a calculation. I had no real basis for "30% of context." I picked it because it felt reasonable and the outputs looked fine. Adaptive thinking moves that decision to the model, which sees the actual problem.&lt;/p&gt;

&lt;p&gt;The mental model shift: &lt;code&gt;budget_tokens&lt;/code&gt; controlled how much the model could think. &lt;code&gt;effort&lt;/code&gt; controls how much it thinks &lt;em&gt;and acts&lt;/em&gt;. They are not the same axis, so there is no clean 1:1 mapping. I stopped trying to translate "8000 tokens" into an effort level and instead picked based on the workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I chose effort levels
&lt;/h2&gt;

&lt;p&gt;After running my own evals, here is where I landed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;Effort&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Classification, routing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fast, scoped, not intelligence-sensitive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Most app traffic&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;medium&lt;/code&gt; to &lt;code&gt;high&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The balance point&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coding and agentic loops&lt;/td&gt;
&lt;td&gt;&lt;code&gt;xhigh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Best for these; it is the Claude Code default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Correctness-critical, latency-insensitive&lt;/td&gt;
&lt;td&gt;&lt;code&gt;max&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When being wrong costs more than tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One thing that surprised me: higher effort up front often &lt;em&gt;reduced&lt;/em&gt; total cost on agentic work because the model planned better and took fewer turns. I had assumed &lt;code&gt;max&lt;/code&gt; always meant more tokens. On multi-step tasks, it sometimes meant fewer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration checklist I actually used
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Grep for &lt;code&gt;budget_tokens&lt;/code&gt; across the codebase.&lt;/li&gt;
&lt;li&gt;Replace &lt;code&gt;thinking: { type: "enabled", budget_tokens: N }&lt;/code&gt; with &lt;code&gt;thinking: { type: "adaptive" }&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;output_config: { effort: "..." }&lt;/code&gt; and pick a level per call site, not one global value.&lt;/li&gt;
&lt;li&gt;Delete the budget-calculation helper entirely. It was dead weight.&lt;/li&gt;
&lt;li&gt;Strip any &lt;code&gt;temperature&lt;/code&gt; / &lt;code&gt;top_p&lt;/code&gt; / &lt;code&gt;top_k&lt;/code&gt; params (those also 400 on 4.7+).&lt;/li&gt;
&lt;li&gt;Run one test request per model and assert on &lt;code&gt;response.model&lt;/code&gt;.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-opus-4-8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;thinking&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;adaptive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ping&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&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="s2"&gt;claude-opus-4-8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  One gotcha with thinking display
&lt;/h2&gt;

&lt;p&gt;On Opus 4.7+ and Fable 5, thinking blocks still stream but their text is empty by default. If you were rendering reasoning to a UI, you now see a long pause instead of progress. Opt back in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;thinking&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;adaptive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;summarized&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I missed this for an afternoon and thought streaming was broken. It was just the new default (&lt;code&gt;omitted&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;I built abstraction on top of a parameter that the platform later removed. That is the risk of wrapping a vendor knob in your own logic before you understand whether the knob is fundamental or incidental. &lt;code&gt;budget_tokens&lt;/code&gt; was incidental. The fundamental thing was "let the model think when it helps," and adaptive thinking expresses that directly. Less of my code, more of theirs, and the outputs got better. I will take that trade.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>typescript</category>
      <category>tutorial</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The $292M KelpDAO Bridge Hack: Why the Audit Wasn't the Problem</title>
      <dc:creator>Pavel Espitia</dc:creator>
      <pubDate>Mon, 13 Jul 2026 16:39:57 +0000</pubDate>
      <link>https://dev.to/pavelespitia/the-292m-kelpdao-bridge-hack-why-the-audit-wasnt-the-problem-3md9</link>
      <guid>https://dev.to/pavelespitia/the-292m-kelpdao-bridge-hack-why-the-audit-wasnt-the-problem-3md9</guid>
      <description>&lt;p&gt;On April 18, 2026, attackers drained about 116,500 rsETH, roughly $292 million, from a cross-chain bridge KelpDAO built on LayerZero. It is the largest DeFi hack of the year so far. I spend my days finding bugs in smart contracts, and the most uncomfortable thing about this hack is that finding bugs would not have stopped it. Here is the post-mortem and what it should change about how you think about security.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;

&lt;p&gt;The bridge held the reserve of rsETH backing the token across more than 20 chains: Base, Arbitrum, Linea, Blast, Mantle, Scroll, and others. When the reserve drained, every wrapped version downstream was suddenly under-collateralized.&lt;/p&gt;

&lt;p&gt;The root cause, per LayerZero's post-mortem, was not a Solidity bug. The attack began on March 6, six weeks earlier, when a developer was socially engineered. The contract code did what it was written to do. The keys that controlled it ended up in the wrong hands.&lt;/p&gt;

&lt;p&gt;KelpDAO froze the system about 46 minutes after the drain. The attacker tried twice more to take another $100 million and failed because of that freeze. The 46 minutes is the one genuinely good part of this story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern is the story, not the protocol
&lt;/h2&gt;

&lt;p&gt;KelpDAO is not an outlier in 2026. Two weeks earlier, Drift Protocol lost $285 million on Solana. That post-mortem also found no code exploit. It was a six-month social engineering operation against the people who held the admin keys. Drift's TVL fell from $550 million to under $300 million in an hour.&lt;/p&gt;

&lt;p&gt;Put the two together and the theme is unmissable: the most expensive failures of 2026 are not bugs. They are humans being patiently targeted. Security firms attribute 76% of crypto hack losses this year to North Korea-linked actors, up from 64% in 2025. These are not smash-and-grab opportunists. They are state-backed teams running multi-month campaigns.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an audit covers, and what it does not
&lt;/h2&gt;

&lt;p&gt;I want to be precise here because "audits are useless" is the wrong takeaway. Audits are not useless. They are scoped.&lt;/p&gt;

&lt;p&gt;An audit answers: does this code do what it claims, and does it have known classes of vulnerabilities? Reentrancy, access control gaps, integer issues, oracle manipulation, logic flaws. I find these for a living and they still matter. Plenty of money is still lost to plain bugs.&lt;/p&gt;

&lt;p&gt;An audit does not answer: will the person holding the upgrade key get phished in March? Is the multisig actually multi-party, or are three of the five signers the same engineer on three laptops? Does the bridge operator have a key-rotation policy? Those are operational security questions, and the contract source code is silent on all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The questions I now ask alongside the code review
&lt;/h2&gt;

&lt;p&gt;When I look at a protocol now, I treat the contracts as one layer and the key custody as another. The code review is necessary. It is not sufficient. The questions that would have mattered for KelpDAO and Drift:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who can move the reserve, and how many independent humans must agree?&lt;/li&gt;
&lt;li&gt;Are admin keys in hardware wallets, in an HSM, or in a hot wallet a server can reach?&lt;/li&gt;
&lt;li&gt;Is there a time delay on privileged actions, so a drain is visible before it completes?&lt;/li&gt;
&lt;li&gt;Is there a circuit breaker, and who can pull it, and how fast? (KelpDAO's 46-minute freeze is the difference between $292M and $392M.)&lt;/li&gt;
&lt;li&gt;What is the social engineering surface? How many people could a patient attacker target?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those are answerable from the &lt;code&gt;.sol&lt;/code&gt; files. All of them mattered more than the &lt;code&gt;.sol&lt;/code&gt; files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves AI-assisted security
&lt;/h2&gt;

&lt;p&gt;I build AI tooling for contract analysis, so people ask whether AI helps here. Honestly, partially. AI is good at the layer audits already cover: reasoning about code, catching cross-function logic flaws, explaining what a privileged function can do. It does not phish-proof your team.&lt;/p&gt;

&lt;p&gt;But there is a second-order effect worth naming. Reports this year note that AI is lowering the bar for exploit discovery, with automated reconnaissance increasingly targeting old and unverified contracts. The same capability that helps me audit helps attackers scan. The defensive move is not to avoid AI. It is to use it on your own attack surface before someone else does, and to remember that the attack surface includes your people.&lt;/p&gt;

&lt;p&gt;The contracts were fine. The keys were not. Spend accordingly.&lt;/p&gt;

</description>
      <category>security</category>
      <category>blockchain</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>Hybrid Local + Cloud LLMs in 2026: When to Use Ollama and When to Pay for Fable</title>
      <dc:creator>Pavel Espitia</dc:creator>
      <pubDate>Sun, 12 Jul 2026 15:06:12 +0000</pubDate>
      <link>https://dev.to/pavelespitia/hybrid-local-cloud-llms-in-2026-when-to-use-ollama-and-when-to-pay-for-fable-4c1o</link>
      <guid>https://dev.to/pavelespitia/hybrid-local-cloud-llms-in-2026-when-to-use-ollama-and-when-to-pay-for-fable-4c1o</guid>
      <description>&lt;p&gt;I run a local model and I pay for cloud models, and the most common question I get is "which one should I use?" The honest answer is both, on the same task, at different stages. After a year of building tools that use Ollama and Claude together, here is the decision framework I actually apply, updated for the mid-2026 landscape where the top cloud tier now costs $50 per million output tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost gap got wider, which makes the question sharper
&lt;/h2&gt;

&lt;p&gt;In 2026 the cloud frontier spread out into a clear ladder:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Haiku 4.5: $1 / $5 per million tokens&lt;/li&gt;
&lt;li&gt;Opus 4.8: $5 / $25&lt;/li&gt;
&lt;li&gt;Fable 5: $10 / $50&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Local is $0 per token after you own the hardware. A 2024-era laptop runs &lt;code&gt;qwen2.5-coder:7b&lt;/code&gt; well enough for real work. The gap between "free and on my machine" and "$50 per million output tokens" is large enough that throwing every request at the frontier is a real waste of money.&lt;/p&gt;

&lt;p&gt;So the framework is not "local or cloud." It is "what is the cheapest tier that gets this specific step right?"&lt;/p&gt;

&lt;h2&gt;
  
  
  The three questions I ask per step
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Does this step leave my machine's privacy boundary?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If I am analyzing a client's unpublished contract or a private repo, that content does not go to a cloud API unless I have explicit permission. Local model, full stop. This is not a cost decision. It is a trust decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Is this step hard, or is it bulk?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Bulk work (chunking, first-pass extraction, classification, "is this file even relevant") goes local. Hard reasoning (the actual vulnerability analysis, the tricky cross-function logic, the final report) goes cloud. The 7B local model is genuinely fine at the bulk. It is not Fable 5 at the hard part, and pretending otherwise produces confident wrong answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. What does being wrong cost?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A wrong classification I can re-run for free. A wrong security finding in a report I hand to a client costs my reputation. The cost-of-error maps directly onto the tier. Cheap-to-fix errors go to cheap models.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like in code
&lt;/h2&gt;

&lt;p&gt;Here is the shape of a hybrid pipeline from one of my tools. Local model triages, cloud model does the heavy reasoning only on what survives triage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Ollama&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ollama&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Anthropic&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@anthropic-ai/sdk&lt;/span&gt;&lt;span class="dl"&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;local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Ollama&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;cloud&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;triage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;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;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;qwen2.5-coder:7b&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Does this contract have external calls, delegatecall, or asset transfers? Answer yes or no only.\n\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;file&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;return&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&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="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;yes&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;deepAudit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="nx"&gt;cloud&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-opus-4-8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;thinking&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;adaptive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;output_config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;effort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;xhigh&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Audit this contract:\n\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;file&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="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &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;f&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;files&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;await&lt;/span&gt; &lt;span class="nf"&gt;triage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;deepAudit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// pays for cloud only on interesting files&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="nx"&gt;results&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;If half my files are boilerplate that fails triage, I just halved my cloud bill, and the local triage cost me nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I reach for Fable 5 specifically
&lt;/h2&gt;

&lt;p&gt;Almost never by default. Opus 4.8 is my cloud workhorse. I escalate to Fable 5 only when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The task is long-horizon agentic work where one mistake cascades.&lt;/li&gt;
&lt;li&gt;I have already tried Opus 4.8 and it missed something I know matters.&lt;/li&gt;
&lt;li&gt;The correctness genuinely justifies double the cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a security report, the math can work, because a missed finding is far more expensive than the token difference. For "summarize this PR," it never works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The anti-pattern: frontier for everything
&lt;/h2&gt;

&lt;p&gt;The mistake I see most is reaching for the most capable model on every call because it "can't hurt." It can hurt. It hurts your latency (more thinking) and your bill (more dollars), and for bulk steps it gives you no measurable quality gain. The local 7B model answering "is this file relevant" in 200ms for free is the right tool. Save the $50-per-million tier for the moments that are actually hard.&lt;/p&gt;

&lt;p&gt;Local for privacy and bulk. Cloud for reasoning. Frontier only when being wrong is expensive. That is the whole framework.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>ollama</category>
      <category>productivity</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Writing Evals for an LLM Security Tool: How I Know It Didn't Get Worse</title>
      <dc:creator>Pavel Espitia</dc:creator>
      <pubDate>Sat, 11 Jul 2026 15:04:37 +0000</pubDate>
      <link>https://dev.to/pavelespitia/writing-evals-for-an-llm-security-tool-how-i-know-it-didnt-get-worse-53na</link>
      <guid>https://dev.to/pavelespitia/writing-evals-for-an-llm-security-tool-how-i-know-it-didnt-get-worse-53na</guid>
      <description>&lt;p&gt;Every time a new model ships, I face the same question for spectr-ai: does my contract auditor get better or worse on the new model? "Vibes" is not an answer when the tool tells people whether their code is safe. So I built evals. Here is how I test an LLM that produces fuzzy output, and why a handful of labeled examples beats a gut feeling every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with testing LLM output
&lt;/h2&gt;

&lt;p&gt;Unit tests assume deterministic output. LLM output is not deterministic, and even when it is correct it phrases things differently each run. You cannot assert &lt;code&gt;output === "reentrancy on line 12"&lt;/code&gt;. The model might say "the withdraw function is vulnerable to reentrancy" or "external call before state update in withdraw()."&lt;/p&gt;

&lt;p&gt;So I do not test the text. I test the &lt;em&gt;finding&lt;/em&gt;. Did the model identify the vulnerability class on the right function? That is a yes/no I can check, and it survives rephrasing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The eval set
&lt;/h2&gt;

&lt;p&gt;I built a directory of contracts where I know the ground truth, because I planted it or verified it by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;evals/
  cases/
    reentrancy-classic.sol        → expect: reentrancy in withdraw
    access-control-missing.sol    → expect: missing onlyOwner on setFee
    safe-checks-effects.sol       → expect: NO findings (clean contract)
    integer-edge.sol              → expect: division by zero in average
  expected.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;expected.json&lt;/code&gt; is the labeled ground truth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reentrancy-classic.sol"&lt;/span&gt;&lt;span class="p"&gt;:&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="nl"&gt;"class"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"reentrancy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"function"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"withdraw"&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="nl"&gt;"access-control-missing.sol"&lt;/span&gt;&lt;span class="p"&gt;:&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="nl"&gt;"class"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"access-control"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"function"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"setFee"&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="nl"&gt;"safe-checks-effects.sol"&lt;/span&gt;&lt;span class="p"&gt;:&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="nl"&gt;"integer-edge.sol"&lt;/span&gt;&lt;span class="p"&gt;:&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="nl"&gt;"class"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"division-by-zero"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"function"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"average"&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="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The clean contract is the most important case. A tool that flags everything has perfect recall and is useless. I need to know it stays quiet when the code is safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoring with precision and recall, not accuracy
&lt;/h2&gt;

&lt;p&gt;I score each run on two numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Recall&lt;/strong&gt;: of the real vulnerabilities, how many did the model find? Missing a bug is the dangerous failure for a security tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Precision&lt;/strong&gt;: of the things the model flagged, how many were real? Crying wolf trains users to ignore the tool.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;found&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Finding&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Finding&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Finding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Finding&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="err"&gt;=== &lt;/span&gt;&lt;span class="nc"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="err"&gt;&amp;amp;&amp;amp; &lt;/span&gt;&lt;span class="nc"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;function&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;truePositives&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;expected&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="nx"&gt;e&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;found&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&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;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&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;falsePositives&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;found&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="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&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;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;f&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;recall&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;truePositives&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;precision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;found&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;truePositives&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;found&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;recall&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;precision&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;I aggregate across all cases and report the mean. One number per model, comparable across runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The runner
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs/promises&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runEvals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&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;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;evals/expected.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&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;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;truth&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&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;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`evals/cases/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;file&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&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;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// your auditor call&lt;/span&gt;
    &lt;span class="nx"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;found&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;truth&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Finding&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;recall&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recall&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;precision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: recall=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;recall&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; precision=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now when Opus 4.8 or Fable 5 lands, I run the same suite against the new model string and compare. No vibes. Two numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the evals actually caught
&lt;/h2&gt;

&lt;p&gt;The eval set has earned its keep more than once. When I tested a newer model, recall &lt;em&gt;dropped&lt;/em&gt; on a case I expected it to nail. The model had found the bug but declined to report it because my prompt said "only report high-confidence issues." The newer model followed that instruction more literally than the old one did. The fix was a prompt change, not a model rollback, and I only knew to look because the number moved.&lt;/p&gt;

&lt;p&gt;That is the real value. The eval does not just tell me a model is worse. It tells me &lt;em&gt;where&lt;/em&gt;, so I can find out whether the cause is the model or my own prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start smaller than you think
&lt;/h2&gt;

&lt;p&gt;You do not need a thousand cases. I started with eight and it was already enough to catch a regression. Label what you have, score precision and recall, run it on every model change. The discipline of having ground truth at all is most of the win. The size of the set is a refinement you add later, when eight cases stop surprising you.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>testing</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
