<?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: Talha Anwar</title>
    <description>The latest articles on DEV Community by Talha Anwar (@ailegend).</description>
    <link>https://dev.to/ailegend</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%2F4067988%2F23197b0f-0d13-439f-8ceb-931a658a3403.png</url>
      <title>DEV Community: Talha Anwar</title>
      <link>https://dev.to/ailegend</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ailegend"/>
    <language>en</language>
    <item>
      <title>I stopped fixing my prompt by hand. Open models fixed it while I napped</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Sat, 24 Oct 2026 15:18:00 +0000</pubDate>
      <link>https://dev.to/ailegend/two-naps-zero-prompt-edits-a-4b-gemma-router-went-from-37-to-60-2h48</link>
      <guid>https://dev.to/ailegend/two-naps-zero-prompt-edits-a-4b-gemma-router-went-from-37-to-60-2h48</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week3-2026-10-19"&gt;Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Fixing a prompt by hand is a loop I hate. I rewrite it, test it by hand, redeploy, and find that I fixed two cases and broke a third. Then I do it again.&lt;/p&gt;

&lt;p&gt;So this week I wrote the laziest prompt I could for a bank's message router, and I never edited it again. Open-weight models rewrote it in two optimizer iterations. I checked every result on messages the models never saw.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Correct, parseable answers, Gemma 3 4B&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;37% → 60%&lt;/strong&gt; on 308 held-out messages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same prompt on Gemma 3 27B&lt;/td&gt;
&lt;td&gt;69% → 74%, without ever tuning for 27B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prompt text I edited by hand&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Models in the loop&lt;/td&gt;
&lt;td&gt;all open-weight: Gemma 3, Gemma 4, gpt-oss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost of the whole experiment&lt;/td&gt;
&lt;td&gt;$0.38&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnld4bu5z47lksvf61kaj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnld4bu5z47lksvf61kaj.png" alt="Bar chart: Gemma 3 4B scores 37%, 55% and 60% with prompt v1, v2 and v3; Gemma 3 27B scores 69%, 73% and 74%" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;PromptSloth is one Python file that runs a prompt-improvement loop on &lt;a href="https://acruxcore.com" rel="noopener noreferrer"&gt;AcruxCore&lt;/a&gt;, the open-source LLM-ops platform I work on. The script talks to AcruxCore only through its &lt;a href="https://acruxcore.com/features/mcp" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt;. MCP (Model Context Protocol) is an open JSON-RPC protocol that lets a client call a server's tools.&lt;/p&gt;

&lt;p&gt;The router's job is intent routing. A customer writes "I still have not received my new card", and the router must answer &lt;code&gt;card_arrival&lt;/code&gt;, not &lt;code&gt;card_delivery_estimate&lt;/code&gt;. The data is &lt;a href="https://huggingface.co/datasets/PolyAI/banking77" rel="noopener noreferrer"&gt;Banking77&lt;/a&gt; from PolyAI: 13,083 real banking messages with 77 intents, under CC-BY-4.0.&lt;/p&gt;

&lt;p&gt;The lazy prompt, v1, is two sentences: the list of 77 labels, then "Reply with the intent label only, nothing else."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8mebex7xph4obqh6q5h0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8mebex7xph4obqh6q5h0.png" alt="Diagram of one iteration: store the lazy prompt, load a training dataset, draft and judge rewrites, promote the winner; the router calls run_prompt with an alias" width="800" height="330"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The router never changes
&lt;/h3&gt;

&lt;p&gt;AcruxCore &lt;a href="https://acruxcore.com/features/prompts" rel="noopener noreferrer"&gt;stores each prompt as numbered versions&lt;/a&gt;, and an alias is a name that points at one version. My router asks for an alias, never a version number. Moving an alias changes the router's prompt without a code change or a redeploy. The aliases here are &lt;code&gt;lazy&lt;/code&gt; (v1), &lt;code&gt;fixed&lt;/code&gt; (v2) and &lt;code&gt;fixed2&lt;/code&gt; (v3).&lt;/p&gt;

&lt;h3&gt;
  
  
  Every model is open-weight
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Router&lt;/td&gt;
&lt;td&gt;Gemma 3 4B. Gemma 3 27B runs the finished prompts as a check.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Optimizer, which writes the rewrites&lt;/td&gt;
&lt;td&gt;gpt-oss-120b&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Judge, which scores them&lt;/td&gt;
&lt;td&gt;Gemma 4 31B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbapvwwwgzl7xfyui9p4g.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbapvwwwgzl7xfyui9p4g.gif" alt="Terminal recording: the second iteration's scores, then the lazy v1 and the v3 prompt on the same 24 held-out messages" width="600" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The recording shows three real commands. &lt;code&gt;report&lt;/code&gt; prints the second iteration's scores. Then &lt;code&gt;eval&lt;/code&gt; runs v1 and v3 on the same 24 held-out messages. With v1, most replies are wrapped in braces, and only 2 of 24 are both right and parseable. With v3, 15 of 24 are right, and every reply is a bare label.&lt;/p&gt;

&lt;p&gt;In the second iteration, two of the three rewrites scored below v2 on the judge's training-set score:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fixed (v2, baseline)  57.1
candidate-A           64.3  beats baseline: True   &amp;lt;- promoted as v3
candidate-B           50.6  beats baseline: False
candidate-C           55.8  beats baseline: False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;a href="https://acruxcore.com" rel="noopener noreferrer"&gt;AcruxCore&lt;/a&gt; did not promote B or C, so the router never used them.&lt;/p&gt;
&lt;h2&gt;
  
  
  What each iteration fixed
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flhzlhg1kk2nufwumgapd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flhzlhg1kk2nufwumgapd.png" alt="Stacked bars for Gemma 3 4B: v1 is 37% right and clean, 20% right with extra characters, 44% wrong; v2 is 55% right and 45% wrong; v3 is 60% right and 40% wrong" width="800" height="368"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Iteration 1 fixed the format
&lt;/h3&gt;

&lt;p&gt;With the lazy v1, the 4B model often wrapped its answer, as in &lt;code&gt;{card_arrival}&lt;/code&gt;. On 20% of all messages the label inside the braces was right, but a strict parser rejects the whole reply. The first rewrite told the model to "respond with only the exact label (no punctuation, no surrounding text)". The extra characters disappeared. The share of wrong labels stayed at about 44%, so iteration 1 did not improve the routing.&lt;/p&gt;
&lt;h3&gt;
  
  
  Iteration 2 fixed the routing
&lt;/h3&gt;

&lt;p&gt;For the second iteration I wrote a new rubric, the paragraph of instructions that a dataset gives the optimizer and the judge. It asked for a one-line definition of every intent, saying how the intent differs from its nearest neighbour. The winning rewrite added 77 lines like these:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;card_arrival = ordered card has not arrived yet.
card_delivery_estimate = user asks how long delivery will take before the card arrives.
activate_my_card = user wants to enable a newly received card.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The optimizer wrote those definitions from the 154 training examples. On held-out messages, wrong labels fell from 45% to 40%.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Held-out, 308 messages&lt;/th&gt;
&lt;th&gt;Gemma 3 4B&lt;/th&gt;
&lt;th&gt;Gemma 3 27B&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;v1 (&lt;code&gt;lazy&lt;/code&gt;): labels only&lt;/td&gt;
&lt;td&gt;37%&lt;/td&gt;
&lt;td&gt;69%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v2 (&lt;code&gt;fixed&lt;/code&gt;): after iteration 1&lt;/td&gt;
&lt;td&gt;55%&lt;/td&gt;
&lt;td&gt;73%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v3 (&lt;code&gt;fixed2&lt;/code&gt;): after iteration 2&lt;/td&gt;
&lt;td&gt;60%&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A reply counts as correct when it is the right label and nothing else. In every cell, the two runs are at most 3.2 points apart.&lt;/p&gt;
&lt;h2&gt;
  
  
  Is the gain real?
&lt;/h2&gt;
&lt;h3&gt;
  
  
  A paired test on every message
&lt;/h3&gt;

&lt;p&gt;I compared each pair of prompts message by message, with an exact McNemar test. That test looks only at the messages that one prompt got right and the other got wrong.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;4B, v1 → v3:&lt;/strong&gt; v3 got 88 messages right that v1 got wrong, and 16 wrong that v1 got right (p &amp;lt; 0.0001).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4B, v2 → v3, routing only:&lt;/strong&gt; v3 won 36 messages and lost 17 (p = 0.013). The definitions helped after the format was already fixed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;27B, v1 → v3:&lt;/strong&gt; v3 won 29 messages and lost 13 (p = 0.02). Both iterations scored rewrites only on the 4B model, so the 27B gain comes from a prompt that was never tuned for 27B.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  The judge agreed with the test it never saw
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F19oagh7o19fqa5xfsx0g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F19oagh7o19fqa5xfsx0g.png" alt="Line chart for Gemma 3 4B: the judge's training scores 36, 57 and 64 next to held-out exact match 37%, 55% and 60%" width="800" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The judge scored v1, v2 and v3 at 36, 57 and 64 on the training messages. Exact match on the held-out messages gave 37%, 55% and 60%. The judge never saw a held-out message, and the two measurements still rank the three versions the same way.&lt;/p&gt;

&lt;p&gt;The optimizer never sees a test message either. The 308 held-out messages come from Banking77's test split, and the 154 training messages come from its train split.&lt;/p&gt;
&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Only MCP, no SDK
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;promptsloth.py&lt;/code&gt; has six subcommands: &lt;code&gt;setup&lt;/code&gt;, &lt;code&gt;dataset&lt;/code&gt;, &lt;code&gt;optimize&lt;/code&gt;, &lt;code&gt;report&lt;/code&gt;, &lt;code&gt;promote&lt;/code&gt; and &lt;code&gt;eval&lt;/code&gt;. Every subcommand sends JSON-RPC to &lt;code&gt;POST /api/v1/mcp&lt;/code&gt; through &lt;code&gt;mcp.py&lt;/code&gt;, a 40-line client built on &lt;code&gt;requests&lt;/code&gt;. The &lt;a href="https://docs.acruxcore.com/docs/guides/connect-an-mcp-client" rel="noopener noreferrer"&gt;MCP guide&lt;/a&gt; shows the same server connected to Claude.&lt;/p&gt;
&lt;h3&gt;
  
  
  Four tool calls per iteration
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;create_prompt&lt;/code&gt; and &lt;code&gt;commit_prompt_version&lt;/code&gt; store the lazy v1.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;create_dataset&lt;/code&gt; and &lt;code&gt;add_dataset_example&lt;/code&gt; load 154 training messages, two per intent, with the rubric.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;optimize_prompt&lt;/code&gt; starts an iteration, and &lt;code&gt;get_optimize_report&lt;/code&gt; returns the scores. The optimizer drafts three rewrites, and the judge scores each rewrite and the current version on the training messages.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;promote_optimize_candidate&lt;/code&gt; commits the winner as a new version and moves an alias to it. &lt;a href="https://acruxcore.com" rel="noopener noreferrer"&gt;AcruxCore&lt;/a&gt; refuses a rewrite that did not beat the version it was compared with.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;a href="https://docs.acruxcore.com/docs/guides/improve-a-prompt-from-feedback" rel="noopener noreferrer"&gt;optimizer guide&lt;/a&gt; covers the same loop from the dashboard, and the &lt;a href="https://acruxcore.com/features/evaluation" rel="noopener noreferrer"&gt;evaluation page&lt;/a&gt; lists what else it scores.&lt;/p&gt;
&lt;h3&gt;
  
  
  Scoring without a judge
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;eval&lt;/code&gt; step calls &lt;code&gt;run_prompt&lt;/code&gt; once per held-out message at temperature 0 and compares the reply with the label in Python:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run_prompt&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;prompt&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;promptsloth-banking77&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;alias&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;fixed2&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;model&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;ps-gemma-3-4b-it&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;variables&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;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row&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;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;sample.py&lt;/code&gt; draws both sets from Banking77 with seed 42: two messages per intent from the train split and four per intent from the test split.&lt;/p&gt;
&lt;h3&gt;
  
  
  The one step outside MCP
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ps-gemma-3-4b-it&lt;/code&gt; is the name I gave Gemma 3 4B in AcruxCore's &lt;a href="https://acruxcore.com/features/gateway" rel="noopener noreferrer"&gt;gateway&lt;/a&gt;, the component that routes every model call to a provider and records its cost. The MCP server has no tool to register a model, so I made one REST call per model.&lt;/p&gt;
&lt;h3&gt;
  
  
  What went wrong
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;I first included a third size, Gemma 3 12B. One upstream provider returned empty replies for it, and one run needed 1,504 retries. Its scores changed with the provider that answered, so I removed 12B from the results. The script now retries an empty reply and counts each retry.&lt;/li&gt;
&lt;li&gt;The lazy prompt's wrapped replies hid what each iteration changed. The script now reports two scores: routing only, with the wrapping removed, and routing plus a clean reply. The routing-only score showed that iteration 1 fixed the format and iteration 2 fixed the routing.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The Lazy Payoff
&lt;/h2&gt;

&lt;p&gt;My own writing was a prompt of two sentences and two rubric paragraphs. The optimizer drafted six rewrites, the judge scored 1,232 answers (4 versions × 154 messages × 2 iterations), and &lt;a href="https://acruxcore.com" rel="noopener noreferrer"&gt;AcruxCore&lt;/a&gt; refused the two rewrites that scored below v2.&lt;/p&gt;

&lt;p&gt;The gateway's usage report (&lt;code&gt;get_gateway_usage&lt;/code&gt;) puts the whole day at &lt;strong&gt;$0.38 for 11,660 model calls&lt;/strong&gt;. That total covers both iterations, every held-out run, the demo and the wasted 12B retries. Each v3 request is longer than a v1 request, about 1,380 tokens against 575. So the 4B router costs $0.046 per 1,000 messages with v3, against $0.018 with v1.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;
&lt;h3&gt;
  
  
  The router can leave the cloud
&lt;/h3&gt;

&lt;p&gt;Gemma 3 4B is small enough to run on a laptop. I ran it through OpenRouter for this measurement, not on my own machine. The accuracy is still the accuracy of the same weights you can download and host.&lt;/p&gt;
&lt;h3&gt;
  
  
  I could inspect every layer
&lt;/h3&gt;

&lt;p&gt;When the 12B scores jumped between runs, the traces showed empty replies from one provider. When iteration 1's gain looked large, the raw replies showed braces, not better routing. A closed optimizer that returns only a score would have shown neither.&lt;/p&gt;
&lt;h3&gt;
  
  
  Anyone can repeat it
&lt;/h3&gt;

&lt;p&gt;Banking77 is public, and the sampling uses a fixed seed. &lt;a href="https://acruxcore.com" rel="noopener noreferrer"&gt;AcruxCore&lt;/a&gt; is Apache-2.0 and self-hostable, with its &lt;a href="https://github.com/AcruxCore/AcruxCore" rel="noopener noreferrer"&gt;source on GitHub&lt;/a&gt;, and every model is open-weight.&lt;/p&gt;
&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;With v3, the 4B model (60%) still scores below the 27B model with the lazy v1 (69%).&lt;/li&gt;
&lt;li&gt;Banking77 is public, so the models may have seen it during training.&lt;/li&gt;
&lt;li&gt;Each cell has two runs. Even at temperature 0, two runs differed by up to 3.2 points.&lt;/li&gt;
&lt;li&gt;I wrote both rubrics. The first one named a few example confusions, such as card arrival against card activation.&lt;/li&gt;
&lt;li&gt;I tested one task. A task with fewer and more distinct labels may gain less from definitions.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/talhaanwarch" rel="noopener noreferrer"&gt;
        talhaanwarch
      &lt;/a&gt; / &lt;a href="https://github.com/talhaanwarch/promptsloth" rel="noopener noreferrer"&gt;
        promptsloth
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A lazy prompt fixes itself on open-weight models (Gemma 3, gpt-oss) over AcruxCore's MCP server. Banking77, held-out exact-match test.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;PromptSloth 🦥&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;A lazy prompt that fixes itself on open-weight models, driven entirely over &lt;a href="https://github.com/AcruxCore/AcruxCore" rel="noopener noreferrer"&gt;AcruxCore&lt;/a&gt;'s MCP server.&lt;/p&gt;

&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/talhaanwarch/promptsloth/images/heldout-accuracy-v2.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Ftalhaanwarch%2Fpromptsloth%2FHEAD%2Fimages%2Fheldout-accuracy-v2.png" alt="Held-out accuracy by prompt version"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The router reads a bank customer's message and returns one of the 77 &lt;a href="https://huggingface.co/datasets/PolyAI/banking77" rel="nofollow noopener noreferrer"&gt;Banking77&lt;/a&gt; intents. Version 1 of its prompt is the label list and one instruction. Two optimizer iterations rewrote it, and an open judge decided each promotion:&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;table&gt;

&lt;thead&gt;

&lt;tr&gt;

&lt;th&gt;Role&lt;/th&gt;

&lt;th&gt;Model (all open-weight)&lt;/th&gt;

&lt;/tr&gt;

&lt;/thead&gt;

&lt;tbody&gt;

&lt;tr&gt;

&lt;td&gt;Router&lt;/td&gt;

&lt;td&gt;Gemma 3 4B (Gemma 3 27B runs the finished prompts as a check)&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;Optimizer&lt;/td&gt;

&lt;td&gt;gpt-oss-120b&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;Judge&lt;/td&gt;

&lt;td&gt;Gemma 4 31B&lt;/td&gt;

&lt;/tr&gt;

&lt;/tbody&gt;

&lt;/table&gt;&lt;/div&gt;&lt;p&gt;&lt;/p&gt;

&lt;p&gt;Held-out results, 308 test-split messages the optimizer never saw, mean of two runs. A reply counts when it is exactly the right label:&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;table&gt;

&lt;thead&gt;

&lt;tr&gt;

&lt;th&gt;Prompt&lt;/th&gt;

&lt;th&gt;Gemma 3 4B&lt;/th&gt;

&lt;th&gt;Gemma 3 27B&lt;/th&gt;

&lt;/tr&gt;

&lt;/thead&gt;

&lt;tbody&gt;

&lt;tr&gt;

&lt;td&gt;v1 (&lt;code&gt;lazy&lt;/code&gt;): labels only&lt;/td&gt;

&lt;td&gt;37%&lt;/td&gt;

&lt;td&gt;69%&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;v2 (&lt;code&gt;fixed&lt;/code&gt;): after iteration 1&lt;/td&gt;

&lt;td&gt;55%&lt;/td&gt;

&lt;td&gt;73%&lt;/td&gt;

&lt;/tr&gt;

&lt;tr&gt;

&lt;td&gt;v3 (&lt;code&gt;fixed2&lt;/code&gt;): after iteration 2&lt;/td&gt;

&lt;td&gt;60%&lt;/td&gt;

&lt;td&gt;74%&lt;/td&gt;

&lt;/tr&gt;

&lt;/tbody&gt;

&lt;/table&gt;&lt;/div&gt;&lt;p&gt;&lt;/p&gt;

&lt;p&gt;Iteration 1 fixed the reply format (the 4B model wrapped about 35% of…&lt;/p&gt;&lt;/div&gt;


&lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/talhaanwarch/promptsloth" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;p&gt;The repo, &lt;a href="https://github.com/talhaanwarch/promptsloth" rel="noopener noreferrer"&gt;talhaanwarch/promptsloth&lt;/a&gt; (Apache-2.0), holds every script and every result file behind the charts. To run it, get an &lt;a href="https://acruxcore.com" rel="noopener noreferrer"&gt;AcruxCore&lt;/a&gt; API key with the editor role and run &lt;code&gt;export ACRUXCORE_API_KEY=...&lt;/code&gt;. Then run &lt;code&gt;python3 sample.py&lt;/code&gt; and the subcommands in the order the script's docstring lists.&lt;/p&gt;

&lt;p&gt;When you fix a prompt today, how do you check that the fix did not break other cases? I tested on a held-out split, and I would like to hear what works on your team. 🦥&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 3 4B and 27B route the messages, and Gemma 4 31B is the judge whose scores decided each promotion. OpenRouter served all three Gemma models.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AI assistance: I built PromptSloth with an AI coding assistant, and this post was drafted with an AI assistant from the project's own result files. I reviewed it, and every number comes from the repo.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>ai</category>
    </item>
    <item>
      <title>Got impacted by railway breach. Now need to change all keys and the worst part is still not able to deploy. Who else here got impacted.</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:37:08 +0000</pubDate>
      <link>https://dev.to/ailegend/got-impacted-by-railway-breach-now-need-to-change-all-keys-and-the-worst-part-is-still-not-able-to-497j</link>
      <guid>https://dev.to/ailegend/got-impacted-by-railway-breach-now-need-to-change-all-keys-and-the-worst-part-is-still-not-able-to-497j</guid>
      <description></description>
    </item>
    <item>
      <title>A receipt reader on open vision models: 4 models, 50 receipts, 25x price gap</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Tue, 13 Oct 2026 19:07:46 +0000</pubDate>
      <link>https://dev.to/ailegend/a-receipt-sorter-on-open-vision-models-4-models-50-receipts-25x-price-gap-5c5d</link>
      <guid>https://dev.to/ailegend/a-receipt-sorter-on-open-vision-models-4-models-50-receipts-25x-price-gap-5c5d</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Challenge, Week 2: Side Hustle&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Freelancers keep a pile of receipt photos and type each one into a spreadsheet: company, date, total. The receipts are private financial records, and many people would rather not upload them to a closed service.&lt;/p&gt;

&lt;p&gt;The receipt sorter takes receipt photos and returns a CSV with the company, date and total of each. The side hustle idea is a small paid service that does this for customers.&lt;/p&gt;

&lt;p&gt;One prompt holds the instructions and the photo, and a vision model reads it. I tested four models on 50 labelled receipts, which means each receipt has a known correct company, date and total. Gemma 4 31B, Qwen3-VL 30B and Qwen3-VL 8B have open weights. GPT-5.4 mini is closed, and I used it as the reference to compare against. I work on &lt;a href="https://acruxcore.com" rel="noopener noreferrer"&gt;AcruxCore&lt;/a&gt;, the open-source LLM-ops platform (&lt;a href="https://github.com/AcruxCore/AcruxCore" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;) that ran these tests. It stores prompts, sends model calls through one gateway, runs evaluations and records traces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqxqycwrwcl26txbvppug.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqxqycwrwcl26txbvppug.gif" alt="A 15-second screen recording of the Streamlit app reading receipts and filling the table" width="800" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Drop photos in, press Extract, and download the CSV. Every row links to a &lt;a href="https://docs.acruxcore.com/docs/guides/trace-an-llm-call" rel="noopener noreferrer"&gt;trace&lt;/a&gt;, the record AcruxCore keeps of one model call with its tokens, cost and time, so you can inspect a wrong total.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffjwmxrg8kspplzmtvizg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffjwmxrg8kspplzmtvizg.png" alt="The Streamlit app after three receipts: a table with company, date, total, seconds and a link to each trace" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/talhaanwarch/receipt-sorter" rel="noopener noreferrer"&gt;github.com/talhaanwarch/receipt-sorter&lt;/a&gt; (Apache-2.0) has the code and a one-command setup. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://acruxcore.com/" rel="noopener noreferrer"&gt;LLMops platform used to compare models and prompt is AcruxCore&lt;/a&gt;, an opensource platform.&lt;/p&gt;

&lt;p&gt;The step-by-step build is in a separate tutorial: &lt;a href="https://dev.to/ailegend/build-a-receipt-sorter-with-an-open-vision-model-and-acruxcore-39a9"&gt;Build a receipt sorter with an open vision model and AcruxCore&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Register the models.&lt;/strong&gt; Four vision models on OpenRouter are registered in the &lt;a href="https://docs.acruxcore.com/docs/guides/route-calls-through-the-gateway" rel="noopener noreferrer"&gt;AcruxCore gateway&lt;/a&gt;, the service that sends each call to a model provider, with their prices: Gemma 4 31B, Qwen3-VL 30B, Qwen3-VL 8B, and GPT-5.4 mini as the closed reference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write one prompt with an image.&lt;/strong&gt; The prompt &lt;code&gt;receipt-extract&lt;/code&gt; has a system message with field rules and a user message with an image part, &lt;code&gt;{{ receipt_url }}&lt;/code&gt;. Each edit is a new &lt;a href="https://docs.acruxcore.com/docs/guides/version-a-prompt" rel="noopener noreferrer"&gt;version&lt;/a&gt;. Version 1 has short instructions, and version 2 adds field rules, such as the day coming first in the date. An alias is a movable name that points at one version, and the alias &lt;code&gt;production&lt;/code&gt; points at the version the app uses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call it through the gateway.&lt;/strong&gt; The Streamlit app renders the prompt for the alias and sends it to the model you choose. The same code reads a public link or an uploaded photo sent as a &lt;code&gt;data:&lt;/code&gt; URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build a dataset.&lt;/strong&gt; The 50 receipts are a sample of the public SROIE set, each with its labelled company, date and total. The dataset stores links to the images, not the images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run an &lt;a href="https://docs.acruxcore.com/docs/guides/evaluate-a-prompt" rel="noopener noreferrer"&gt;experiment&lt;/a&gt;.&lt;/strong&gt; An experiment crosses prompt versions with models. I ran two versions on four models, three times. I scored exact matches from the raw outputs. An LLM judge, a second model that reads the receipt, the answer and the correct values, also gave each answer a score from 0 to 100.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0t6ktu8bmp4yxdi1ug7v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0t6ktu8bmp4yxdi1ug7v.png" alt="An AcruxCore trace of one receipt: the model, tokens, cost and a 4.98 second duration" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every call in these steps wrote a trace like this one, so the cost and seconds in the table below come from the spans, not from a stopwatch.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Promote the winner.&lt;/strong&gt; Moving the &lt;code&gt;production&lt;/code&gt; alias from version 1 to version 2 changed the app's behaviour with no code change.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What the measurement shows
&lt;/h2&gt;

&lt;p&gt;Each row is 50 receipts, three runs of the same prompt (version 2). "All three right" means company, date and total were all correct. Cost is the extraction call only.&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;Open weights&lt;/th&gt;
&lt;th&gt;All three right (of 50)&lt;/th&gt;
&lt;th&gt;Median seconds&lt;/th&gt;
&lt;th&gt;$ per 1,000 receipts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 31B&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;45–46&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;td&gt;0.05&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-VL 30B&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;39–44&lt;/td&gt;
&lt;td&gt;3.1&lt;/td&gt;
&lt;td&gt;0.21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-VL 8B&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;41–42&lt;/td&gt;
&lt;td&gt;2.2&lt;/td&gt;
&lt;td&gt;0.34&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 mini&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;td&gt;1.8&lt;/td&gt;
&lt;td&gt;1.25&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F60rkn2h6guoj0arldznh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F60rkn2h6guoj0arldznh.png" alt="The AcruxCore run report: a matrix of two prompt versions by four models, each cell with its judge score" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Gemma 4 31B and GPT-5.4 mini cannot be told apart on accuracy in this sample. Gemma costs 25 times less per receipt: $0.05 against $1.25 per 1,000 receipts. GPT-5.4 mini is faster, at 1.8 seconds against 5.0.&lt;/p&gt;

&lt;p&gt;The Qwen models are faster than Gemma, but they got fewer receipts fully right. Qwen3-VL 8B costs more per 1,000 receipts than the 30B because its registered price per token is higher: $0.25 in and $0.75 out per million tokens, against $0.15 and $0.60. &lt;br&gt;
The order of the models changes between prompt version 1 and version 2, so I do not name an accuracy winner. The table supports a smaller claim: an open model matched the closed one on this sample.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cost.&lt;/strong&gt; At 40 receipts a month, Gemma 4 31B costs $0.002 and GPT-5.4 mini costs $0.05. Both amounts are small, but the closed model costs 25 times more, with accuracy this sample cannot tell apart, and that difference grows with every customer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Control of financial records.&lt;/strong&gt; Open weights can run where the data stays. I ran these models through OpenRouter, not on a laptop, so this article does not show a local run. The measurement shows that models you could host yourself read these receipts as well as the closed one. You need that evidence before you move them in-house.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the judge cost
&lt;/h2&gt;

&lt;p&gt;The judge is the expensive part of an image evaluation. One run covers eight combinations of prompt version and model, with 50 receipts each, so 400 extractions and 400 judge calls. The four extraction models cost $0.18 in total, and the judge cost $2.33.&lt;/p&gt;

&lt;p&gt;The judge was billed about 37,000–41,000 input tokens per call. The extraction models were billed 340–1,500 input tokens. The most likely cause is that the judge also receives the receipt image, but I did not test a different judge to confirm it. Check the token count of one judge call before you start a full run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;SROIE is public, so a model may have seen it in training. A private set of receipts could give different exact-match counts.&lt;/li&gt;
&lt;li&gt;50 receipts is small. The ranges show how much three repeat runs differ. They do not show how much a different set of 50 receipts would change the result.&lt;/li&gt;
&lt;li&gt;The receipts are Malaysian English. Other languages and layouts are untested.&lt;/li&gt;
&lt;li&gt;Five receipts are missed by all four models. Two print two valid totals, one has a cut-off year, one has a low-resolution year, and one shows two company names. I kept the published labels. On the other 45 receipts, Gemma missed none, GPT-5.4 mini missed one, Qwen3-VL 8B missed four and Qwen3-VL 30B missed seven.&lt;/li&gt;
&lt;li&gt;Qwen3-VL 8B hit provider rate limits in one run. I ran it again and report only runs without provider errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 4 31B, Google's open-weight model, reads the receipts through a provider (OpenRouter). In the measurement, its exact-match range overlapped the closed reference model's, and it cost 25 times less..&lt;/p&gt;

&lt;p&gt;The prompt, gateway, dataset, evaluation and tracing layers run on &lt;a href="https://acruxcore.com" rel="noopener noreferrer"&gt;AcruxCore&lt;/a&gt;, which is open source under Apache-2.0.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>opensource</category>
      <category>python</category>
    </item>
    <item>
      <title>Build a receipt sorter with an open vision model and AcruxCore</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Tue, 13 Oct 2026 19:07:07 +0000</pubDate>
      <link>https://dev.to/ailegend/build-a-receipt-sorter-with-an-open-vision-model-and-acruxcore-39a9</link>
      <guid>https://dev.to/ailegend/build-a-receipt-sorter-with-an-open-vision-model-and-acruxcore-39a9</guid>
      <description>&lt;p&gt;You will build a receipt sorter: drop receipt photos into a web page and get a CSV with the company, date and total of each one. Then you will use AcruxCore (&lt;a href="https://differ.blog/p/llmops-vs-mlops-what-changes-when-the-model-is-not-yours-ecf9e0" rel="noopener noreferrer"&gt;an LLMops platform&lt;/a&gt;) to choose the model with evidence instead of a guess. You finish with a versioned image prompt, a 50-receipt test set, a model comparison you can repeat, and a trace for every receipt the app reads.&lt;/p&gt;

&lt;p&gt;Every &lt;a href="https://acruxcore.com/" rel="noopener noreferrer"&gt;AcruxCore&lt;/a&gt; step shows the dashboard and the Python SDK side by side. The full code is in &lt;a href="https://github.com/talhaanwarch/receipt-sorter" rel="noopener noreferrer"&gt;github.com/talhaanwarch/receipt-sorter&lt;/a&gt;. The measured result of the comparison is in the &lt;a href="https://dev.to/ailegend/a-receipt-sorter-on-open-vision-models-4-models-50-receipts-25x-price-gap-5c5d"&gt;challenge article&lt;/a&gt;; this page teaches the build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You need:&lt;/strong&gt; Python 3.11+, an &lt;a href="https://openrouter.ai" rel="noopener noreferrer"&gt;OpenRouter&lt;/a&gt; API key with a few dollars of credit (the whole tutorial costs under $3, most of it the judge), and an AcruxCore account. The screenshots use a local server at &lt;code&gt;http://localhost:5173&lt;/code&gt;; the steps are the same on a hosted account.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Set up the project
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/talhaanwarch/receipt-sorter &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;receipt-sorter
python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install &lt;/span&gt;acruxcore streamlit python-dotenv pandas pyarrow pillow
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the dashboard, open &lt;strong&gt;Account &amp;amp; keys&lt;/strong&gt;, create an API key, and put it in &lt;code&gt;.env&lt;/code&gt; as &lt;code&gt;ACRUXCORE_API_KEY&lt;/code&gt;. Set &lt;code&gt;ACRUXCORE_BASE_URL&lt;/code&gt; to your API (&lt;code&gt;http://localhost:3001/api/v1&lt;/code&gt; locally) and &lt;code&gt;OPENROUTER_API_KEY&lt;/code&gt; to your OpenRouter key.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Connect OpenRouter and register the models
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://acruxcore.com/features/gateway" rel="noopener noreferrer"&gt;AcruxCore gateway&lt;/a&gt; serves named models. A request says &lt;code&gt;model="rs-gemma-4-31b"&lt;/code&gt;, and the gateway resolves that name to an upstream model and a stored key. So the app code never holds a provider key, and switching a model is a name change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dashboard.&lt;/strong&gt; Open &lt;strong&gt;Credentials&lt;/strong&gt;, add an OpenAI-compatible credential with base URL &lt;code&gt;https://openrouter.ai/api/v1&lt;/code&gt; and your OpenRouter key. Then open &lt;strong&gt;Models&lt;/strong&gt; and press &lt;strong&gt;New model&lt;/strong&gt; once per model, choosing that credential and entering the price per million tokens.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3vxl1yq50pkvy20ydjqa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3vxl1yq50pkvy20ydjqa.png" alt="The Models page listing five registered models with their upstream names and prices" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python.&lt;/strong&gt; &lt;code&gt;[scripts/setup_acruxcore.py](https://github.com/talhaanwarch/receipt-sorter/blob/main/scripts/setup_acruxcore.py)&lt;/code&gt; registers the same five models (four readers and the judge, which step 7 explains):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;MODELS&lt;/span&gt; &lt;span class="o"&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;rs-gemma-4-31b&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;google/gemma-4-31b-it&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.09&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.34&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rs-qwen3-vl-30b&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;qwen/qwen3-vl-30b-a3b-instruct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rs-qwen3-vl-8b&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;qwen/qwen3-vl-8b-instruct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rs-gpt-5.4-mini&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;openai/gpt-5.4-mini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;4.50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-4o-mini&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;openai/gpt-4o-mini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;# the judge
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prices matter later: AcruxCore multiplies them by the token counts of each call, which is where the dollar figures in traces come from. All reader models are open-weight except &lt;code&gt;rs-gpt-5.4-mini&lt;/code&gt;, which is the closed reference. Gemini models are left out because the gateway cannot pass them an image link.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Get 50 test receipts
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tools/build_sample.py&lt;/code&gt; downloads the public &lt;a href="https://huggingface.co/datasets/jsdnrs/ICDAR2019-SROIE" rel="noopener noreferrer"&gt;SROIE&lt;/a&gt; test split, picks 50 receipts with a fixed seed, shrinks them to 1,600 pixels on the long side, and writes &lt;code&gt;data/labels.json&lt;/code&gt; with the correct company, date and total of each. The images are already committed to the repo, so you can skip the script. SROIE is licensed CC BY 4.0, and the &lt;code&gt;NOTICE&lt;/code&gt; file carries the credit.&lt;/p&gt;

&lt;p&gt;The images live on &lt;code&gt;raw.githubusercontent.com&lt;/code&gt;, so each one has a public link. That is deliberate: a dataset example's input is capped at 8,192 bytes, which a photo does not fit in. The example stores the link, and the model fetches the image.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Write the prompt with an image
&lt;/h2&gt;

&lt;p&gt;A &lt;a href="https://acruxcore.com/features/prompts" rel="noopener noreferrer"&gt;prompt in AcruxCore&lt;/a&gt; is a list of messages. A user message can hold several parts, including an image part whose URL is a variable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dashboard.&lt;/strong&gt; Open &lt;strong&gt;Prompts&lt;/strong&gt;, create &lt;code&gt;receipt-extract&lt;/code&gt;, and in the editor add a system message with the rules, then a user message with a text part ("Read this receipt.") and an &lt;strong&gt;Image link&lt;/strong&gt; part set to &lt;code&gt;{{ receipt_url }}&lt;/code&gt;. Press &lt;strong&gt;Commit new version&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Funnwhpri8btk5qcs1oci.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Funnwhpri8btk5qcs1oci.png" alt="The receipt-extract prompt in the editor: a system message with field rules and a user message with a text part and an image link set to receipt_url" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;USER_PARTS&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Read this receipt.&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;image_url&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;image_url&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;url&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;{{ receipt_url }}&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="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompts&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;receipt-extract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;v1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit_version&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;messages&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;promote_alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;version_number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details. The image part is allowed only in a user message. And &lt;code&gt;promote_alias&lt;/code&gt; creates the alias &lt;code&gt;production&lt;/code&gt;: a movable name that points at one version. The app asks for &lt;code&gt;production&lt;/code&gt;, never for a version number, so you can change what runs without touching the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Call it through the gateway
&lt;/h2&gt;

&lt;p&gt;Rendering fills the variable and returns messages ready to send. The gateway call then runs them on a registered model and records a trace.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;rendered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;receipt-extract&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;production&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;receipt_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;receipt_url&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rs-gemma-4-31b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rendered&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="n"&gt;temperature&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="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;prompt_version_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rendered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;version_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;variables&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rendered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="o"&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;name&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;extract-receipt-fields&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;tags&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;receipt-sorter&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;production&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="n"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_fields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# {"company": ..., "date": ..., "total": ...}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;receipt_url&lt;/code&gt; is either a public link or an uploaded photo turned into a &lt;code&gt;data:image/jpeg;base64,...&lt;/code&gt; value by &lt;code&gt;to_data_url&lt;/code&gt;. Both go through the same call. Passing &lt;code&gt;prompt_version_id&lt;/code&gt; links the trace to the exact prompt version that produced it, and &lt;code&gt;temperature=0&lt;/code&gt; keeps repeat runs comparable.&lt;/p&gt;

&lt;p&gt;Models sometimes wrap the JSON in a code fence, so &lt;code&gt;parse_fields&lt;/code&gt; finds the first &lt;code&gt;{...}&lt;/code&gt; in the reply and parses that. The extraction code is in &lt;a href="https://github.com/talhaanwarch/receipt-sorter/blob/main/receipt_sorter/extract.py" rel="noopener noreferrer"&gt;&lt;code&gt;receipt_sorter/extract.py&lt;/code&gt;&lt;/a&gt;. For image parts in more detail, see the guide &lt;a href="https://docs.acruxcore.com/docs/guides/gateway/send-an-image-to-a-model" rel="noopener noreferrer"&gt;Send an image to a model&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Build the dataset
&lt;/h2&gt;

&lt;p&gt;A dataset is a set of examples: an input, and a description of what a good answer looks like. Here each input is one receipt link, and the criteria spell out the expected company, date and total.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dashboard.&lt;/strong&gt; Open &lt;strong&gt;Evaluations&lt;/strong&gt;, create a dataset, and use &lt;strong&gt;Add example&lt;/strong&gt; for each one. For 50 examples, the SDK is quicker.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1m1dwsg7xkqp8gngrrtt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1m1dwsg7xkqp8gngrrtt.png" alt="The dataset page listing 50 examples, each with a receipt link as input" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;datasets&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;receipt-sorter-sroie-50&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;datasets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_examples&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&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;input&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;receipt_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;IMAGE_BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.jpg&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;criteria&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;criteria&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&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;label&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;labels&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;criteria&lt;/code&gt; writes one sentence per receipt, for example: expected company "F&amp;amp;P PHARMACY", date "02/03/2018", total "31.90"; the company may differ in capitalisation or punctuation only; any other date or total means a low score. The judge in step 7 reads this text.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Compare prompts and models
&lt;/h2&gt;

&lt;p&gt;An &lt;strong&gt;experiment&lt;/strong&gt; crosses prompt versions with models over one dataset. A &lt;strong&gt;run&lt;/strong&gt; is one execution of that grid, and each combination of version and model is a &lt;strong&gt;cell&lt;/strong&gt;. So there are two things to compare, and you get both from one run.&lt;/p&gt;

&lt;p&gt;First commit a second prompt version with stricter field rules (the day comes first in a Malaysian date, the total is the amount owed after tax, not the subtotal). Put it on the &lt;code&gt;staging&lt;/code&gt; alias, and open the &lt;strong&gt;Diff&lt;/strong&gt; tab to see exactly what changed from version 1.&lt;/p&gt;

&lt;p&gt;Then create the experiment: dataset, both versions, the four reader models.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dashboard.&lt;/strong&gt; Open the dataset and press &lt;strong&gt;Run experiment&lt;/strong&gt;, tick both versions and the four models, and start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;experiments&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;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;v1_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v2_id&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;rs-gemma-4-31b&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;rs-qwen3-vl-30b&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;rs-qwen3-vl-8b&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;rs-gpt-5.4-mini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;prompt_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;receipts: all 4 models, prompt v1,2&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="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;experiments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;runs&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="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;succeeded&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;failed&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;cancelled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A worker process runs the jobs in the background, so a run stays &lt;code&gt;queued&lt;/code&gt; until a worker is running. The run takes a few minutes: 400 model calls plus 400 judge calls.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk3qu04sr59gml4ito7uh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk3qu04sr59gml4ito7uh.png" alt="The Runs page listing the experiments, each with a name that says which models and repeat it covers" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Name experiments after what they contain. The Runs list shows only the name, and "run 3" tells you nothing a month later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Read the report
&lt;/h3&gt;

&lt;p&gt;The report has a leaderboard and a matrix with one cell per version and model. Each cell shows a judge score and how many of the 50 examples were scored.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbpqqukl5v066xh4ctip1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbpqqukl5v066xh4ctip1.png" alt="The run report: a leaderboard ranking the eight cells by judge score, and below it a matrix of two prompt versions by four models" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;judge&lt;/strong&gt; is another model that reads the receipt, the model's answer and your criteria, and gives a 0–100 score with a reason. Open any example to read that reason. Three things to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;a href="https://docs.acruxcore.com/docs/guides/evaluate-a-prompt-with-conversation-history" rel="noopener noreferrer"&gt;judge model is set by the server&lt;/a&gt; (&lt;code&gt;EVAL_JUDGE_MODEL&lt;/code&gt;, default &lt;code&gt;gpt-4o-mini&lt;/code&gt;) and must be registered in Models, as in step 2. If it is missing, every example stays unscored.&lt;/li&gt;
&lt;li&gt;A run ends &lt;code&gt;succeeded&lt;/code&gt; even when cells failed or went unscored. &lt;code&gt;[scripts/run_eval.py](https://github.com/talhaanwarch/receipt-sorter/blob/main/scripts/run_eval.py)&lt;/code&gt; checks &lt;code&gt;scoredCount&lt;/code&gt; for every cell and stops if any is zero.&lt;/li&gt;
&lt;li&gt;The judge is the expensive part. It receives the image too, and in this project the judge cost about 13 times the readers' cost per run. Read the token count of one judge trace before starting a full run, and consider a cheaper vision model as judge.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Score from the raw outputs too
&lt;/h3&gt;

&lt;p&gt;A judge score is a good overview, but the question "were all three fields right?" has an exact answer. &lt;code&gt;[scripts/score_run.py](https://github.com/talhaanwarch/receipt-sorter/blob/main/scripts/score_run.py)&lt;/code&gt; reads each cell's raw replies and compares company, date and total to the labels, then prints the counts. Provider errors, such as a rate limit, are counted in their own column and are not treated as wrong answers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Repeat the run
&lt;/h3&gt;

&lt;p&gt;The same prompt scores a little differently each time. Run the experiment three times (&lt;code&gt;python scripts/run_eval.py --repeat 3&lt;/code&gt;) and compare ranges, not single numbers. If two models' ranges overlap, treat them as tied. For the guide to this step, see &lt;a href="https://docs.acruxcore.com/docs/guides/evaluation/evaluate-a-prompt-that-reads-an-image" rel="noopener noreferrer"&gt;Evaluate a prompt that reads an image&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Promote the winner
&lt;/h2&gt;

&lt;p&gt;When version 2 reads better, move the alias.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dashboard.&lt;/strong&gt; Open the prompt's &lt;strong&gt;Versions&lt;/strong&gt; tab and move the &lt;code&gt;production&lt;/code&gt; alias to version 2. The badges under the prompt name show which alias points where.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python.&lt;/strong&gt;&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;promote_alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;&lt;span class="sh"&gt;"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app asks for &lt;code&gt;production&lt;/code&gt;, so its next extraction uses version 2 with no code change and no redeploy. If version 2 turns out worse, point the alias back at version 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Read the traces
&lt;/h2&gt;

&lt;p&gt;Every gateway call wrote a trace. Open &lt;strong&gt;Traces&lt;/strong&gt; and pick one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5j86t901hcp9y8b4zzop.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5j86t901hcp9y8b4zzop.png" alt="A trace named extract-receipt-fields with its tags, token count, cost, and one LLM span with the model and a 4.98 second duration" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The trace shows the model that ran, the tokens, the dollar cost, and the latency. The tags &lt;code&gt;receipt-sorter&lt;/code&gt; and the alias name let you filter all receipts read under &lt;code&gt;production&lt;/code&gt;. Because step 5 passed &lt;code&gt;prompt_version_id&lt;/code&gt;, the trace also links back to the prompt version, so a wrong total can be traced to the exact wording that produced it. The median seconds and cost per 1,000 receipts in the challenge article come from these spans.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Add the app
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;app.py&lt;/code&gt; is about 40 lines of Streamlit. It lets you choose a model and an alias, takes uploaded photos, calls &lt;code&gt;extract_fields&lt;/code&gt; for each, shows a table with a link to each trace, and offers the CSV download.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;streamlit run app.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl4cjk5hw0j5wxbi4ocp2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl4cjk5hw0j5wxbi4ocp2.png" alt="The receipt sorter app showing three receipts with company, date, total, seconds and a trace link, and a Download CSV button" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The app holds no prompt text and no provider key. Everything it needs, the wording, the version and the model routing, lives in AcruxCore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions you may have
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I choose the judge model and the judge prompt?
&lt;/h3&gt;

&lt;p&gt;Yes. You pick both when you set up an experiment. The judge model can be any model registered in Models, so you can use a cheaper vision model than &lt;code&gt;gpt-4o-mini&lt;/code&gt;. The judge prompt holds the instructions the judge follows when it scores an answer, and you can edit it for your task. For receipts, you could tell the judge to ignore capitalisation in the company name.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I compare several prompts and several models in one run?
&lt;/h3&gt;

&lt;p&gt;Yes. One experiment crosses any number of versions of one prompt with any number of models. Step 7 uses two versions and four models, so one run has eight cells. Each cell has its own score, and the matrix puts all of them on one page. The &lt;a href="https://docs.acruxcore.com/docs/guides/evaluate-a-prompt" rel="noopener noreferrer"&gt;Evaluate a prompt&lt;/a&gt; guide covers the same flow for a text prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use a model that is not on OpenRouter?
&lt;/h3&gt;

&lt;p&gt;Yes. The gateway serves any model that has an OpenAI-compatible API, whether a hosted provider or a server you run yourself. Register it in Models with its base URL and prices, and use its name in the experiment like any other model. A model must accept image input to read receipts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I check the answers with my own code as well as the judge?
&lt;/h3&gt;

&lt;p&gt;Yes. An experiment always scores with a judge, which gives each answer a 0–100 score and a reason. A run also keeps the raw reply of every example. If your task has one correct answer, as the receipt fields do, you can compare those replies to your labels in code, as &lt;a href="https://github.com/talhaanwarch/receipt-sorter/blob/main/scripts/score_run.py" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/score_run.py&lt;/code&gt;&lt;/a&gt; does. This exact count is made by your script, and the dashboard does not show it. The judge score is a quick overview, and the exact count is the number you can check.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much does a run cost, and how do I lower it?
&lt;/h3&gt;

&lt;p&gt;The judge is the largest cost, because it receives the receipt image as well as the answer. Run the experiment on 10 examples first, read the token count of one judge trace, and then start the full run. A cheaper judge model lowers the cost of every later run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where is my OpenRouter key stored?
&lt;/h3&gt;

&lt;p&gt;In the AcruxCore credential you created in step 2. The app sends a model name to the gateway, and the gateway adds the key. The code in the repository holds only the AcruxCore API key.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I run AcruxCore on my own server?
&lt;/h3&gt;

&lt;p&gt;Yes. AcruxCore is open source under Apache-2.0, and the screenshots in this tutorial come from a local server. The steps are the same on a hosted account. The code is at &lt;a href="https://github.com/AcruxCore/AcruxCore" rel="noopener noreferrer"&gt;github.com/AcruxCore/AcruxCore&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The measured comparison of the four models, with the table and the limits of the test: the &lt;a href="https://dev.to/ailegend/a-receipt-sorter-on-open-vision-models-4-models-50-receipts-25x-price-gap-5c5d"&gt;challenge article&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Replace the 50 SROIE receipts with 50 of your own, with labels you checked yourself. SROIE is public and some labels are wrong, so a private set tells you more.&lt;/li&gt;
&lt;li&gt;Add a fifth model: register it in step 2 and run the experiment again.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>tutorial</category>
      <category>opensource</category>
      <category>python</category>
      <category>ai</category>
    </item>
    <item>
      <title>When I started writing on dev.to, it was hard. New profile, no readers, and my posts felt too senior-level. Today I found out I'm a runner-up in the #bugsmash #devchallenge. I even missed the first email! Thank you, DEV community 🙏</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Sat, 10 Oct 2026 18:11:15 +0000</pubDate>
      <link>https://dev.to/ailegend/when-i-started-writing-on-devto-it-was-hard-new-profile-no-readers-and-my-posts-felt-too-4imd</link>
      <guid>https://dev.to/ailegend/when-i-started-writing-on-devto-it-was-hard-new-profile-no-readers-and-my-posts-felt-too-4imd</guid>
      <description></description>
      <category>beginners</category>
      <category>community</category>
      <category>devchallenge</category>
    </item>
    <item>
      <title>Clean Air Window: the cleanest hour to go outside in smog season</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Mon, 05 Oct 2026 20:37:50 +0000</pubDate>
      <link>https://dev.to/ailegend/clean-air-window-the-cleanest-hour-to-go-outside-in-smog-season-189h</link>
      <guid>https://dev.to/ailegend/clean-air-window-the-cleanest-hour-to-go-outside-in-smog-season-189h</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Try it live:&lt;/strong&gt; &lt;a href="https://clean-air-window.onrender.com" rel="noopener noreferrer"&gt;clean-air-window.onrender.com&lt;/a&gt; (five plans per visit)&lt;br&gt;&lt;br&gt;
&lt;strong&gt;⌨ Code:&lt;/strong&gt; &lt;a href="https://github.com/talhaanwarch/clean-air-window" rel="noopener noreferrer"&gt;github.com/talhaanwarch/clean-air-window&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Clean Air Window picks the cleanest time to go outside today. You give it your city, your activity and the hours you are free. You can also say whether you are in an EPA sensitive group, such as people with asthma. The app answers with a time card, an hourly air quality chart, and advice from the U.S. EPA's health guidance. Some weeks a local news site reports a smog alert or a school closure. In those weeks, the answer names the headline and links to the article.&lt;/p&gt;

&lt;p&gt;The EPA splits the air quality index (AQI) into six &lt;a href="https://www.airnow.gov/aqi/aqi-basics/" rel="noopener noreferrer"&gt;bands&lt;/a&gt;: Good (0–50), Moderate (51–100), Unhealthy for Sensitive Groups (101–150), Unhealthy (151–200), Very Unhealthy (201–300), and Hazardous (above 300). On 5 October, the forecast for Lahore was AQI 137 at 15:00 and AQI 166 at 18:00. A person with asthma wanted a 45-minute walk between 15:00 and 21:00. The app picked 15:00 and suggested a shorter, slower walk. The same answer linked a headline from that week in Dawn, a Pakistani newspaper. The headline said that Lahore again ranked as the most polluted city in the world.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvbgioxje77bzuw3tx180.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvbgioxje77bzuw3tx180.png" alt="The plan for a 45-minute walk in Lahore: best option 15:00 to 16:00 at AQI 137, an hourly AQI chart in EPA colours, advice for a person with asthma, and a local news line from Dawn" width="800" height="667"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;The app is for anyone who walks, runs, cycles or plays outside in a city with seasonal smog. The app's instructions to the AI model say never to tell people to stay inside. When even the best hour is unhealthy, the model suggests a shorter and easier version of the activity.&lt;/p&gt;

&lt;p&gt;The AQI values are &lt;a href="https://open-meteo.com/" rel="noopener noreferrer"&gt;Open-Meteo&lt;/a&gt;'s hourly forecast, not measurements.&lt;/p&gt;
&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj6xtivgyqu603ulw9lo5.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj6xtivgyqu603ulw9lo5.gif" alt="Demo: planning a walk in Lahore, then the same run as a trace in AcruxCore" width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The demo plans a walk and clicks "I went outside" to mark that the person went. Then the demo opens the record of that plan in AcruxCore, the platform that stores the app's prompt, tools and run history. The demo ends on two AcruxCore screens that the post explains below. The &lt;a href="https://github.com/talhaanwarch/clean-air-window/blob/main/docs/demo.mp4" rel="noopener noreferrer"&gt;full-quality MP4&lt;/a&gt; is in the repo.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/talhaanwarch" rel="noopener noreferrer"&gt;
        talhaanwarch
      &lt;/a&gt; / &lt;a href="https://github.com/talhaanwarch/clean-air-window" rel="noopener noreferrer"&gt;
        clean-air-window
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      The cleanest time to go outside today: open-weight Qwen3 + Open-Meteo AQI + EPA guidance (RAG), traced in AcruxCore. DEV Hacktoberfest 2026 entry.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;🌿 Clean Air Window&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Clean Air Window tells you the cleanest time to go outside today. You give it your city, your activity, the hours you are free, and whether you are in a sensitive group such as people with asthma. It answers with one time window, an hourly air-quality chart, advice taken from the U.S. EPA's health guidance, and any local air-quality news from this week that changes the plan.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://clean-air-window.onrender.com" rel="nofollow noopener noreferrer"&gt;clean-air-window.onrender.com&lt;/a&gt;, on Render.&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/talhaanwarch/clean-air-window/docs/images/demo.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2Ftalhaanwarch%2Fclean-air-window%2FHEAD%2Fdocs%2Fimages%2Fdemo.gif" alt="Demo: planning a walk in Lahore, then the same run as a trace in AcruxCore"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/talhaanwarch/clean-air-window/docs/demo.mp4" rel="noopener noreferrer"&gt;Full-quality demo video (MP4)&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What you see&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/talhaanwarch/clean-air-window/docs/images/app-result.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Ftalhaanwarch%2Fclean-air-window%2FHEAD%2Fdocs%2Fimages%2Fapp-result.png" alt="The plan for a 45-minute walk in Lahore: best option 15:00–16:00 at AQI 137, an hourly AQI chart in EPA colours, advice for a person with asthma, and a local news headline from Dawn"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The card at the top and the chart come straight from the forecast data. The model writes the lines below the chart. The health advice links to the EPA document it came from, and the news line links to the headline it came from.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How it works&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Gemma 4 31B, an open-weight model from Google, runs a tool-calling loop with three tools:&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/talhaanwarch/clean-air-window" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The app is a Streamlit page on top of a tool-calling agent. The model is &lt;strong&gt;Gemma 4 31B&lt;/strong&gt; (&lt;code&gt;google/gemma-4-31b-it&lt;/code&gt;), an open-weight model from Google under Apache 2.0, served through OpenRouter. The model can call three tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;get_air_and_weather&lt;/code&gt; downloads today's hourly AQI, temperature and rain chance from Open-Meteo. The tool tries every time window of the activity's length inside your free hours. The tool rates each window by the dirtiest hour it covers, not by the average hour, and returns the best and the worst window. The dirtiest hour decides, because a 45-minute walk that passes through one bad hour still spends part of the walk in that bad air.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;search_health_guidance&lt;/code&gt; does the retrieval for RAG (retrieval-augmented generation). This tool searches five EPA and AirNow documents and returns the four passages closest to the model's search query. Qwen3-Embedding-8B makes the embeddings. The documents split into only 83 chunks. A set that small needs no vector database: a NumPy matrix and a dot product do the search. The 83 vectors ship inside the Docker image as a 1.3 MB file.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_local_air_news&lt;/code&gt; searches Google News through &lt;a href="https://serpapi.com/" rel="noopener noreferrer"&gt;SerpApi&lt;/a&gt; for the city's air quality headlines from the last seven days. The EPA guidance explains what an AQI level means, but the guidance cannot know that a city closed its schools this morning. SerpApi's free plan allows 250 searches a month, so the tool keeps each city's results for six hours.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Python code, not the model, draws the time card and the chart from the tool's best window. A model that copies numbers by hand can copy them wrong. The model writes only the explanation, the health advice, a tip and the news line. The model ends with a JSON answer that has six fixed fields:&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="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PlanAnswer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;why&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;           &lt;span class="c1"&gt;# best window vs worst window, with their numbers
&lt;/span&gt;    &lt;span class="n"&gt;for_you&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;       &lt;span class="c1"&gt;# what the EPA guidance says for this person
&lt;/span&gt;    &lt;span class="n"&gt;source_title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;  &lt;span class="c1"&gt;# the guidance document the advice came from
&lt;/span&gt;    &lt;span class="n"&gt;tip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;           &lt;span class="c1"&gt;# one practical tip for the activity
&lt;/span&gt;    &lt;span class="n"&gt;local_news&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;    &lt;span class="c1"&gt;# one sentence about a headline, or empty
&lt;/span&gt;    &lt;span class="n"&gt;news_title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;    &lt;span class="c1"&gt;# the exact title of that headline, or empty
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app links &lt;code&gt;source_title&lt;/code&gt; to the real EPA document. The app links &lt;code&gt;news_title&lt;/code&gt; only when the title matches a headline that &lt;code&gt;get_local_air_news&lt;/code&gt; returned in the same run. So the page never shows a news link that the model made up.&lt;/p&gt;

&lt;h3&gt;
  
  
  What AcruxCore keeps
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/AcruxCore/AcruxCore" rel="noopener noreferrer"&gt;AcruxCore&lt;/a&gt; is an open-source LLM-ops platform that I build. The app uses the hosted version at acruxcore.com. AcruxCore keeps four things for the app: the prompt, the three tools, the list of tools that the model may call with that prompt, and a trace of every plan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The prompt&lt;/strong&gt; is a versioned template. An alias is a name, such as &lt;code&gt;production&lt;/code&gt; or &lt;code&gt;staging&lt;/code&gt;, that points at one version. The template has an &lt;code&gt;if&lt;/code&gt; block that adds a line only for people in a sensitive group.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fusrqrgd40hwg541rvtmg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fusrqrgd40hwg541rvtmg.png" alt="The clean-air-window prompt in the AcruxCore editor. The production and staging aliases both point at version 2. The template has variables such as city and activity, and an if block for people in a sensitive group" width="800" height="752"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The three tools&lt;/strong&gt; are in the AcruxCore tool catalog. The &lt;code&gt;@acrux.tool&lt;/code&gt; decorator turns each Python function's signature and docstring into a tool definition. A setup script in the repo, &lt;code&gt;scripts/setup_acruxcore.py&lt;/code&gt;, sends those definitions to the catalog, and the catalog keeps a version of each tool. The code below is the whole definition of the news tool:&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="nd"&gt;@acrux.tool&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_local_air_news&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;city&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;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Get the last seven days of news headlines about air quality and smog in a city,
    newest first, such as official smog alerts or school closures. Use them only if a
    headline changes what the person should do today; many cities have none.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft1diib6izq9a5s5efj3k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft1diib6izq9a5s5efj3k.png" alt="The AcruxCore tool catalog with the three tools, each on version 1 for production and marked " width="800" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tools are connected to the prompt in the dashboard.&lt;/strong&gt; The prompt's Tools tab is a table with one row per tool. Each alias can have its own column, but only when that alias needs different tools. An alias without its own column uses the first column, named default. The app calls &lt;code&gt;run_prompt_with_tools&lt;/code&gt;, which reads that list of connected tools from the prompt. The app code never names the tools it offers the model. The code only maps each tool name to the Python function that runs it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;rendered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clean-air-window&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PROMPT_ALIAS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_prompt_with_tools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;rendered&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;google/gemma-4-31b-it:nitro&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client_tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CLIENT_TOOLS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;response_format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;acrux&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pydantic_response_format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PlanAnswer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan_answer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Adding the news tool one alias at a time
&lt;/h3&gt;

&lt;p&gt;The first version of the app had no news tool. Version 1 of the prompt asked for two tools, &lt;code&gt;get_air_and_weather&lt;/code&gt; and &lt;code&gt;search_health_guidance&lt;/code&gt;, and the &lt;code&gt;production&lt;/code&gt; alias pointed at version 1. I added the news tool in four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I committed version 2 of the prompt, which asks the model to call &lt;code&gt;get_local_air_news&lt;/code&gt; and to fill &lt;code&gt;local_news&lt;/code&gt; and &lt;code&gt;news_title&lt;/code&gt;. I pointed &lt;code&gt;staging&lt;/code&gt; at version 2 and left &lt;code&gt;production&lt;/code&gt; on version 1.&lt;/li&gt;
&lt;li&gt;On the prompt's Tools tab, I gave &lt;code&gt;staging&lt;/code&gt; its own column and connected the news tool there. The default column, which &lt;code&gt;production&lt;/code&gt; follows, still had only the weather and guidance tools.&lt;/li&gt;
&lt;li&gt;I ran a second copy of the app with &lt;code&gt;PROMPT_ALIAS=staging&lt;/code&gt; and checked that the news line appeared in the answer. The production copy kept serving version 1 with two tools.&lt;/li&gt;
&lt;li&gt;I pointed &lt;code&gt;production&lt;/code&gt; at version 2, connected the news tool in the default column, and removed the &lt;code&gt;staging&lt;/code&gt; column.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The screenshot below shows the Tools tab during step 3. &lt;code&gt;staging&lt;/code&gt; has its own column and serves version 2 with three tools. &lt;code&gt;production&lt;/code&gt; still follows the default column and serves version 1 with two tools:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxcjbiskun60kwfz1xynw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxcjbiskun60kwfz1xynw.png" alt="The prompt's Tools tab during the rollout. The default column connects get_air_and_weather and search_health_guidance. A separate staging column, serving v2, also connects get_local_air_news" width="800" height="531"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One code change came before these four steps: I shipped the app with the news tool's Python function. AcruxCore stores only the tool's definition, and the app runs the function itself. The function did nothing until step 2 connected the tool to an alias. Steps 1 to 4 changed only data in AcruxCore. The running production app picked up step 4 on its next plan, with no deploy.&lt;/p&gt;

&lt;h3&gt;
  
  
  One trace per plan
&lt;/h3&gt;

&lt;p&gt;A trace is the stored record of one run: every model call and every tool call, in order, with their inputs and outputs. The trace below is the Lahore plan from the screenshot at the top. In its first model call, Gemma asked for the weather tool and the news tool together, and the app ran both tools at the same time. The plan made four model calls in total, and the last call wrote the JSON answer. The "I went outside" button saves its feedback on the same trace.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fimp1lcsslhpti8rbtkjn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fimp1lcsslhpti8rbtkjn.png" alt="One plan as a trace in AcruxCore: four Gemma 4 calls and three tool calls, in order, with their timings" width="800" height="317"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The model calls go straight to OpenRouter with my own OpenRouter key, and they do not pass through AcruxCore. For each plan, the app first reads the prompt from AcruxCore. When the plan is done, the app sends the trace to AcruxCore. On version 2, plans took between 12 and 22 seconds in my runs, including one on the live app. The time card and the chart appear as soon as the weather tool returns, after about 5 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 4 chooses which tools to call, calls two of them in parallel, and writes the typed JSON answer in every plan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best Use of SerpApi.&lt;/strong&gt; &lt;code&gt;get_local_air_news&lt;/code&gt; uses SerpApi's Google News engine to find this week's smog alerts and school closures. The app shows a news link only when the title matches a headline that SerpApi returned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Render.&lt;/strong&gt; The live app runs on Render as a Docker web service, built from the repo's Dockerfile. The 1.3 MB guidance index ships inside the image, so the service needs no disk and no database, and a restart loads the index in under a second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Open weights mean no single company serves the model.&lt;/strong&gt; Gemma 4's weights are public, so on 6 October &lt;a href="https://openrouter.ai/google/gemma-4-31b-it" rel="noopener noreferrer"&gt;13 providers served Gemma 4 31B&lt;/a&gt; through OpenRouter. The &lt;code&gt;:nitro&lt;/code&gt; suffix asks OpenRouter for the fastest of those providers on each request. One environment variable chooses the model. Before Gemma, the app ran on Qwen3-Next-80B, another open-weight model, and the switch was this one line:&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="nv"&gt;CHAT_MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;google/gemma-4-31b-it:nitro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The repo includes the health guidance.&lt;/strong&gt; Work by the U.S. federal government, such as EPA and AirNow publications, is &lt;a href="https://www.usa.gov/government-copyright" rel="noopener noreferrer"&gt;not protected by copyright&lt;/a&gt;. So the extracted text sits in &lt;code&gt;data/guidance/&lt;/code&gt;, and every file names its source URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The app does not depend on one company's server.&lt;/strong&gt; AcruxCore is under Apache 2.0. The setup script creates the tools, the prompt and the tool connections from the repo's code. So the same app can run against a self-hosted copy of AcruxCore by changing &lt;code&gt;ACRUXCORE_BASE_URL&lt;/code&gt;. A trace stores a person's city, their free hours, and whether they have asthma, and a self-hosted copy keeps that data in your own Postgres database.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This project was built with help from AI. I used Claude Code to write most of the code and the first draft of this post, and I reviewed and tested both.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I published AI research papers and have 6 YoE building backends. Why do AI coding agents make me feel completely dumb?</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Sat, 22 Aug 2026 08:02:26 +0000</pubDate>
      <link>https://dev.to/ailegend/i-published-ai-research-papers-and-have-6-yoe-building-backends-why-do-ai-coding-agents-make-me-4igm</link>
      <guid>https://dev.to/ailegend/i-published-ai-research-papers-and-have-6-yoe-building-backends-why-do-ai-coding-agents-make-me-4igm</guid>
      <description>&lt;p&gt;There is a running joke that AI researchers can't write production software, and backend engineers don't understand research math.&lt;/p&gt;

&lt;p&gt;I happen to do both. I’ve published deep learning papers in top conferences, won ML competitions, and spent the last 6 years as a founding engineer building production APIs, async queues, and distributed systems. I read dense math papers and trace database deadlocks for fun.&lt;/p&gt;

&lt;p&gt;Yet, on any given day, an AI coding agent can "fix a bug" in 20 minutes and make me question if I even know how to read English.&lt;/p&gt;




&lt;h3&gt;
  
  
  The "10x Productivity" Myth vs. Management Expectations
&lt;/h3&gt;

&lt;p&gt;Management reads one article on Twitter and suddenly expects everyone to ship features at &lt;strong&gt;10x speed&lt;/strong&gt;.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frwf4edstvm2dv28sdnbe.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frwf4edstvm2dv28sdnbe.gif" alt="typing meme" width="320" height="180"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is what that "10x velocity" actually looks like when hooking up a RAG chatbot to external platform webhooks and syncing vector stores:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1 hour:&lt;/strong&gt; Planning the sync logic, payload schema, and chunking strategy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;20 minutes:&lt;/strong&gt; The AI agent writes 400 lines of code across 5 files to fix a streaming concurrency bug. Tests pass. The app runs. Pure magic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4 hours:&lt;/strong&gt; Me sitting frozen in front of my monitor trying to reverse-engineer why it bypassed our API gateway, restructured our entire websocket pipeline, and injected three layers of mysterious custom middleware.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code is "done" in 20 minutes, but now I’m in a full sweat because tomorrow morning in standup, someone is going to ask:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Hey, what was actually broken with the webhook listener?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And what am I supposed to say?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhyensadninsvnygjf49p.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhyensadninsvnygjf49p.gif" alt="Sweating in code review" width="504" height="312"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;"Honestly? The AI wizard cast a spell on line 42 and now it works. Please don't ask me anything else."&lt;/em&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  The 4-Stage Spiral of Shame
&lt;/h3&gt;

&lt;p&gt;When I don't understand why the agent chose a specific architecture to fix the bug, I ask it to explain. That’s when the psychological damage begins, with my confidence dropping on every single attempt:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 1: The Pretentious Professor&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I ask: &lt;em&gt;"Why did you restructure the vector search callback and event listener this way?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It replies with a 500-word essay containing words I haven't seen since a GRE test. I re-read it four times. My brain goes numb. &lt;em&gt;Is my English broken? Am I secretly bad at backend engineering?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 2: The Polite Request&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I say: &lt;em&gt;Please explain in simpler, direct terms without the jargon.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It uses everyday words, but strings them together so backwards that the logic eats its own tail:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Of course! We just make sure that when the webhook fires, the thing checking the data knows that the data being checked isn't the same data that triggered the check in the first place.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No fancy jargon, but my brain is completely melted trying to parse the sentence structure. Is my reading comprehension broken? Confidence drops another 25%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 3: The Desperation Move&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I type: &lt;em&gt;"Explain like I'm 15."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The AI instantly decides I have the cognitive ability of a toddler:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Imagine your vector database is a magical post office! When a user sends a message, a friendly owl named Barnaby flies across the forest to find the matching letter..."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Attempt 4: Installing the "Fixes"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At this point, I started searching for community prompt hacks. I even installed the popular &lt;a href="https://github.com/ayghri/i-have-adhd" rel="noopener noreferrer"&gt;&lt;code&gt;ayghri/i-have-adhd&lt;/code&gt;&lt;/a&gt; skill to force the agent into concise, no-fluff, step-by-step bullet points.&lt;/p&gt;

&lt;p&gt;And you know what happened? Now the agent gives me blunt, hyper-compressed steps, and I &lt;em&gt;still&lt;/em&gt; spend 30 minutes staring at bullet point #2 trying to understand the mathematical jump it made. Now I feel twice as dumb because I can't even digest the ADHD-friendly version.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdmfkwnfbhosonanvhl1q.gif" alt="Ai coding depression" width="386" height="480"&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Real Problem: Reverse-Engineering Fatigue
&lt;/h3&gt;

&lt;p&gt;Getting an AI-generated integration or bug fix to run is easy. &lt;strong&gt;Owning the code&lt;/strong&gt; in a team of senior engineers is the hard part.&lt;/p&gt;

&lt;p&gt;When a teammate looks at the PR and asks:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fflwygoc6c8ki0tqsulc7.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fflwygoc6c8ki0tqsulc7.gif" alt="code review" width="480" height="245"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Why did we switch to this custom event-emitter pattern instead of our standard message queue?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I can't reply:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Well, Barnaby the Owl needed a place to rest his wings."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So you spend half your day decoding AI logic just to translate it into normal human engineer terms. We didn't eliminate the coding bottleneck—we just traded writing code for full-time reverse-engineering.&lt;/p&gt;




&lt;h3&gt;
  
  
  Let's Settle This in the Comments:
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;How do you handle standup when AI fixed a bug but you have zero idea how?&lt;/strong&gt; Do you fake confidence or admit the robot did it?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What is the most insulting analogy an AI has ever given you&lt;/strong&gt; when you asked it to "simplify"?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What exact system prompt do you use&lt;/strong&gt; to make your agent talk like a normal, grounded senior dev without getting a PhD thesis or a fairy tale?&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>discuss</category>
      <category>productivity</category>
      <category>ai</category>
      <category>programming</category>
    </item>
    <item>
      <title>Our SDK Cached One User's Answer and Served It to the Next One Who Asked</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Tue, 11 Aug 2026 19:21:55 +0000</pubDate>
      <link>https://dev.to/ailegend/our-sdk-cached-one-users-answer-and-served-it-to-the-next-one-who-asked-55h7</link>
      <guid>https://dev.to/ailegend/our-sdk-cached-one-users-answer-and-served-it-to-the-next-one-who-asked-55h7</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;I work on &lt;a href="https://acruxcore.com" rel="noopener noreferrer"&gt;AcruxCore&lt;/a&gt;, an LLM ops platform with a published SDK in &lt;a href="https://www.npmjs.com/package/@acruxcoreai/sdk" rel="noopener noreferrer"&gt;TypeScript&lt;/a&gt; and &lt;a href="https://pypi.org/project/acruxcore/" rel="noopener noreferrer"&gt;Python&lt;/a&gt;. Both SDKs render prompts client-side against a small in-memory cache, so a hot path doesn't make a network round trip on every call:&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;rendered&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;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renderPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rag-chat&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="s1"&gt;production&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;question&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;renderPrompt&lt;/code&gt; fetches the templated messages once, then serves that same result out of the cache for &lt;code&gt;cacheTtl&lt;/code&gt; milliseconds (60 seconds by default) before checking the API again. That's the whole feature. Two bugs were hiding in how "serve that same result" actually decided what counted as "the same."&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Correctness bug — the cache key didn't include the one thing that changes on every real call: the variables.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The key was built from three fields:&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;cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;hashApiKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apiKey&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;name&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;alias&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;API key, prompt name, alias. Nothing about what you're actually asking. &lt;strong&gt;Two different renders of the same prompt with different variables, inside the same TTL window, hashed to the identical key&lt;/strong&gt; — so the second call got the first call's cached answer back, not its own.&lt;/p&gt;

&lt;p&gt;This wasn't a hypothetical. It was live in our own docs. The &lt;a href="https://docs.acruxcore.com/docs/tutorials/build-a-rag-agent-without-the-gateway" rel="noopener noreferrer"&gt;RAG-without-a-gateway tutorial&lt;/a&gt; renders the same prompt per question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;rendered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rag-chat&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;production&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;context&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ask "Where is my order?", then ask "How do I refund?" within 60 seconds, and the second call's &lt;code&gt;rendered.messages&lt;/code&gt; still says "Where is my order?" — while the trace viewer, which logs what you &lt;em&gt;passed in&lt;/em&gt;, correctly shows "How do I refund?" as the input. &lt;strong&gt;The trace and the actual prompt sent to the model disagree, and nothing errors.&lt;/strong&gt; It's also exactly why a sibling tutorial, &lt;a href="https://docs.acruxcore.com/docs/tutorials/build-a-tool-calling-agent-in-python-sdk" rel="noopener noreferrer"&gt;the tool-calling agent guide&lt;/a&gt;, never puts the question in a template variable at all — it appends it to the messages array in code instead, a design forced by this bug rather than a stylistic choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The second bug was in the same file, one function down:&lt;/strong&gt;&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cacheTtl&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;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// fresh — serve it directly&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// stale — serve the old value anyway, refresh in the background&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting &lt;code&gt;cacheTtl: 0&lt;/code&gt; to turn caching off did the opposite. &lt;code&gt;age &amp;lt; 0&lt;/code&gt; is never true, so every single call took the &lt;strong&gt;stale&lt;/strong&gt; branch: return the cached value immediately, and kick off a background refetch nobody's waiting on. A TTL of &lt;code&gt;0&lt;/code&gt; didn't mean "don't cache" — it meant "always serve whatever was cached first, forever." There was no setting that actually disabled the cache.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two bugs, one root cause
&lt;/h3&gt;

&lt;p&gt;Both come from the same gap: the cache didn't have a way to represent "this input is different" or "this call opted out." One key was too coarse; one flag had no matching branch. Fixing them meant adding both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;The fix is public in our mirror — same logic in both SDKs, shown here in TypeScript:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/AcruxCore/AcruxCore/blob/main/packages/sdk/src/client.ts" rel="noopener noreferrer"&gt;&lt;code&gt;packages/sdk/src/client.ts&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Bug 1 — hash the variables into the key:&lt;/strong&gt;&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="cm"&gt;/**
 * Serialises a value with object keys in sorted order, at every depth, so that
 * two variable maps that differ only in insertion order produce one string.
 */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;stableStringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="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;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;null&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`[&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stableStringify&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt; &lt;span class="o"&gt;=&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;value&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(([,&lt;/span&gt; &lt;span class="nx"&gt;v&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;v&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&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="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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`{&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;entries&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;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;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;k&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="nf"&gt;stableStringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hashVariables&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;stableStringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- const cacheKey = `${hashApiKey(this.apiKey)}:${name}:${alias}`;
&lt;/span&gt;&lt;span class="gi"&gt;+ const cacheKey = `${hashApiKey(this.apiKey)}:${name}:${alias}:${hashVariables(variables)}`;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Bug 2 — make a non-positive TTL actually skip the cache, on read and write:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;  async renderPrompt(name, alias, variables = {}) {
&lt;span class="gi"&gt;+   // A non-positive TTL means "never serve a cached render" — skip the read and
+   // pass a null key so the write side skips too, instead of filling the cache
+   // with entries no read path will ever consult.
+   if (this.cacheTtl &amp;lt;= 0) {
+     return this._fetchAndCache(name, alias, variables, null);
+   }
&lt;/span&gt;    const cache = getCache(DEFAULT_MAX_CACHE_SIZE);
    const cacheKey = `...`;
    ...
  }
&lt;span class="err"&gt;
&lt;/span&gt;  async _fetchAndCache(name, alias, variables, cacheKey /* now: string | null */) {
    ...
&lt;span class="gd"&gt;-   const cache = getCache(DEFAULT_MAX_CACHE_SIZE);
-   cache.set(cacheKey, { value, fetchedAt: Date.now() });
&lt;/span&gt;&lt;span class="gi"&gt;+   if (cacheKey !== null) {
+     const cache = getCache(DEFAULT_MAX_CACHE_SIZE);
+     cache.set(cacheKey, { value, fetchedAt: Date.now() });
+   }
&lt;/span&gt;    return value;
  }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Technical Approach
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why hash sorted keys at every depth instead of &lt;code&gt;JSON.stringify(variables)&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;JSON.stringify&lt;/code&gt; preserves insertion order. &lt;code&gt;{ name: 'Alice', city: 'Lahore' }&lt;/code&gt; and &lt;code&gt;{ city: 'Lahore', name: 'Alice' }&lt;/code&gt; are the same request to any caller, but they'd stringify to two different strings — and split into two cache entries for what should be one. A caller that builds the variables object by spreading defaults first (&lt;code&gt;{ ...defaults, question }&lt;/code&gt;) versus spreading them last would silently halve their own cache hit rate. Sorting keys at every depth, including inside nested objects, makes key order invisible to the cache. A dedicated unit test pins exactly 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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;treats the same variables in a different key order as one cache entry&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="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="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fetch&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;vi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fn&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;mockResolvedValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;makeOkResponse&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;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renderPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-prompt&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="s1"&gt;production&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Alice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Lahore&lt;/span&gt;&lt;span class="dl"&gt;'&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;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renderPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-prompt&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="s1"&gt;production&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;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Lahore&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Alice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveBeenCalledTimes&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why not just delete the "serve stale, refresh in background" path
&lt;/h3&gt;

&lt;p&gt;That's a real reliability feature, not accidental complexity: if the API is briefly unreachable, a caller with a warm cache keeps getting an answer instead of an error. Deleting it to fix the &lt;code&gt;cacheTtl: 0&lt;/code&gt; bug would trade one bug for a regression. The fix instead adds a third state — "don't cache at all" — that bypasses the stale-serving branch entirely rather than replacing it, so &lt;code&gt;cacheTtl: 30_000&lt;/code&gt; still falls back to a stale value during an outage, and only &lt;code&gt;cacheTtl: 0&lt;/code&gt; gives that up on purpose. Both behaviors are documented side by side in the README so the trade-off is explicit, not discovered during an incident.&lt;/p&gt;

&lt;h3&gt;
  
  
  Proving the fix actually fixes it, against a real API
&lt;/h3&gt;

&lt;p&gt;Unit tests mock &lt;code&gt;fetch&lt;/code&gt;. The two bugs above are about what a &lt;em&gt;real&lt;/em&gt; render pipeline does with &lt;em&gt;real&lt;/em&gt; prompt versions, so the integration suite hits an actual Express app and a real Postgres database instead:&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;renderPrompt renders the new variables inside the cache window instead of replaying the first render&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="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="c1"&gt;// ...creates a real prompt, a real version with "Question: {{ question }}"...&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;acruxcore&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cacheTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maxRetries&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;first&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;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renderPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&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;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Where is my order?&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;second&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;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renderPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&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;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;How do I refund?&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;repeat&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;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renderPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&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;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Where is my order?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;first&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="mi"&gt;0&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;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Question: Where is my order?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;second&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="mi"&gt;0&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;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Question: How do I refund?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repeat&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="mi"&gt;0&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;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Question: Where is my order?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// still cached, not evicted&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;I reverted &lt;code&gt;src/client.ts&lt;/code&gt; and re-ran this test before writing the fix.&lt;/strong&gt; It failed on exactly the assertion you'd expect — &lt;code&gt;second.messages[0].content&lt;/code&gt; came back as &lt;code&gt;'Question: Where is my order?'&lt;/code&gt;, the first question, not the second. That's the failure this whole post describes, caught by a test instead of a support ticket.&lt;/p&gt;

&lt;p&gt;A second integration test does the same for Bug 2 — &lt;code&gt;cacheTtl: 0&lt;/code&gt;, promote a new prompt version, confirm the very next call sees it instead of serving the version that was cached before the promotion.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two existing tests were asserting the bug
&lt;/h3&gt;

&lt;p&gt;The old unit test for caching was itself proof the bug had never been tested against a variable change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- it('caches the result — second call does not hit fetch', async () =&amp;gt; {
&lt;/span&gt;&lt;span class="gi"&gt;+ it('caches the result — repeating the same variables does not hit fetch', async () =&amp;gt; {
&lt;/span&gt;    await hub.renderPrompt('my-prompt', 'production', { name: 'Alice' });
&lt;span class="gd"&gt;-   await hub.renderPrompt('my-prompt', 'production', { name: 'Bob' });
&lt;/span&gt;&lt;span class="gi"&gt;+   await hub.renderPrompt('my-prompt', 'production', { name: 'Alice' });
&lt;/span&gt;    expect(global.fetch).toHaveBeenCalledTimes(1);
  });
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It rendered Alice, then Bob, and asserted &lt;strong&gt;one&lt;/strong&gt; fetch call — which is precisely the bug, written down as a passing test. Fixing the implementation without touching this test would have left it green while lying about what "caching" meant. It had to be rewritten to assert the correct behavior — different inputs, different fetches — before the fix could be trusted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;second &lt;code&gt;renderPrompt&lt;/code&gt; call, different variables, inside TTL&lt;/td&gt;
&lt;td&gt;first call's cached content (wrong)&lt;/td&gt;
&lt;td&gt;its own rendered content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;repeated call, same variables as the first, inside TTL&lt;/td&gt;
&lt;td&gt;cached content (correct, coincidentally)&lt;/td&gt;
&lt;td&gt;cached content (correct, by design)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;cacheTtl: 0&lt;/code&gt;, two calls&lt;/td&gt;
&lt;td&gt;1st content served both times&lt;/td&gt;
&lt;td&gt;fresh API call both times&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;entries written to the cache with &lt;code&gt;cacheTtl: 0&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;one per call&lt;/td&gt;
&lt;td&gt;zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;variables passed in a different key order&lt;/td&gt;
&lt;td&gt;new cache entry (cache pressure ↑)&lt;/td&gt;
&lt;td&gt;same entry, one hit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Test suite after the fix: 111 TS unit, 14 TS integration (both new ones failing-then-passing verified against the revert), 112 Python, clean &lt;code&gt;tsc --noEmit&lt;/code&gt;, clean docs build.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I'm carrying to the next cache I write
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A cache key has to include everything the caller varies, not just everything the caller identifies with.&lt;/strong&gt; API key and prompt name answer "who and what" — they don't answer "with what input," and that's the part that actually changes per call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Off" needs its own code path, not a boundary value on an existing one.&lt;/strong&gt; &lt;code&gt;0&lt;/code&gt; looked like it should mean "never fresh, so never serve cached" — the actual check (&lt;code&gt;age &amp;lt; ttl&lt;/code&gt;) made it mean the opposite. A dedicated bypass, not a clever value, is what you want for a real off-switch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A passing cache test that only ever sends one input is testing the plumbing, not the cache key.&lt;/strong&gt; The bug survived in production because the one existing test for "does caching work" happened to use two different inputs and asserted the outcome the bug produces.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If your SDK caches a rendered or computed result keyed by anything less than the full input, it's worth a five-minute check today: call it twice with different arguments inside your TTL window, and read back what you actually get. What did yours return?&lt;/p&gt;

</description>
      <category>bugsmash</category>
      <category>devchallenge</category>
      <category>devops</category>
      <category>ai</category>
    </item>
    <item>
      <title>LLM Observability Tools Compared: Langfuse vs Helicone vs Opik vs Phoenix</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Tue, 11 Aug 2026 19:00:00 +0000</pubDate>
      <link>https://dev.to/ailegend/llm-observability-tools-compared-langfuse-vs-helicone-vs-opik-vs-phoenix-2596</link>
      <guid>https://dev.to/ailegend/llm-observability-tools-compared-langfuse-vs-helicone-vs-opik-vs-phoenix-2596</guid>
      <description>&lt;h2&gt;
  
  
  The first trace looks the same everywhere
&lt;/h2&gt;

&lt;p&gt;Wrap your LLM client with any open-source observability SDK — Langfuse, Helicone, Opik, Phoenix, doesn't matter which — and the first result is identical: a request goes out, a span shows up in a dashboard with the prompt, the response, and how long it took. That part is solved. Every landing page in this space makes the same claim, and for a first trace, they're all telling the truth.&lt;/p&gt;

&lt;p&gt;The next question every team asks is also the same one: can this tool act on a trace, not just show me one? Score it. Alert someone. Block it before it ships. On the surface that looks solved too — all four platforms have an "Evaluation" tab and a "Settings" page, so it's tempting to assume they converge there as well.&lt;/p&gt;

&lt;p&gt;They don't. I have all four self-hosted right now, and going through each dashboard side by side, four specific capabilities stopped me — each one a genuine standout for exactly one platform, and absent from at least two of the other three. Picking a tool because "it does observability" and finding out six months later that the one feature you actually needed lives in a dashboard you didn't choose is an expensive way to learn this.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Langfuse&lt;/strong&gt; — an LLM judge that scores every live production trace on its own, no manual review step required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Helicone&lt;/strong&gt; — a dedicated rate-limit rule builder, a different object from a simple spend cap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opik&lt;/strong&gt; — topic and PII guardrails that inspect a call's input or output directly, configurable per project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phoenix&lt;/strong&gt; (built by Arize) — PXI, an AI agent docked inside the dashboard that already knows the trace you're looking at.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  At a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Langfuse&lt;/th&gt;
&lt;th&gt;Helicone&lt;/th&gt;
&lt;th&gt;Opik&lt;/th&gt;
&lt;th&gt;Phoenix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Auto-scores live traces, no manual trigger&lt;/td&gt;
&lt;td&gt;✅ LLM-as-a-judge&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ Online evaluation rules (same idea, different name)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dedicated rate-limit rule builder&lt;/td&gt;
&lt;td&gt;❌ (alerts only)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Topic / PII guardrails on the call itself&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI agent built into the dashboard&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ PXI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Opik is the only platform that clears two of the four rows. Full breakdown below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Langfuse: the judge that never sleeps
&lt;/h2&gt;

&lt;p&gt;Most platforms' "evaluation" means a human clicks run against a dataset. Langfuse's Evaluators page does something different: you write an eval template — a scoring prompt, a model, and the trace variables it should read — and it fires that template against every new production trace as it lands. There's no "run experiment" button in the loop at all.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;That's the deciding detail if you're comparing tools on this feature: is scoring something you trigger, or something that's already running before you open the dashboard?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://langfuse.com/docs/evaluation/evaluation-methods/llm-as-a-judge" rel="noopener noreferrer"&gt;Langfuse's own docs&lt;/a&gt; walk through setting one up. Opik's Online evaluation rules do the same job under a different name (see the table above), so Langfuse doesn't own this category outright — but it's the platform where this run found it first, with execution tracing on the evaluator itself as a bonus.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fswoff7sv1t5krsq8m1va.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fswoff7sv1t5krsq8m1va.png" alt="Langfuse's " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; if unattended, always-on trace scoring is the requirement, Langfuse and Opik are the two platforms here built for it. Helicone and Phoenix both still need someone to explicitly trigger a run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Helicone: a rate-limit rule, not a spend cap
&lt;/h2&gt;

&lt;p&gt;A spend budget answers one question: how much can this app cost before it stops. A rate-limit rule answers a different one: how many requests can this &lt;em&gt;specific user&lt;/em&gt; make in &lt;em&gt;this window&lt;/em&gt;, independent of what anything costs.&lt;/p&gt;

&lt;p&gt;Helicone's Monitor → Rate Limits page has a "Create Rule" builder for exactly that second question. A rule is a quota, a time window, a unit (requests, tokens, or dollars), and a segment — user, team, or global — expressed as a header like &lt;code&gt;10;w=1000;u=cents;s=user&lt;/code&gt;, which reads as "10 cents of requests per 1000 seconds, per user." &lt;a href="https://docs.helicone.ai/features/advanced-usage/custom-rate-limits" rel="noopener noreferrer"&gt;Helicone's docs&lt;/a&gt; cover the full syntax.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwgxi213k9vt9s9qtv1cc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwgxi213k9vt9s9qtv1cc.png" alt="Helicone's Rate Limit Rules tab: " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; if one noisy user needs throttling without shutting the whole app's budget down for everyone else, that's a rate-limit rule, and Helicone is the only one of the four that ships one as its own object.&lt;/p&gt;

&lt;h2&gt;
  
  
  Opik: guardrails that inspect the call itself
&lt;/h2&gt;

&lt;p&gt;Everything above scores or alerts &lt;em&gt;after&lt;/em&gt; a call happens. Opik's guardrails act on the call directly — before or after the model sees it, depending on which check you enable.&lt;/p&gt;

&lt;p&gt;The "Set a guardrail" panel has two checks: a Topic guardrail (a sensitivity slider plus a comma-separated restricted-topics list) and a PII guardrail (per-category toggles — credit card number, phone number, email, and more — each with its own sensitivity threshold). Both ship with a ready &lt;code&gt;opik.guardrails&lt;/code&gt; Python snippet. &lt;a href="https://www.comet.com/site/blog/opik-guardrails-release/" rel="noopener noreferrer"&gt;Comet's announcement post&lt;/a&gt; covers what triggers each one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fedyt8q84ko7jfhjlzvox.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fedyt8q84ko7jfhjlzvox.png" alt="Opik's " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; if the requirement is blocking a category of content before it ships, not scoring it afterward, guardrails are the only feature in this list built for that — and Opik is the only platform that has both this and an auto-scoring rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phoenix: PXI, the assistant that already knows your traces
&lt;/h2&gt;

&lt;p&gt;PXI is a chat panel that lives on every Phoenix page, seeded with suggestions like "Find critical issues" and "Explain a concept." The detail that makes it more than a docs chatbot bolted onto the product: it reads from whatever page you're already on, so it can answer questions about your own traces without you pasting a trace ID or copying a payload into it. &lt;a href="https://arize.com/docs/phoenix/pxi" rel="noopener noreferrer"&gt;Arize's docs&lt;/a&gt; describe it as an agent that debugs traces, builds evaluators, and runs experiments from the context you're already viewing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftqv5hentoqd2fkrwx0kk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftqv5hentoqd2fkrwx0kk.png" alt="Phoenix's " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; if "explain what's happening in this trace, right now, without switching tools" is the requirement, Phoenix is the only platform here with an assistant scoped to the page in front of you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;None of these four features decide which tool is "best" — they decide which tool fits a requirement you may not have hit yet. A team that never needs unattended scoring won't miss Langfuse's judge or Opik's online evaluation. A team running one internal chatbot for five people won't miss Helicone's rate-limit rules.&lt;/p&gt;

&lt;p&gt;But if you're picking a platform today assuming "observability" is a solved, interchangeable checkbox, this is the list of four ways that assumption breaks: automated live-trace scoring, a real rate-limit object, content guardrails, and an in-dashboard agent. Check which of the four you'll actually need before you wrap your first client, not after.&lt;/p&gt;

&lt;p&gt;Which of these four would you actually use — and is there a fifth dashboard feature I missed that deserves its own row?&lt;/p&gt;

&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;None of these four features decide which tool is "best" — they decide which tool fits a requirement you may not have hit yet. A team that never needs unattended scoring won't miss Langfuse's judge or Opik's online evaluation. A team running one internal chatbot for five people won't miss Helicone's rate-limit rules.&lt;/p&gt;

&lt;p&gt;But if you're picking a platform today assuming "observability" is a solved, interchangeable checkbox, this is the list of four ways that assumption breaks: automated live-trace scoring, a real rate-limit object, content guardrails, and an in-dashboard agent. Check which of the four you'll actually need before you wrap your first client, not after.&lt;/p&gt;

&lt;p&gt;This piece only had room for one standout feature per platform. AcruxCore has compared 8 &lt;a href="https://docs.acruxcore.com/blog/hands-on-llm-ops-comparison" rel="noopener noreferrer"&gt;LLM observability tools&lt;/a&gt; in more depth, aspect by aspect.&lt;/p&gt;

&lt;p&gt;Which of these four would you actually use — and is there a fifth dashboard feature I missed that deserves its own row?&lt;/p&gt;

</description>
      <category>llm</category>
      <category>saas</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Opik vs Langfuse: Where Two Open-Source LLM Observability Tools Actually Agree (and Where They Don't)</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Sun, 09 Aug 2026 19:55:19 +0000</pubDate>
      <link>https://dev.to/ailegend/opik-vs-langfuse-where-two-open-source-llm-observability-tools-actually-agree-and-where-they-2anp</link>
      <guid>https://dev.to/ailegend/opik-vs-langfuse-where-two-open-source-llm-observability-tools-actually-agree-and-where-they-2anp</guid>
      <description>&lt;h2&gt;
  
  
  The box both of them check
&lt;/h2&gt;

&lt;p&gt;If you're adding your first bit of visibility into an LLM app, the simplest version is a &lt;code&gt;print()&lt;/code&gt; statement before the API call. That's enough while you're the only one testing it.&lt;/p&gt;

&lt;p&gt;The natural next step is to swap that print statement for a real tracing SDK. You wrap your client, make a call, and a UI shows you a span with the prompt, the response, and how long it took. That feels solved — until your team grows past one prompt and one person testing it by hand.&lt;/p&gt;

&lt;p&gt;That's the point where the real friction shows up: conditional prompt logic, a way to diff two prompt versions before you ship one, a way to catch a call your app got wrong before a user complains, and a currently-live guardrail against risky output. "It produces a trace" doesn't cover any of that. And the frustrating part is that this doesn't fail loudly — most open-source, self-hosted LLM observability tools &lt;em&gt;look&lt;/em&gt; interchangeable from their landing pages, because they'd all check the same three boxes: open source, self-hosted, traces your calls.&lt;/p&gt;

&lt;p&gt;Opik (built by Comet ML) and Langfuse are two of the most-used tools in that box. So we tested the actual boundary: we built the exact same prompt — a support-triage agent that changes tone for VIP customers and lists their open tickets — on both, independently, then set the two runs side by side.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Both platforms hit the &lt;strong&gt;exact same wall&lt;/strong&gt; on prompt templating — neither supports real conditional logic, only flat variable substitution.&lt;/li&gt;
&lt;li&gt;Both confirmed, independently, that their &lt;strong&gt;Playground alone does not produce a trace&lt;/strong&gt; — only a wrapped SDK call does.&lt;/li&gt;
&lt;li&gt;They diverge sharply on &lt;strong&gt;prompt versioning&lt;/strong&gt;: Opik ships a real diff view and environment labels, Langfuse's version history has neither.&lt;/li&gt;
&lt;li&gt;Their &lt;strong&gt;measured client-side overhead&lt;/strong&gt; is different in kind, not just number — and the two numbers come from two separate test sessions, not one shared benchmark.&lt;/li&gt;
&lt;li&gt;Both have a feature that does the &lt;strong&gt;same job under a different name&lt;/strong&gt; — auto-scoring live production traffic.&lt;/li&gt;
&lt;li&gt;They've drifted into genuinely different territory: &lt;strong&gt;guardrails and regression test suites&lt;/strong&gt; (Opik) versus &lt;strong&gt;threshold alerting&lt;/strong&gt; (Langfuse).&lt;/li&gt;
&lt;li&gt;And as of five months ago, they're no longer comparable as two independent startups — &lt;strong&gt;Langfuse was acquired by ClickHouse in January 2026.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  At a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Opik&lt;/th&gt;
&lt;th&gt;Langfuse&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prompt templating&lt;/td&gt;
&lt;td&gt;Flat &lt;code&gt;{{variable}}&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;Flat &lt;code&gt;{{variable}}&lt;/code&gt; only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Playground produces a trace&lt;/td&gt;
&lt;td&gt;No — confirmed hands-on&lt;/td&gt;
&lt;td&gt;No — confirmed hands-on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Version diff&lt;/td&gt;
&lt;td&gt;Real red/green diff view&lt;/td&gt;
&lt;td&gt;No diff control found&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Environment labels&lt;/td&gt;
&lt;td&gt;"Deploy to" tags a version&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;production&lt;/code&gt;/&lt;code&gt;latest&lt;/code&gt; labels&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Measured SDK overhead&lt;/td&gt;
&lt;td&gt;+102ms (real, CI doesn't cross zero)&lt;/td&gt;
&lt;td&gt;+15ms (not distinguishable from zero)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto-scores live traffic&lt;/td&gt;
&lt;td&gt;Online Evaluation rules&lt;/td&gt;
&lt;td&gt;LLM-as-a-judge evaluators&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standout feature&lt;/td&gt;
&lt;td&gt;Guardrails (topic + PII) and test suites&lt;/td&gt;
&lt;td&gt;Monitors — alerts to Slack/Webhooks/GitHub&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub stars (checked today)&lt;/td&gt;
&lt;td&gt;21,252&lt;/td&gt;
&lt;td&gt;32,778&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Owned by&lt;/td&gt;
&lt;td&gt;Comet ML (in-house product)&lt;/td&gt;
&lt;td&gt;ClickHouse (acquired Jan 2026)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Full breakdown below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where they hit the exact same wall: templating
&lt;/h2&gt;

&lt;p&gt;Both platforms' prompt editors only do flat &lt;code&gt;{{variable}}&lt;/code&gt; substitution — no &lt;code&gt;{% if %}&lt;/code&gt;, no &lt;code&gt;{% for %}&lt;/code&gt;. We needed a VIP-customer branch and a rendered list of open tickets, and on both platforms we had to flatten that logic into plain text ourselves before the prompt ever saw it, keeping only the simple fields (like &lt;code&gt;{{company}}&lt;/code&gt;) as real template variables.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwi4ej995ehnwl2l0kp5q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwi4ej995ehnwl2l0kp5q.png" alt="Opik's New chat prompt dialog showing flat {{company}} and {{customer_message}} variable substitution with no conditional syntax available" width="800" height="775"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffiuppd14vci34fn9ec6d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffiuppd14vci34fn9ec6d.png" alt="Langfuse's create-prompt screen with a commit message noting the prompt was adapted to flat variables since the platform only supports substitution, not conditionals" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Neither product hides this — it's a real, shared design choice, not a bug in either one. If your prompts need branching logic, both push that logic back onto your own code (a pre-render step) rather than into the template itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where they hit the exact same wall, again: the Playground doesn't trace
&lt;/h2&gt;

&lt;p&gt;This one surprised us enough to check twice on each platform. Running a prompt from Opik's Playground left its Logs tab at "No traces yet." Running a prompt from Langfuse's Playground did the same thing — no trace appeared until we called the SDK-wrapped client instead. On both, the interactive "try it in the UI" surface and the "get a trace out of it" surface are two separate things, which is easy to miss if you only ever click around the Playground before wiring in the SDK.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where they genuinely diverge: prompt versioning
&lt;/h2&gt;

&lt;p&gt;Opik has a real "Diff" button — edit a prompt to create v2, and it shows the old system message in red strikethrough next to the new one in green. It also has a "Deploy to" menu that tags a specific version &lt;code&gt;production&lt;/code&gt;, &lt;code&gt;staging&lt;/code&gt;, or &lt;code&gt;development&lt;/code&gt; with a colored badge.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvxgbq9s8sgjuhfui5qa0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvxgbq9s8sgjuhfui5qa0.png" alt="Opik's Compare v1 to v2 panel, showing the old system message in red strikethrough on the left and the new message in green on the right" width="800" height="775"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fki85qtty5un6kdporbdg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fki85qtty5un6kdporbdg.png" alt="Opik's Deploy to menu, with v2 tagged production via a green badge next to the version number" width="800" height="775"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Langfuse has environment labels too (&lt;code&gt;production&lt;/code&gt; / &lt;code&gt;latest&lt;/code&gt;), but we could not find any diff or compare control anywhere in its version history UI — each version sits in a flat list, and comparing two of them means opening both and reading side by side yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the numbers genuinely differ (and why you shouldn't stack them)
&lt;/h2&gt;

&lt;p&gt;We measured client-side SDK overhead for both platforms — but in two separate benchmark sessions, run on different days as part of two different posts, each with its own direct-to-provider baseline. That matters: network conditions differ session to session, so these two numbers aren't from one shared interleaved test and shouldn't be read as a single ranked table.&lt;/p&gt;

&lt;p&gt;With that caveat stated plainly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Opik SDK&lt;/th&gt;
&lt;th&gt;Langfuse SDK&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Measured overhead&lt;/td&gt;
&lt;td&gt;+102ms&lt;/td&gt;
&lt;td&gt;+15ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;95% bootstrap CI&lt;/td&gt;
&lt;td&gt;[+14, +191]ms&lt;/td&gt;
&lt;td&gt;[-90, +150]ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Statistically real?&lt;/td&gt;
&lt;td&gt;Yes — CI does not cross zero&lt;/td&gt;
&lt;td&gt;No — CI crosses zero, indistinguishable from noise&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Comparison with other platform can be found at &lt;a href="https://acruxcore.com/compare" rel="noopener noreferrer"&gt;AcruxCore&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Opik's &lt;code&gt;track_openai()&lt;/code&gt; wrapper adds overhead that clears the noise floor in its own test session. Langfuse's instrumentation, in its own separate test session, didn't clear that floor at all — its "cost" was statistically zero. Both are real findings about &lt;em&gt;client-side instrumentation&lt;/em&gt;, not about which product is "faster" than the other, since neither routes a call through a gateway the way a proxy-style tool would.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same idea, different name: scoring production traffic automatically
&lt;/h2&gt;

&lt;p&gt;Both platforms have a feature that does the same underlying job — score live traffic as it lands, with no manual review step — under completely different names and UI.&lt;/p&gt;

&lt;p&gt;Langfuse's &lt;strong&gt;LLM-as-a-judge Evaluators&lt;/strong&gt; walk you through writing an eval template: a scoring prompt, a model to run it, and the trace variables it reads.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fswoff7sv1t5krsq8m1va.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fswoff7sv1t5krsq8m1va.png" alt="Langfuse's " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Opik's &lt;strong&gt;Online Evaluation&lt;/strong&gt; page, under Production, lets you create a rule that scores every matching trace as it arrives.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw0pmcz5148o8kp1y1663.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw0pmcz5148o8kp1y1663.png" alt="Opik's " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If a platform-neutral feature list only checked for the &lt;em&gt;name&lt;/em&gt; "online evaluation" or "LLM-as-a-judge," it would miss that both products actually offer this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where they've genuinely diverged: guardrails vs. alerting
&lt;/h2&gt;

&lt;p&gt;Opik ships a &lt;strong&gt;guardrails&lt;/strong&gt; panel — a topic-restriction check with a sensitivity slider and a comma-separated restricted-topics list, plus a PII (personally identifiable information) check that flags categories like credit card numbers, phone numbers, and email addresses, each with its own threshold. It also has a dedicated &lt;strong&gt;Test suites&lt;/strong&gt; object for pre-deployment regression testing, distinct from its Experiments feature — import cases from a CSV/JSON file or the SDK, each with an expected output and a scoring method.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fedyt8q84ko7jfhjlzvox.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fedyt8q84ko7jfhjlzvox.png" alt="Opik's " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Langfuse doesn't ship anything like that. What it has instead is &lt;strong&gt;Monitors&lt;/strong&gt; — set a threshold on cost, quality, or latency once, and get a notification routed to Slack, a webhook, or a GitHub Action when a metric moves outside it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu01imk1qzkx48gj45npm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu01imk1qzkx48gj45npm.png" alt="Langfuse's Monitors setup: " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Neither one has the other's feature. If you need to block a call before it reaches a user for containing PII, Langfuse has nothing for that today. If you need a Slack ping when your P95 latency creeps up, Opik has nothing for that today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who's behind them, and how big is each
&lt;/h2&gt;

&lt;p&gt;This is where the story changed since we first tested these products. Numbers below are live as of this week, pulled straight from each project's GitHub API.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Opik&lt;/th&gt;
&lt;th&gt;Langfuse&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub stars&lt;/td&gt;
&lt;td&gt;21,252&lt;/td&gt;
&lt;td&gt;32,778&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forks&lt;/td&gt;
&lt;td&gt;1,690&lt;/td&gt;
&lt;td&gt;3,524&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contributors&lt;/td&gt;
&lt;td&gt;~136&lt;/td&gt;
&lt;td&gt;~190&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open issues&lt;/td&gt;
&lt;td&gt;178&lt;/td&gt;
&lt;td&gt;764&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repo created&lt;/td&gt;
&lt;td&gt;May 2023&lt;/td&gt;
&lt;td&gt;May 2023&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both repos were created within a week of each other in May 2023 and are still receiving commits daily — this isn't a "one's abandoned" story. Langfuse has the larger community footprint on every raw count, including a notably higher open-issue count, which can mean more active usage surfacing more edge cases, or a smaller team keeping up with triage — we didn't dig into issue-close rates to tell which.&lt;/p&gt;

&lt;p&gt;The bigger difference is who owns each project. Opik is a product inside &lt;strong&gt;Comet ML&lt;/strong&gt;, a New York-based ML platform company founded in 2017, with $6.8M in disclosed seed funding (2018, 2020) and nothing disclosed since. Langfuse was an independent, Y Combinator-backed startup — until &lt;a href="https://clickhouse.com/blog/clickhouse-acquires-langfuse-open-source-llm-observability" rel="noopener noreferrer"&gt;ClickHouse acquired it on January 16, 2026&lt;/a&gt;, the same day ClickHouse announced a $400M Series D at a $15B valuation. Both companies have publicly committed to keeping Langfuse open source and self-hostable going forward.&lt;/p&gt;

&lt;p&gt;For a reader picking a platform to build on, that's a real signal worth weighing alongside the feature list: one tool is a side product of a smaller, steady company; the other just became an integration point inside a much larger, newly-flush infrastructure company. Neither is inherently the safer bet — but they're different bets.&lt;/p&gt;

&lt;p&gt;We also compared each project's documentation footprint via its own sitemap, with a caveat: Langfuse's sitemap covers its &lt;em&gt;entire&lt;/em&gt; domain (blog, changelog, academy, and docs together — 822 URLs, of which only 112 are strictly &lt;code&gt;/docs&lt;/code&gt;), while Opik's docs live under Comet's site and its sitemap is scoped to just that docs subtree (709 URLs). So "709 vs 822" isn't an apples-to-apples count of documentation depth — it's closer to "Opik's docs alone" versus "Langfuse's docs plus its blog, changelog, and handbook combined."&lt;/p&gt;

&lt;h2&gt;
  
  
  What we're not calling a winner
&lt;/h2&gt;

&lt;p&gt;We picked seven points, not the full feature list either platform ships, and skipped a symmetric "pro/con" for every row — some things really are one-sided (neither platform gained a fake counterweight just to look fair). Templating and Playground-tracing are genuine ties. Versioning favors Opik. The overhead numbers aren't comparable to each other, only to their own baselines. Guardrails/test-suites and Monitors/alerting are two teams building in different directions, not one being behind the other.&lt;/p&gt;

&lt;p&gt;If you're choosing between them today, the feature list would point you toward whichever gap matters more to your team — guardrails and regression testing, or threshold alerting. The ownership question is the one that didn't exist a year ago, and it might now matter just as much as either feature list.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sources: &lt;a href="https://github.com/comet-ml/opik" rel="noopener noreferrer"&gt;comet-ml/opik&lt;/a&gt; and &lt;a href="https://github.com/langfuse/langfuse" rel="noopener noreferrer"&gt;langfuse/langfuse&lt;/a&gt; on GitHub (checked 2026-08-10); &lt;a href="https://clickhouse.com/blog/clickhouse-acquires-langfuse-open-source-llm-observability" rel="noopener noreferrer"&gt;ClickHouse's acquisition announcement&lt;/a&gt;; &lt;a href="https://langfuse.com/blog/joining-clickhouse" rel="noopener noreferrer"&gt;Langfuse's own "joining ClickHouse" post&lt;/a&gt;. Product screenshots and hands-on findings are from our own independent testing of both platforms.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>opensource</category>
      <category>observability</category>
    </item>
    <item>
      <title>LangSmith Just Shipped an LLM Gateway. Here's the 3-Year Mess That Made Me Care</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Sat, 08 Aug 2026 13:33:14 +0000</pubDate>
      <link>https://dev.to/ailegend/langsmith-just-shipped-an-llm-gateway-heres-the-3-year-mess-that-made-me-care-1bfh</link>
      <guid>https://dev.to/ailegend/langsmith-just-shipped-an-llm-gateway-heres-the-3-year-mess-that-made-me-care-1bfh</guid>
      <description>&lt;p&gt;Just found that langsmith has launched gateway feature and I decided to share my experience. It is about how messy real LLM tooling decisions actually are, and why a small feature announcement from LangSmith made me sit up this month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it all started
&lt;/h2&gt;

&lt;p&gt;In August 2023, I onboarded my first RAG (retrieval-augmented generation — you fetch relevant documents first, then hand them to the model as context) chatbot client as an AI lead. Back then the models we had access to were much weaker and much less reliable than what you get today. Hallucinations were common, context windows were small, and you spent half your time just making the model behave.&lt;/p&gt;

&lt;p&gt;The setup was simple, though. One client, one model, one hardcoded prompt in the code. No observability tool. No gateway. It just worked because there was nothing to break.&lt;/p&gt;

&lt;h2&gt;
  
  
  The step that looked solved
&lt;/h2&gt;

&lt;p&gt;Then we got client number two. Then three. The obvious move was to copy the same folder, swap the API key, and ship it. For a while, that felt like scaling.&lt;/p&gt;

&lt;p&gt;It was not. Our prompts lived scattered across different codebases. Our logs sat in our own Postgres database, one table per project, with no shared view across clients. When something went wrong for one client, we had no fast way to tell if it was that client's prompt, a model change, or something else entirely.&lt;/p&gt;

&lt;p&gt;That is the real friction nobody warns you about. A broken prompt does not throw an error. It just quietly returns worse answers, and you might not notice for days — by which time a client has already noticed for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision that forced
&lt;/h2&gt;

&lt;p&gt;That friction split into two separate problems we had to solve on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Where do we store and version prompts, and see what is actually happening across every client's traces (a trace is the full record of one request — every step, every token, every millisecond)?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How do we talk to multiple model providers through one shape, so a provider outage does not become our outage?&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first is an observability or "LLM ops" platform. The second is a gateway. Here is the order we actually went through both, mistakes included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round one: LangSmith, and giving up on it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.langchain.com/langsmith" rel="noopener noreferrer"&gt;LangSmith&lt;/a&gt; is LangChain's platform for tracing LLM calls, storing and versioning prompts, and running evaluations. We picked it up early because it was the obvious default in the LangChain ecosystem, and we were already leaning on some LangChain pieces.&lt;/p&gt;

&lt;p&gt;We stuck with it for a few months before we dropped it. It kept breaking in ways that made us stop trusting the data it showed us — traces that did not line up with what we already had in our own Postgres logs. When your monitoring tool is less reliable than the thing it is monitoring, you stop using it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round two: the platform Anthropic later swallowed whole
&lt;/h2&gt;

&lt;p&gt;So we moved to &lt;a href="https://humanloop.com/" rel="noopener noreferrer"&gt;Humanloop&lt;/a&gt;, a platform built for the same prompt-management-and-evaluation job. It worked well enough for a good stretch.&lt;/p&gt;

&lt;p&gt;Then, in August 2025, &lt;a href="https://techcrunch.com/2025/08/13/anthropic-nabs-humanloop-team-as-competition-for-enterprise-ai-talent-heats-up" rel="noopener noreferrer"&gt;Anthropic acqui-hired Humanloop's founders and most of its team&lt;/a&gt; — no product, no IP, just the people. Humanloop as a platform sunset shortly after. If you have never seen an acqui-hire before: a bigger company hires the team, not the company, and the product itself just stops being maintained. We had to move a second time, through no fault of our own engineering choices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round three: back to LangSmith, warts and all
&lt;/h2&gt;

&lt;p&gt;By then LangSmith had matured, and honestly, we did not have many other doors left to knock on. We are still on it today. It is not the platform I would design from scratch — there are features we need that are still missing — but it is stable enough now, and it gives us the cross-client visibility we were missing back in 2023.&lt;/p&gt;

&lt;h2&gt;
  
  
  Meanwhile, the gateway problem: why we picked LiteLLM
&lt;/h2&gt;

&lt;p&gt;While all that platform-hopping was happening, we separately needed a gateway (a layer that sits between your app and every model provider, so your code talks to one consistent API instead of five different ones). We landed on &lt;a href="https://docs.litellm.ai/" rel="noopener noreferrer"&gt;LiteLLM&lt;/a&gt;, which we host ourselves on our own server.&lt;/p&gt;

&lt;p&gt;LiteLLM is not perfect. We know about specific bugs in it, and we live with them, because the thing it does right is the thing we cannot do without: &lt;a href="https://docs.litellm.ai/docs/proxy/reliability" rel="noopener noreferrer"&gt;automatic fallback&lt;/a&gt; to another model or provider when the primary one errors out or gets rate-limited. That single feature is probably the most-used piece of LiteLLM in our whole stack. When a provider has a bad day, our clients never know.&lt;/p&gt;

&lt;h2&gt;
  
  
  This month: LangSmith adds its own Gateway
&lt;/h2&gt;

&lt;p&gt;Then this month I noticed LangSmith has its own &lt;a href="https://www.langchain.com/langsmith/llm-gateway" rel="noopener noreferrer"&gt;LLM Gateway&lt;/a&gt; — &lt;a href="https://www.langchain.com/blog/introducing-llm-gateway" rel="noopener noreferrer"&gt;announced in private beta on May 13, 2026, and opened to public beta on July 30, 2026&lt;/a&gt;. My first reaction was genuinely good: one platform for traces, prompts, and provider routing instead of two separate systems to keep in sync.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwp1jzgsa652kgqeik9o8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwp1jzgsa652kgqeik9o8.png" alt="Langsmith introduces gateway functionality" width="800" height="445"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I went in assuming the one feature we could not give up — fallback — would still be missing, because the original May announcement listed model fallback under "where this is going," as a future plan rather than a shipped feature. I was wrong about that. By the July public beta, &lt;a href="https://www.langchain.com/langsmith/llm-gateway" rel="noopener noreferrer"&gt;LangSmith Gateway already ships configurable model fallbacks&lt;/a&gt;, chained across models, providers, and hosts, triggered by rate limits or provider errors. That is genuinely good news, since fallback is the one LiteLLM feature we would have refused to give up.&lt;/p&gt;

&lt;p&gt;But here is the part I still cannot skip: &lt;strong&gt;latency.&lt;/strong&gt; Our LiteLLM instance is self-hosted, on our own server. A request goes from our app to a local process and out to the provider — no extra hop over the public internet. LangSmith Gateway is a hosted, external service. Routing every completion through it adds a real network round trip we do not currently pay.&lt;/p&gt;

&lt;p&gt;A fallback that reads well on a feature page has not been through a real outage on our own traffic yet. Before we retire LiteLLM, I want to see two numbers with our own eyes: the added latency per request, and how the fallback behaves the first time a provider actually goes down on us, not in a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell you, if you were the one making this call
&lt;/h2&gt;

&lt;p&gt;Do not trust a feature list over battle scars, even when the feature list is telling the truth. Test the exact thing you depend on most, under the exact load you actually run, before you let go of the tool that is already working.&lt;/p&gt;

&lt;p&gt;Three years, four tools, one lesson that keeps repeating: the tool that wins is not the one with the longest feature list. It is the one that is still standing after the outage nobody planned for.&lt;/p&gt;

&lt;p&gt;So tell me your version of this. Has anyone already pushed LangSmith Gateway through a real provider outage or a hard rate limit on real production traffic? Or did you go through your own round of tool-hopping — what made you leave a platform, and what finally made one stick? I would genuinely like to hear it before we run our own test.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>discuss</category>
      <category>llm</category>
    </item>
    <item>
      <title>Every Check Said Our GA4 Analytics Worked. It Had Never Recorded a Visit</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Sat, 08 Aug 2026 10:06:48 +0000</pubDate>
      <link>https://dev.to/ailegend/every-check-said-our-ga4-analytics-worked-it-had-never-recorded-a-visit-1cn0</link>
      <guid>https://dev.to/ailegend/every-check-said-our-ga4-analytics-worked-it-had-never-recorded-a-visit-1cn0</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Adding analytics to a website is the most boring task in web development. Google hands you eight lines, you paste them above &lt;code&gt;&amp;lt;/head&amp;gt;&lt;/code&gt;, and traffic shows up in the dashboard about a minute later.&lt;/p&gt;

&lt;p&gt;On a real app you tidy it up a little. The measurement ID moves into an environment variable, the snippet becomes a module, and you add a cookie banner with &lt;a href="https://developers.google.com/tag-platform/security/guides/consent" rel="noopener noreferrer"&gt;Consent Mode&lt;/a&gt; so nothing is tracked before a visitor agrees. Still a small job, and it still looks finished when the banner renders.&lt;/p&gt;

&lt;p&gt;Here's the part nobody warns you about: &lt;strong&gt;every layer you just added can fail without saying a word.&lt;/strong&gt; A build tool can drop the env var. A queue can accept commands it will never run. A CI secret can point at a property nobody is watching. None of them throws. None of them logs. The site loads fine, the banner works, and the dashboard stays at zero.&lt;/p&gt;

&lt;p&gt;So the only check that means anything is the last one — did a hit actually arrive? This is the story of the three silent failures I had to clear before the answer was yes, and the second one nearly had me file a bug report against Google.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I work on &lt;a href="https://acruxcore.com" rel="noopener noreferrer"&gt;AcruxCore&lt;/a&gt;, an LLM ops platform. Two sites: the marketing site and dashboard (&lt;code&gt;apps/web&lt;/code&gt;, React + Vite, deployed in Docker), and the docs site (&lt;code&gt;apps/docs&lt;/code&gt;, Docusaurus, deployed to Cloudflare Pages).&lt;/p&gt;

&lt;p&gt;I shipped Google Analytics 4 to both in one PR. Consent Mode v2 with &lt;code&gt;analytics_storage&lt;/code&gt; denied by default, a cookie banner, consent stored as a first-party cookie shared across subdomains.&lt;/p&gt;

&lt;p&gt;I tested it properly before merging:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Playwright against a real dev server — accept, decline, reload, reopen from the footer&lt;/li&gt;
&lt;li&gt;built the bundle with the env var set and grepped the output for the measurement ID&lt;/li&gt;
&lt;li&gt;built it again with the var unset and confirmed the tag never loads&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All green. I merged it, rebuilt the production image, and opened Google's tag inspector on the live site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tag not detected.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1 — the tag was never in the bundle
&lt;/h2&gt;

&lt;p&gt;The cookie banner rendered on production, so the new frontend had definitely deployed. That's what made this confusing: the same PR shipped both, and only half of it arrived.&lt;/p&gt;

&lt;p&gt;I SSH'd into the VPS and worked backward from the artifact rather than forward from the config.&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;# search the actually-served bundle for Google's script host&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;acruxcore-web &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rc&lt;/span&gt; googletagmanager /usr/share/nginx/html/assets/
&lt;span class="c"&gt;# → 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero. Not a wrong ID, not a consent problem — the tag was never compiled in. A rebuild with &lt;code&gt;docker compose build web&lt;/code&gt; still gave zero, and a full &lt;code&gt;docker build --no-cache&lt;/code&gt; reproduced the same empty result.&lt;/p&gt;

&lt;p&gt;At that point I'd checked the Docker &lt;code&gt;ARG&lt;/code&gt;/&lt;code&gt;ENV&lt;/code&gt; lines, the &lt;code&gt;args:&lt;/code&gt; block in &lt;code&gt;docker-compose.yml&lt;/code&gt;, and the &lt;code&gt;define&lt;/code&gt; in &lt;code&gt;vite.config.ts&lt;/code&gt;. All three were correct. So I stopped reading config and ran the build one layer down, inside the same built image:&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;# bypass the orchestrator, call the bundler directly&lt;/span&gt;
&lt;span class="nv"&gt;GA4_MEASUREMENT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;G-XXXXXXXXXX npx vite build
&lt;span class="c"&gt;# → googletagmanager IS in the output&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;vite build&lt;/code&gt; produced the tag. &lt;code&gt;turbo run build&lt;/code&gt; did not. Same image, same env, same everything else. The bug wasn't in Docker, Compose or Vite — it was in the layer between them.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Turborepo was doing
&lt;/h3&gt;

&lt;p&gt;Our Dockerfile builds through the monorepo task runner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;npx turbo run build &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;@acruxcore/web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://turborepo.com/docs/reference/configuration#envmode" rel="noopener noreferrer"&gt;Turborepo's default &lt;code&gt;envMode&lt;/code&gt; is &lt;code&gt;strict&lt;/code&gt;&lt;/a&gt;. Strict means a task's process starts with a &lt;strong&gt;filtered&lt;/strong&gt; environment. It sees only the variables you declared in that task's &lt;code&gt;env&lt;/code&gt; config, plus any auto-inferred from a recognized framework's naming convention. For Vite, that's anything prefixed &lt;code&gt;VITE_&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Our variable is called &lt;code&gt;GA4_MEASUREMENT_ID&lt;/code&gt;. It is deliberately &lt;em&gt;not&lt;/em&gt; &lt;code&gt;VITE_&lt;/code&gt;-prefixed, because &lt;code&gt;vite.config.ts&lt;/code&gt; remaps it at build time:&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;define&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="s1"&gt;import.meta.env.VITE_GA4_MEASUREMENT_ID&lt;/span&gt;&lt;span class="dl"&gt;'&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GA4_MEASUREMENT_ID&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;?? ''&lt;/code&gt; is the whole tragedy in two characters. Turbo stripped &lt;code&gt;GA4_MEASUREMENT_ID&lt;/code&gt; &lt;strong&gt;before Vite's config file ever ran&lt;/strong&gt;, so &lt;code&gt;process.env.GA4_MEASUREMENT_ID&lt;/code&gt; was &lt;code&gt;undefined&lt;/code&gt;, the fallback kicked in, and the bundle compiled a perfectly valid empty string. Analytics reads the ID, finds &lt;code&gt;''&lt;/code&gt;, and correctly decides not to load. No error, because nothing went wrong — every layer did exactly what it was told.&lt;/p&gt;

&lt;p&gt;Docker and Compose passing the variable in correctly made no difference. Turbo removed it after they handed it over.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx turbo run build &lt;span class="nt"&gt;--dry&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;@acruxcore/web &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'.tasks[0].envMode, .tasks[0].environmentVariables.specified.env'&lt;/span&gt;
&lt;span class="c"&gt;# → "strict"&lt;/span&gt;
&lt;span class="c"&gt;# → []      ← nothing declared, so nothing gets through&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is one line in &lt;code&gt;turbo.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; "build": {
   "dependsOn": ["^build"],
   "outputs": ["dist/**", "build/**", ".docusaurus/**"],
&lt;span class="gi"&gt;+  "env": ["SENTRY_WEB_DSN", "GA4_MEASUREMENT_ID"]
&lt;/span&gt; }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Mirror file: &lt;a href="https://github.com/AcruxCore/AcruxCore/blob/main/turbo.json" rel="noopener noreferrer"&gt;&lt;code&gt;turbo.json&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The part that actually scared me
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;GA4_MEASUREMENT_ID&lt;/code&gt; wasn't alone in that &lt;code&gt;env&lt;/code&gt; array. &lt;code&gt;SENTRY_WEB_DSN&lt;/code&gt; was there too, and I only added it because it has the same shape — a non-&lt;code&gt;VITE_&lt;/code&gt; name remapped in the same &lt;code&gt;define&lt;/code&gt; block.&lt;/p&gt;

&lt;p&gt;Then I reverted the fix and re-ran the build with &lt;code&gt;SENTRY_WEB_DSN&lt;/code&gt; set, the same way I'd reproduced the GA4 failure. Identical result: stripped, empty string, no SDK.&lt;/p&gt;

&lt;p&gt;Sentry had been wired into the web app six days earlier. In that window, the browser SDK on the deployed site had almost certainly never sent a single error report.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And nothing would ever have told me.&lt;/strong&gt; A quiet analytics dashboard is at least ambiguous — maybe nobody visited. A quiet error monitor looks like &lt;em&gt;good news&lt;/em&gt;. That is the failure mode I keep thinking about: the tool whose whole job is telling you when something is wrong can be the one thing that's broken, and its silence reads as success.&lt;/p&gt;

&lt;p&gt;What wasn't affected. The docs site builds in CI with &lt;code&gt;npm run build -w @acruxcore/docs&lt;/code&gt; — never through Turbo — so it picked up the measurement ID on the first deploy. Half the platform worked from day one, which is exactly why I initially suspected something specific to the marketing site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2 — the tag loaded, the queue filled, nothing was sent
&lt;/h2&gt;

&lt;p&gt;New image deployed. &lt;code&gt;googletagmanager&lt;/code&gt; now in the bundle, script loading in the network tab, cookie banner working, consent granted.&lt;/p&gt;

&lt;p&gt;GA4 Realtime: &lt;strong&gt;zero users.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I went through the browser console expecting an obvious break, and instead found every single signal healthy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;code&gt;https://www.googletagmanager.com/gtag/js?id=…&lt;/code&gt; script loads with a 200&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;window.google_tag_manager&lt;/code&gt; is defined&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;window.dataLayer.push !== Array.prototype.push&lt;/code&gt; — gtag.js has replaced the queue's push, so it &lt;em&gt;is&lt;/em&gt; watching&lt;/li&gt;
&lt;li&gt;the queue fills up with my &lt;code&gt;consent&lt;/code&gt;, &lt;code&gt;js&lt;/code&gt; and &lt;code&gt;config&lt;/code&gt; commands&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And no request to &lt;code&gt;/g/collect&lt;/code&gt;. No &lt;code&gt;_ga&lt;/code&gt; cookie. Nothing dispatched, ever.&lt;/p&gt;

&lt;p&gt;I also ran GA4's own &lt;strong&gt;"Test your website"&lt;/strong&gt; check from the admin panel. It went green — which I now know only means the tag script is present on the page. It never checks whether a hit arrives.&lt;/p&gt;

&lt;h3&gt;
  
  
  The wrong turn
&lt;/h3&gt;

&lt;p&gt;Everything on my side was correct, so I started building the case that the problem was Google's.&lt;/p&gt;

&lt;p&gt;I set up a second, completely unrelated GA4 property and pointed the site at it. Identical zero-dispatch behaviour. That felt conclusive: not my account, not my property, not my tag config.&lt;/p&gt;

&lt;p&gt;I got as far as writing a standalone &lt;code&gt;ga4-zero-hits-repro.html&lt;/code&gt; to hand over, drafting the issue report, and opening a PR to track the escalation. The report was finished and queued to post.&lt;/p&gt;

&lt;h3&gt;
  
  
  The moment it broke
&lt;/h3&gt;

&lt;p&gt;Before posting I re-read my own repro page, and the thing I'd been ignoring finally registered. I had &lt;strong&gt;hand-written that page from Google's documentation&lt;/strong&gt; rather than copying the code my product actually runs.&lt;/p&gt;

&lt;p&gt;Which means it contained Google's canonical stub:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(){&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&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="nx"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And my product contained mine:&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;function&lt;/span&gt; &lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&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="nx"&gt;args&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;Those look equivalent. TypeScript is happy, ESLint prefers the second one, and every reviewer including me read straight past it. My "repro of the bug" had quietly swapped out the one line that &lt;em&gt;was&lt;/em&gt; the bug.&lt;/p&gt;

&lt;p&gt;So I stopped reasoning and isolated the single variable. Two pages served from the same local origin, same measurement ID, same Consent Mode sequence, differing only in the push form:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;stub&lt;/th&gt;
&lt;th&gt;hits to &lt;code&gt;/g/collect&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;_ga&lt;/code&gt; cookie&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;push(args)&lt;/code&gt; — what shipped&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;not set&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;push(arguments)&lt;/code&gt; — canonical&lt;/td&gt;
&lt;td&gt;2 (&lt;code&gt;page_view&lt;/code&gt;, &lt;code&gt;scroll&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;set&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One line. Everything else identical.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why &lt;code&gt;arguments&lt;/code&gt; is load-bearing
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;gtag.js&lt;/code&gt; replaces &lt;code&gt;dataLayer.push&lt;/code&gt; with its own processor. That queue carries two different kinds of thing, and the processor has to tell them apart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;commands&lt;/strong&gt; — &lt;code&gt;gtag('config', 'G-…')&lt;/code&gt;, &lt;code&gt;gtag('consent', 'update', {…})&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;data-layer objects&lt;/strong&gt; — ordinary values other tags push in for their own use&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It tells them apart by checking whether the pushed value is a real &lt;code&gt;arguments&lt;/code&gt; object. A rest parameter gives you a plain &lt;code&gt;Array&lt;/code&gt; instead, which fails that check — so the value gets filed away as data and no command ever runs.&lt;/p&gt;

&lt;p&gt;That includes &lt;code&gt;config&lt;/code&gt;. Without &lt;code&gt;config&lt;/code&gt;, the tag is initialised with no measurement ID, so it has nothing to send and no reason to complain. Everything downstream still looks alive because everything downstream &lt;em&gt;is&lt;/em&gt; alive — the script really did load, the queue really is hooked, the commands really are in it. They're just sitting there.&lt;/p&gt;

&lt;p&gt;I didn't get this from the docs. It's just the only difference between the two rows of that table. It's also a decent rule of thumb: when a vendor snippet uses an unfashionable &lt;code&gt;function(){}&lt;/code&gt; where a modern one would do, assume the old form is doing something.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Lint rules encode what's usually true. &lt;code&gt;prefer-rest-params&lt;/code&gt; is right nearly every time, and this is the case where "nearly" costs you all your data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;Keep Google's original stub verbatim, and leave a note loud enough to survive the next cleanup:&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="cm"&gt;/**
 * **`arguments` here is load-bearing — do not "modernize" it into a rest
 * parameter.** gtag.js tells a command apart from a data-layer object by
 * checking the pushed value is a real `arguments` object. A plain array is
 * silently ignored: the tag loads, the queue fills, and not one hit is sent.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GtagFn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="c1"&gt;// eslint-disable-next-line prefer-rest-params -- a rest array is not a command&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&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="nx"&gt;arguments&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;ul&gt;
&lt;li&gt;Mirror file: &lt;a href="https://github.com/AcruxCore/AcruxCore/blob/main/apps/web/src/lib/analytics.ts" rel="noopener noreferrer"&gt;&lt;code&gt;apps/web/src/lib/analytics.ts&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A comment alone wasn't enough for me here, because the failure is invisible and the "cleanup" that reintroduces it is a one-character edit. So the test asserts the exact property that matters:&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;queues each command as an `arguments` object, which is what gtag.js recognizes&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="nx"&gt;vi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stubGlobal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;window&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;as&lt;/span&gt; &lt;span class="nx"&gt;Window&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;config&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="s1"&gt;G-TEST123&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;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queue&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="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[object Arguments]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queue&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="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;IArguments&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;config&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="s1"&gt;G-TEST123&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;Rewrite the stub as a rest parameter and that test fails immediately with &lt;code&gt;[object Array]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Verified end to end against a real &lt;code&gt;vite build&lt;/code&gt;: accepting the consent banner produces a &lt;code&gt;gcs=G101&lt;/code&gt; hit and sets &lt;code&gt;_ga&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3 — the site that worked was reporting to nobody
&lt;/h2&gt;

&lt;p&gt;While isolating bug 2 I went back to the docs site as a control, because it had looked healthy from day one. Its snippet was hand-written directly into &lt;code&gt;docusaurus.config.ts&lt;/code&gt; and never shared code with &lt;code&gt;apps/web&lt;/code&gt;, so it had the correct &lt;code&gt;arguments&lt;/code&gt; form all along.&lt;/p&gt;

&lt;p&gt;It was sending hits. It was sending them to the wrong property.&lt;/p&gt;

&lt;p&gt;Its measurement ID comes from a GitHub Actions secret that was never updated when the property changed, so every visit it recorded had been landing somewhere nobody was looking. No code change — update the secret, re-run the workflow — but it made the point for a third time in one week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Working and reporting where you're watching are two different states&lt;/strong&gt;, and only one of them shows up on a dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before and after
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GA4 tag in the production bundle&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sentry browser SDK in the production bundle&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gtag commands recognized by gtag.js&lt;/td&gt;
&lt;td&gt;0 of 3&lt;/td&gt;
&lt;td&gt;3 of 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hits reaching &lt;code&gt;/g/collect&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;page_view&lt;/code&gt;, &lt;code&gt;scroll&lt;/code&gt;, consent hits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;docs-site hits landing in the watched property&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;all of them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;checks that had reported a problem&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 failing unit test if it regresses&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the one I'd frame. Before the fix, the total number of automated signals warning me about any of this was zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm carrying to the next codebase
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A repro has to copy the product's code path, not your memory of it.&lt;/strong&gt; I re-typed Google's snippet from the docs, which silently replaced the variable under test, and that one shortcut is the entire reason I wrote a bug report against Google. Paste the real function in and the two-page test finds it in ten minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing a second account felt like ruling something out. It ruled out nothing.&lt;/strong&gt; Both accounts ran the same broken client code, so the experiment could only ever return the same answer. A control group that shares the defect isn't a control group — and the confidence it gave me is what pushed me toward blaming Google instead of looking closer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assert the last link, not the layers.&lt;/strong&gt; I had a check for the env var, a check for the bundle, a check for the banner, and Google's own check for the tag. Four green lights, zero data. Every one of them verified a &lt;em&gt;precondition&lt;/em&gt; for a hit; none verified a hit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When one config gap swallows a variable, check every variable of the same shape.&lt;/strong&gt; &lt;code&gt;SENTRY_WEB_DSN&lt;/code&gt; was found only because it looked like &lt;code&gt;GA4_MEASUREMENT_ID&lt;/code&gt;, and it was the more dangerous of the two — nobody files a ticket saying "I'm getting suspiciously few errors."&lt;/p&gt;




&lt;p&gt;If you've got analytics or error monitoring wired into a monorepo build, two things are worth ten minutes today: grep your deployed bundle for the tag host, and check the type of what your &lt;code&gt;gtag&lt;/code&gt; stub actually pushes.&lt;/p&gt;

&lt;p&gt;What did yours turn out to be — an &lt;code&gt;Array&lt;/code&gt;, or &lt;code&gt;Arguments&lt;/code&gt;?&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
