<?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: build996</title>
    <description>The latest articles on DEV Community by build996 (@build996).</description>
    <link>https://dev.to/build996</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%2F3909730%2F6972eddd-4c8f-475b-a284-e5755d0ce323.jpeg</url>
      <title>DEV Community: build996</title>
      <link>https://dev.to/build996</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/build996"/>
    <language>en</language>
    <item>
      <title>What's a number you published that turned out to describe your tools, not your subject?</title>
      <dc:creator>build996</dc:creator>
      <pubDate>Mon, 09 Nov 2026 02:09:48 +0000</pubDate>
      <link>https://dev.to/build996/whats-a-number-you-published-that-turned-out-to-describe-your-tools-not-your-subject-1g1d</link>
      <guid>https://dev.to/build996/whats-a-number-you-published-that-turned-out-to-describe-your-tools-not-your-subject-1g1d</guid>
      <description>&lt;p&gt;I once published a host's "21% failure rate" after seven of a speed test's 33 nodes failed. Then the other three hosts returned the same 26/33 result: I had attributed the measuring service's failing probes to the host.&lt;/p&gt;

&lt;p&gt;The arithmetic wasn't the problem. The explanation attached to it was.&lt;/p&gt;

&lt;p&gt;That is the kind of mistake I'm interested in here: a number you could reproduce, but whose apparent meaning changed when you checked another target, inspected the response, or separated the measurement into phases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Have you published or shared a number that turned out to describe your tools rather than the thing you were testing?&lt;/strong&gt; A dashboard, internal report, code review, or benchmark result counts; it doesn't have to have reached a public article.&lt;/p&gt;

&lt;p&gt;I'd like to hear:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What did you initially think the number meant?&lt;/li&gt;
&lt;li&gt;What check made you change that interpretation?&lt;/li&gt;
&lt;li&gt;Did you correct the number itself, its label, or the conclusion people drew from it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm especially curious about the last part. "These probes failed" can remain a valid observation even when "this host is unreliable" isn't supported by it. Keeping the observation while withdrawing the explanation seems useful, but it can be awkward once people have already acted on the original claim.&lt;/p&gt;

&lt;p&gt;What did your correction look like, and what would have helped you catch the attribution error earlier?&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>testing</category>
      <category>career</category>
    </item>
    <item>
      <title>My benchmark reported 4,393,158 tokens per second</title>
      <dc:creator>build996</dc:creator>
      <pubDate>Thu, 05 Nov 2026 08:12:16 +0000</pubDate>
      <link>https://dev.to/build996/my-benchmark-reported-4393158-tokens-per-second-35cc</link>
      <guid>https://dev.to/build996/my-benchmark-reported-4393158-tokens-per-second-35cc</guid>
      <description>&lt;p&gt;My API benchmark once reported &lt;strong&gt;4,393,158 tokens per second&lt;/strong&gt;. The useful result was finding out what that number actually measured.&lt;/p&gt;

&lt;p&gt;The harness timed a streaming response and calculated throughput like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;generation_time = total_request_time - time_to_first_token
tokens_per_second = output_tokens / generation_time
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That calculation assumes the client observes tokens arriving over a meaningful interval after the first token. Some endpoints in the run instead held the response and delivered it in a burst. The first observed content arrived close to the end of the request. Subtracting those two timestamps left an interval close to zero.&lt;/p&gt;

&lt;p&gt;Dividing the entire output by that tiny interval produced the spectacular number. It described the burst arriving at the client, not the model generating millions of tokens per second.&lt;/p&gt;

&lt;h2&gt;
  
  
  A streaming request is not proof of observable generation
&lt;/h2&gt;

&lt;p&gt;Asking for streaming does not guarantee that the timing seen by the client exposes the underlying generation process. From that trace alone, I could not locate the buffering inside the serving path. I could only see that the arrivals did not support the throughput calculation I was making.&lt;/p&gt;

&lt;p&gt;This is the distinction I needed in the results:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Post-first-content throughput:&lt;/strong&gt; output divided by the observed interval after first content, when the stream provides a useful interval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;End-to-end throughput:&lt;/strong&gt; output divided by the whole request duration, including waiting before content arrives.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second number is useful for the caller, but it is a different metric. Quietly putting both in one ranking would hide the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  The guard I added
&lt;/h2&gt;

&lt;p&gt;The benchmark now falls back to end-to-end timing when a streaming response has fewer than three data chunks or the calculated generation interval is below 0.2 seconds. It also withholds the first-token metric for responses classified as buffered. Empty output becomes an error rather than a row claiming zero tokens per second.&lt;/p&gt;

&lt;p&gt;Here is the relevant logic, simplified from the harness:&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;generation_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;total_time&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;first_content_time&lt;/span&gt;
&lt;span class="n"&gt;buffered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chunk_count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;generation_time&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;buffered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;generation_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;total_time&lt;/span&gt;

&lt;span class="n"&gt;throughput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;output_tokens&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;generation_time&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those thresholds are conservative measurement guards, &lt;strong&gt;not a universal detector of server buffering&lt;/strong&gt;. A short, fast response can also produce a small interval. The harness has a separate streaming capability probe that uses chunk count; its comments explicitly warn against treating a short duration alone as evidence that streaming is fake.&lt;/p&gt;

&lt;p&gt;The fallback gives the run a usable wall-clock measurement. It does not recover the hidden generation speed, and I should not label it as though it did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce the failure without calling a model
&lt;/h2&gt;

&lt;p&gt;For a synthetic illustration, suppose 1,000 output tokens arrive after a ten-second wait, with only 0.001 seconds between the first and last observed content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;post-first-content calculation: 1,000 / 0.001 = 1,000,000 tokens/s
end-to-end calculation:         1,000 / 10    =       100 tokens/s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are illustrative inputs, not the raw timings behind my 4,393,158 result. They show why the denominator matters: the same output can produce radically different rates depending on the interval being measured.&lt;/p&gt;

&lt;p&gt;Before trusting an unusually fast row, I now want the total duration, first-content timing, output token count, chunk count, and the metric actually used beside it. If the trace only exposes a final burst, the honest result is end-to-end throughput with the generation rate left unknown.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>benchmarks</category>
      <category>testing</category>
    </item>
    <item>
      <title>Changing my agent's default model did not change its existing sessions</title>
      <dc:creator>build996</dc:creator>
      <pubDate>Mon, 02 Nov 2026 02:13:16 +0000</pubDate>
      <link>https://dev.to/build996/changing-my-agents-default-model-did-not-change-its-existing-sessions-4p67</link>
      <guid>https://dev.to/build996/changing-my-agents-default-model-did-not-change-its-existing-sessions-4p67</guid>
      <description>&lt;p&gt;A model setting on disk can be correct without describing the model an existing conversation will use.&lt;/p&gt;

&lt;p&gt;I hit that distinction while repairing a local OpenClaw installation in September. This is a historical debugging note about that installation, initially running version 2026.4.1, rather than a guide to the current release's storage format.&lt;/p&gt;

&lt;p&gt;The visible symptom was an empty assistant reply. One configured NVIDIA endpoint returned &lt;code&gt;410&lt;/code&gt; with a message saying the model had reached its end of life on July 27. My catalogue check at the time found all eight configured models gone, and the diagnostic state showed &lt;code&gt;fallbackConfigured: false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That explained why the old model could not answer. It did not explain why changing a default was insufficient.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default was only one copy
&lt;/h2&gt;

&lt;p&gt;My setup had model settings in several places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;~/.openclaw/openclaw.json&lt;/code&gt;, under &lt;code&gt;agents.defaults.model.primary&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Agent-specific entries in &lt;code&gt;agents.list[]&lt;/code&gt;, whose &lt;code&gt;model&lt;/code&gt; value was a string in that version.&lt;/li&gt;
&lt;li&gt;Persisted state under &lt;code&gt;~/.openclaw/agents/&amp;lt;id&amp;gt;/sessions/sessions.json&lt;/code&gt; and the agent model store, &lt;code&gt;agents/models.json&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The persisted session copies overrode the settings above them in this installation. Editing only the main configuration left existing sessions using their saved values.&lt;/p&gt;

&lt;p&gt;The useful distinction was between choosing a default and changing an already-created conversation. I had been treating those as the same operation. The application had stored enough state to make them different operations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Search for the value you are trying to remove
&lt;/h2&gt;

&lt;p&gt;Reading the new default back proves that one setting changed. It does not establish that every consumer uses it.&lt;/p&gt;

&lt;p&gt;For this kind of failure, I would start by backing up the configuration directory, then search the relevant configuration and session stores for the old model identifier. Inspect which agent and session each match belongs to before changing anything. A historical transcript mentioning a model is different from a field selecting it.&lt;/p&gt;

&lt;p&gt;Then compare an existing session with a newly created one and inspect the model actually selected for a request. That comparison is a diagnostic I recommend; I do not have a recorded old-versus-new session experiment from this incident to present as a result.&lt;/p&gt;

&lt;p&gt;If the two differ, the next question becomes concrete: which saved selection does the existing session retain, and what supported operation updates it? Repeating an edit to the default cannot answer that question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not turn a storage detail into a permanent recipe
&lt;/h2&gt;

&lt;p&gt;During a later upgrade of the same installation to 2026.8.1, the configuration schema and session storage changed again. The old session JSON stores became migration blockers, and new SQLite stores were created after the legacy files were moved aside. That procedure lost the old chat history; the entire configuration directory had been backed up first.&lt;/p&gt;

&lt;p&gt;That is why I would not prescribe deleting a session file as the general fix for a stale model selection. The file layout belongs to a particular version, and recovery can have a history cost.&lt;/p&gt;

&lt;p&gt;The lasting debugging check is smaller: when a default changes but an existing conversation behaves the same way, inspect the conversation's saved selection as well as the default. A valid configuration value is evidence about that value. The dispatched request is evidence about what the agent actually used.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>debugging</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>I asked a deleted file to speak. It lied about line 9.</title>
      <dc:creator>build996</dc:creator>
      <pubDate>Wed, 28 Oct 2026 16:21:54 +0000</pubDate>
      <link>https://dev.to/build996/i-asked-a-deleted-file-to-speak-it-lied-about-line-9-1j01</link>
      <guid>https://dev.to/build996/i-asked-a-deleted-file-to-speak-it-lied-about-line-9-1j01</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week4-2026-10-26"&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Deleting a file from a Git repository does not quite kill it. The last surviving version is still in the parent of the deletion commit. I wanted to see what would happen if an open-weight model tried to speak as that file — with the actual source and commit attached, so I could challenge its memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ghostline&lt;/strong&gt; is a small Python CLI. Point it at a repository and a deleted path. It finds the most recent deletion commit, retrieves the file from the preceding revision, and gives numbered evidence blocks to an open-weight model. The model writes a short first-person “ghost” account and selects citations. The CLI refuses to print an answer if any cited evidence ID does not exist.&lt;/p&gt;

&lt;p&gt;The eerie part came when the model cited &lt;em&gt;real&lt;/em&gt; lines and still told a false story about them.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://github.com/build996/ghostline/releases/download/v0.1.0/ghostline-demo.mp4" rel="noopener noreferrer"&gt;Watch the 15-second demo&lt;/a&gt; · &lt;a href="https://github.com/build996/ghostline/blob/main/demo/captured-run.txt" rel="noopener noreferrer"&gt;Read the complete captured run&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The demo uses a deliberately fictional, two-commit repository generated by &lt;code&gt;demo/make_history.py&lt;/code&gt;. Its &lt;code&gt;legacy_cache.py&lt;/code&gt; file contains a fallback read. The second commit deletes the file with the message “Remove legacy cache after switching to direct reads.” I ran ghostline against that history using NVIDIA's hosted Nemotron 3.5 Lightning open-weight model.&lt;/p&gt;

&lt;p&gt;The model's voice said the assignment on line 9 “would have stored a potentially failed value.” That sounds plausible. It even cited the source block containing line 9. But the preceding block says:&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;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cache&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;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;On the timeout path, the function returns before the assignment. The model's interpretation was wrong. The video freezes on that moment. I left the mistake in the captured run because the distinction is the point of the tool: &lt;strong&gt;a valid citation is a place to check, not a guarantee that the sentence is true.&lt;/strong&gt;&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/build996" rel="noopener noreferrer"&gt;
        build996
      &lt;/a&gt; / &lt;a href="https://github.com/build996/ghostline" rel="noopener noreferrer"&gt;
        ghostline
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &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;ghostline&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;A tiny code séance for &lt;strong&gt;Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine&lt;/strong&gt;. Give it a deleted file in a Git repository. It retrieves the file's last surviving version and deletion commit, then asks an open-weight model to write a short first-person testimony. Every response must cite real evidence IDs; an invented citation makes the run fail.&lt;/p&gt;

&lt;p&gt;This is a new project created during the October 26–31, 2026 challenge window. MIT licensed. Python 3.10+, Git, and no Python package dependencies.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Reproduce the demo&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;The demo history is &lt;strong&gt;fictional and generated locally&lt;/strong&gt;. It is not presented as a production incident.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/build996/ghostline/releases/download/v0.1.0/ghostline-demo.mp4" rel="noopener noreferrer"&gt;Watch the captured 15-second demo&lt;/a&gt;. The video is rendered from an actual model run saved in &lt;a href="https://github.com/build996/ghostline/demo/captured-run.txt" rel="noopener noreferrer"&gt;&lt;code&gt;demo/captured-run.txt&lt;/code&gt;&lt;/a&gt;. It shows a mistake the model made about the cache write and the source lines that disprove it.&lt;/p&gt;

&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;python demo/make_history.py demo-history
python ghostline.py demo-history --file legacy_cache.py --evidence-only
&lt;/pre&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/build996/ghostline" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://github.com/build996/ghostline" rel="noopener noreferrer"&gt;MIT-licensed repository&lt;/a&gt; was created during this Week 4 challenge window. It has the CLI, a script that builds the fictional Git history, the saved model output, the video renderer, and three tests. The CLI itself uses only Python's standard library and Git.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python demo/make_history.py demo-history
python ghostline.py demo-history &lt;span class="nt"&gt;--file&lt;/span&gt; legacy_cache.py &lt;span class="nt"&gt;--evidence-only&lt;/span&gt;
&lt;span class="c"&gt;# Set NVIDIA_API_KEY, then:&lt;/span&gt;
python ghostline.py demo-history &lt;span class="nt"&gt;--file&lt;/span&gt; legacy_cache.py
python &lt;span class="nt"&gt;-m&lt;/span&gt; unittest discover &lt;span class="nt"&gt;-s&lt;/span&gt; tests &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Git does the archaeology. &lt;code&gt;git log --diff-filter=D&lt;/code&gt; finds the deletion; &lt;code&gt;git show PARENT:path&lt;/code&gt; retrieves the last surviving file. Source lines are numbered and grouped into evidence IDs such as &lt;code&gt;S1&lt;/code&gt; and &lt;code&gt;S2&lt;/code&gt;. The deletion commit message and file status become &lt;code&gt;C1&lt;/code&gt; and &lt;code&gt;C2&lt;/code&gt;. There is no vector database or fabricated backstory.&lt;/p&gt;

&lt;p&gt;Nemotron 3.5 Lightning does the part Git cannot: it turns those fragments into a compact narrative and chooses which evidence it thinks supports the account. The response must be JSON with a &lt;code&gt;voice&lt;/code&gt;, 2–5 &lt;code&gt;citations&lt;/code&gt;, and an &lt;code&gt;uncertainty&lt;/code&gt; sentence. The program rejects missing IDs, duplicates, and malformed responses. It prints the exact evidence behind accepted IDs beside the voice.&lt;/p&gt;

&lt;p&gt;I tested the code against a real two-commit Git repository built inside a temporary directory, plus an invented-citation response and a credential-redaction case. All three tests pass. The live demo made one model call; the video was rendered from that captured CLI output, rather than a hand-written mock answer.&lt;/p&gt;

&lt;p&gt;The checks validate &lt;em&gt;references&lt;/em&gt;, not semantic truth. The line 9 error demonstrates the gap. In particular, this is an aid to reading history, not an automated code reviewer or a source of certainty about why a maintainer deleted something.&lt;/p&gt;

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

&lt;p&gt;The file's “voice” is the part I want to keep replaceable. The default is &lt;a href="https://huggingface.co/nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16" rel="noopener noreferrer"&gt;Nemotron 3.5 Lightning 30B A3B&lt;/a&gt;, an open-weight model. The CLI accepts another model and a compatible chat-completions endpoint, so a user with suitable hardware can run inference on infrastructure they control. That matters when the source is private, and it lets people inspect or improve the model-side behavior instead of locking this experiment to one provider.&lt;/p&gt;

&lt;p&gt;My recorded run used NVIDIA's hosted API. It was &lt;strong&gt;not&lt;/strong&gt; offline or private. ghostline sends source excerpts to the configured endpoint; its basic secret redaction is best effort. For a sensitive repository, use &lt;code&gt;--evidence-only&lt;/code&gt; or a self-hosted endpoint and review the material first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spirit of It
&lt;/h2&gt;

&lt;p&gt;The ghost did not know how it died. It saw a commit message and some old lines, then filled a gap with a confident, incorrect memory. Keeping the source beside the story made that mistake visible. For me, that is a useful kind of haunting: the past can speak, but you still have to read the record.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Three hosting errors that pointed me to the wrong fix</title>
      <dc:creator>build996</dc:creator>
      <pubDate>Fri, 23 Oct 2026 05:58:07 +0000</pubDate>
      <link>https://dev.to/build996/three-hosting-errors-that-pointed-me-to-the-wrong-fix-31kk</link>
      <guid>https://dev.to/build996/three-hosting-errors-that-pointed-me-to-the-wrong-fix-31kk</guid>
      <description>&lt;p&gt;I deployed the same small PHP and MySQL application to four free hosts in September. Three failures looked familiar enough that I nearly fixed the wrong thing: an FTP login error, an FTP permission error, and a database timeout. The words in each error described what the client &lt;em&gt;saw&lt;/em&gt;, not the layer that failed.&lt;/p&gt;

&lt;p&gt;The useful question was not “What does this error code usually mean?” It was “Which step had already succeeded before this error appeared?”&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;530 Login authentication failed&lt;/code&gt; with the correct password
&lt;/h2&gt;

&lt;p&gt;On ProFreeHost, I copied the FTP credentials from the control panel into a client. Login returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;530 Login authentication failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had no reason to think the password was wrong, but that message gave me no other lead. Re-entering it did not help. Changing the &lt;strong&gt;connection mode&lt;/strong&gt; did: the server rejected plaintext FTP and accepted the same username and password with &lt;em&gt;explicit FTPS&lt;/em&gt; (FTP over TLS, starting on the usual FTP port).&lt;/p&gt;

&lt;p&gt;The discriminating test was to hold the credentials constant and change only TLS. If the same credentials work over FTPS, resetting the password would have addressed the wrong variable. This does not mean every &lt;code&gt;530&lt;/code&gt; is a TLS problem; it means &lt;code&gt;530&lt;/code&gt; alone does not establish that a password is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;425 Operation not permitted&lt;/code&gt; after login worked
&lt;/h2&gt;

&lt;p&gt;On AwardSpace and FreeHostingEU, the FTPS login succeeded. I could change directories. Then every file transfer failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;425 Unable to build data connection: Operation not permitted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;“Operation not permitted” sounds like a directory permission problem. But directory changes and authentication were already working. The step that changed was opening the &lt;strong&gt;data connection&lt;/strong&gt; for the upload. FTP uses a control connection for commands and a separate connection for file data; a successful login says little about the latter.&lt;/p&gt;

&lt;p&gt;These servers required the data connection to resume the control connection's TLS session. Python's ordinary &lt;code&gt;ftplib.FTP_TLS&lt;/code&gt; opened a fresh TLS session for data, and the server refused it. In my deploy script, a small &lt;code&gt;FTP_TLS&lt;/code&gt; subclass passes &lt;code&gt;session=self.sock.session&lt;/code&gt; when wrapping the data socket; transfers then proceed. The fix belongs at the data-channel TLS handshake, not in a panel checkbox for file permissions. A client that supports TLS session reuse can solve the same problem without custom Python.&lt;/p&gt;

&lt;p&gt;I would not generalize this behavior to every FTPS server. ProFreeHost did not require this change; the AwardSpace pair did. Also, FTPS has two separate settings to check: use TLS for the control connection, and request protection for the data connection (&lt;code&gt;prot_p()&lt;/code&gt; in Python).&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A MySQL timeout caused by the address itself
&lt;/h2&gt;

&lt;p&gt;ProFreeHost's panel supplied a MySQL hostname, &lt;code&gt;sql301.ezyro.com&lt;/code&gt;. A local database client timed out trying to connect. That initially looked like a blocked port or a firewall rule. I checked DNS before changing either:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sql301.ezyro.com -&amp;gt; 192.168.0.191
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a private IPv4 address, not a route to a database from my laptop. I checked it again on October 23 and got the same address. The hostname works for code running inside the host's network; it is not an invitation to connect from the public internet. For this account, the workable paths were PHP running on the host or phpMyAdmin in its panel, rather than a local MySQL client.&lt;/p&gt;

&lt;p&gt;A timeout can come from many places. This one did not require guessing: the DNS answer showed that the destination was unreachable from where I was running the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check I now do first
&lt;/h2&gt;

&lt;p&gt;For an FTP or database failure, I write down the last step that worked:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Last successful step&lt;/th&gt;
&lt;th&gt;Next thing to inspect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FTP credentials entered, login refused&lt;/td&gt;
&lt;td&gt;Compare plaintext FTP with explicit FTPS using the &lt;em&gt;same&lt;/em&gt; credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FTPS login and directory change worked, upload failed&lt;/td&gt;
&lt;td&gt;Inspect the data connection, including TLS protection and session reuse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database hostname resolves, connection times out&lt;/td&gt;
&lt;td&gt;Inspect the resolved IP before changing passwords or firewall rules&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These were observations from specific accounts, not universal meanings for &lt;code&gt;530&lt;/code&gt;, &lt;code&gt;425&lt;/code&gt;, or a timeout. The point is to change one variable at a time and follow the protocol stage that actually failed.&lt;/p&gt;

&lt;p&gt;For the full ProFreeHost deployment record—including the original &lt;code&gt;530&lt;/code&gt;, the private MySQL address, and what happened after the files finally uploaded—I kept &lt;a href="https://toolfreebie.com/profreehost-free-php-hosting/" rel="noopener noreferrer"&gt;the measured application and host notes&lt;/a&gt; together. The three diagnostics above stand on their own; that record is for anyone who wants to inspect the larger test.&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>ftp</category>
      <category>hosting</category>
      <category>python</category>
    </item>
    <item>
      <title>quietline: an open-weight model decides which nightly log warnings should wake me</title>
      <dc:creator>build996</dc:creator>
      <pubDate>Fri, 23 Oct 2026 04:13:35 +0000</pubDate>
      <link>https://dev.to/build996/quietline-an-open-weight-model-decides-which-nightly-log-warnings-should-wake-me-53ol</link>
      <guid>https://dev.to/build996/quietline-an-open-weight-model-decides-which-nightly-log-warnings-should-wake-me-53ol</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;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;I have scheduled jobs that occasionally print alarming lines and then recover. Reading the whole log the next morning is work; treating every &lt;code&gt;WARN&lt;/code&gt; or &lt;code&gt;ERROR&lt;/code&gt; as an alert is even more work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;quietline&lt;/strong&gt; is a tiny Python CLI that reads only newly appended log lines and tries to wake me only for failures that still need attention. A cheap local filter finds signal words. An open-weight model reads those lines with nearby context and marks each one &lt;code&gt;wake&lt;/code&gt; or &lt;code&gt;sleep&lt;/code&gt;. The CLI checks that every decision refers to an actual line number, then prints the original evidence beside any alert.&lt;/p&gt;

&lt;p&gt;The sample log has three signals: a storage API returns 429 and succeeds on retry, a site build cannot create its output directory and exits with code 1, and a cache miss is followed by HTTP 200. The model should sleep through the two recoveries and flag the failed build. On my captured run, it did exactly that. This is a &lt;strong&gt;synthetic test log&lt;/strong&gt;, not a claim that quietline is already monitoring a production system.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://github.com/build996/quietline/releases/download/v0.1.0/quietline-demo.mp4" rel="noopener noreferrer"&gt;Watch the 15-second CLI demo&lt;/a&gt; · &lt;a href="https://github.com/build996/quietline/releases/tag/v0.1.0" rel="noopener noreferrer"&gt;See the release and recording details&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The video renders the output of three actual CLI invocations against a temporary copy of the sample log. The first call used NVIDIA's hosted Nemotron 3.5 Lightning model; the next two did not call a model. Here is the first result (the model's wording varies between runs):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Read 9 new lines; 3 signal lines; 1 need attention.
WAKE line 5: job exited with code 1; output directory not created
  Evidence: 2026-10-23T01:01:05Z job=site-build ERROR output directory was not created
  Next: investigate directory permissions and retry
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The second run said &lt;code&gt;Quiet: no new complete lines. Model calls: 0.&lt;/code&gt; After I appended a routine &lt;code&gt;INFO&lt;/code&gt; line, the third said &lt;code&gt;Quiet: 1 new lines, no error signals. Model calls: 0.&lt;/code&gt; That is the lazy payoff I can verify: unchanged or harmless input needs neither my attention nor an inference request. I have &lt;strong&gt;not&lt;/strong&gt; measured time saved over weeks of real use.&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/build996" rel="noopener noreferrer"&gt;
        build996
      &lt;/a&gt; / &lt;a href="https://github.com/build996/quietline" rel="noopener noreferrer"&gt;
        quietline
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A quiet daily digest for automation logs using an open-weight model
    &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;quietline&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Sleep through recovered warnings; wake up for the failures that still need you.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I check automation logs after scheduled jobs. A retryable 429 and a failed build both contain alarming words, but only the failed build needs me. &lt;code&gt;quietline&lt;/code&gt; reads new lines from one log file, sends only signal lines and nearby context to an &lt;strong&gt;open-weight NVIDIA Nemotron 3.5 Lightning&lt;/strong&gt; model, and prints unresolved failures with line-numbered evidence. When the log has no new signals, it makes &lt;strong&gt;zero model calls&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The Python CLI is MIT-licensed and uses the standard library. The model weights are available &lt;a href="https://huggingface.co/nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16" rel="nofollow noopener noreferrer"&gt;from NVIDIA on Hugging Face&lt;/a&gt;; the default endpoint is NVIDIA's hosted NIM API, so this default setup &lt;strong&gt;sends redacted log excerpts to NVIDIA&lt;/strong&gt;. You can point &lt;code&gt;--endpoint&lt;/code&gt; and &lt;code&gt;--model&lt;/code&gt; at a compatible service for an on-premises setup. Model availability and provider rate limits can change.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Try it&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;Python 3.10+ and an…&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/build996/quietline" 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 &lt;a href="https://github.com/build996/quietline" rel="noopener noreferrer"&gt;repository&lt;/a&gt; was created during this challenge window. It contains the MIT-licensed CLI, the synthetic log, a script that captures and renders the demo, and three focused tests for cursor handling, secret redaction, and rejecting invented line numbers. The CLI itself has no package dependencies.&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="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;NVIDIA_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
python quietline.py demo/sample.log &lt;span class="nt"&gt;--state&lt;/span&gt; .quietline-state.json
python quietline.py demo/sample.log &lt;span class="nt"&gt;--state&lt;/span&gt; .quietline-state.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The program stores a byte offset and a hash of the log prefix. It waits for a complete line; if the file is rewritten or rotated, it reads from the beginning. Only new lines matching words such as &lt;code&gt;error&lt;/code&gt;, &lt;code&gt;warning&lt;/code&gt;, &lt;code&gt;timeout&lt;/code&gt;, or &lt;code&gt;429&lt;/code&gt; go to the model, along with neighboring lines. That context is essential: a 429 followed by a successful retry is a different incident from a 429 followed by a failed job.&lt;/p&gt;

&lt;p&gt;The model is &lt;a href="https://huggingface.co/nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16" rel="noopener noreferrer"&gt;NVIDIA Nemotron 3.5 Lightning 30B A3B&lt;/a&gt;, an open-weight model. I called it through NVIDIA NIM for this demo. The model's role is semantic triage: a regex can find an error word, but it cannot reliably decide whether later lines show recovery. Code handles the cursor, evidence, and output checks. If the API fails or the model returns invalid JSON, the cursor is &lt;strong&gt;not advanced&lt;/strong&gt;, so the same lines can be retried.&lt;/p&gt;

&lt;p&gt;I first tried an older model ID from a previous project; the endpoint returned 404. The listed Gemma 4 endpoint timed out during a probe. Nemotron 3.5 Lightning returned a useful result once I disabled its reasoning output for this short JSON task. This is a practical reason to keep the model ID and endpoint configurable rather than hiding them in the code.&lt;/p&gt;

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

&lt;p&gt;The project needs a model that can read the &lt;em&gt;meaning&lt;/em&gt; of a short log sequence, not just match a token. Using open weights means the triage logic is not tied to this hosted API: someone with the hardware can serve the model themselves and keep log excerpts on their own infrastructure. My demo uses NVIDIA's hosted endpoint, so I do &lt;strong&gt;not&lt;/strong&gt; claim that the demo was offline or private.&lt;/p&gt;

&lt;p&gt;The CLI redacts common token and password patterns before sending excerpts, but that filter is best effort. I would review any sensitive logs before using a hosted endpoint. I also would not replace exit-code monitoring with this tool: the signal filter can miss unusual failures, and the model can misclassify one. quietline is a calmer digest, not a guarantee that a job is healthy.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>A DEV comment streak can lose an old qualifying week</title>
      <dc:creator>build996</dc:creator>
      <pubDate>Mon, 19 Oct 2026 02:24:29 +0000</pubDate>
      <link>https://dev.to/build996/a-dev-comment-streak-can-lose-an-old-qualifying-week-4p91</link>
      <guid>https://dev.to/build996/a-dev-comment-streak-can-lose-an-old-qualifying-week-4p91</guid>
      <description>&lt;p&gt;I expected a streak counter to remember which weeks had qualified. Reading Forem's Community Wellness code showed me a different design: it rebuilds the active streak from the comment and reaction rows that exist when the calculation runs.&lt;/p&gt;

&lt;p&gt;That means an old week can stop qualifying after the activity in that week is over.&lt;/p&gt;

&lt;p&gt;This is a source-code finding, not a claim that I have watched DEV revoke a badge. I checked the public Forem implementation on October 19. DEV's deployed version and operational settings are not exposed by those files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The qualifying week is a live query
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://github.com/forem/forem/blob/main/app/queries/comments/community_wellness_query.rb" rel="noopener noreferrer"&gt;Comments::CommunityWellnessQuery&lt;/a&gt;, comments join a set of comment IDs with surviving reaction rows. IDs with a thumbsdown or vomit reaction in the query's lookback are excluded.&lt;/p&gt;

&lt;p&gt;Comment age is divided by 604,800 seconds and truncated to form rolling seven-day buckets. Both comments and reactions are bounded by a 231-day lookback. There is also an eligibility check for more than one comment in the seven-to-fourteen-day interval.&lt;/p&gt;

&lt;p&gt;There is no saved record here saying that a particular historical week passed forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The award service walks the result again
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/forem/forem/blob/main/app/services/badges/award_community_wellness.rb" rel="noopener noreferrer"&gt;Badges::AwardCommunityWellness&lt;/a&gt; skips bucket zero, requires at least two qualifying comments in a bucket, and advances only through consecutive buckets starting at one. It creates an award when the resulting length matches 1, 2, 4, 8, 16, 24, or 32 weeks.&lt;/p&gt;

&lt;p&gt;Consider a hypothetical old bucket with exactly two qualifying comments. If one loses its only qualifying reaction, that bucket can fall below the threshold on a later calculation. The consecutive sequence can then stop before the next milestone.&lt;/p&gt;

&lt;p&gt;That affects the calculation of future awards. The service shown here does not remove previously awarded badges when the sequence becomes shorter.&lt;/p&gt;

&lt;h2&gt;
  
  
  A reaction can disappear without the commenter doing anything
&lt;/h2&gt;

&lt;p&gt;The profile-cleanup path in &lt;a href="https://github.com/forem/forem/blob/main/app/services/users/delete_activity.rb" rel="noopener noreferrer"&gt;Users::DeleteActivity&lt;/a&gt; deletes the departing user's reactions in batches. Those include reactions they gave to other people's comments.&lt;/p&gt;

&lt;p&gt;Following that deletion into the query gives the failure path: a comment can lose the reaction that qualified it, changing an older bucket. Multiple surviving reactions or extra qualifying comments can prevent that particular deletion from changing the result.&lt;/p&gt;

&lt;p&gt;I did not delete a test account to reproduce this on DEV. The mechanism follows from these public source paths; its occurrence on a particular account would need separate evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I can actually verify
&lt;/h2&gt;

&lt;p&gt;My profile currently shows the 1-, 2-, and 4-week Community Wellness badges. My local reconstruction has disagreed with the profile before, so I use the profile as the record of awards received.&lt;/p&gt;

&lt;p&gt;I also cannot infer exactly which reactions the live badge job counts from a displayed reaction total. A new comment showing one reaction is not sufficient evidence that an outside reader liked it or that it will qualify.&lt;/p&gt;

&lt;p&gt;My practical adjustment is modest: participate across the week, respond when someone adds something specific, and avoid treating exactly two comments as a guaranteed milestone. Additional useful conversations leave some room for changes in old data. They still do not make an award certain.&lt;/p&gt;

&lt;p&gt;If you were building this feature, would you preserve each week's qualification once calculated, or deliberately let later deletions change the active streak?&lt;/p&gt;

</description>
      <category>meta</category>
      <category>forem</category>
      <category>community</category>
    </item>
    <item>
      <title>tierwatch: Gemma 4 reads the free-tier pages I depend on, and has to quote every number</title>
      <dc:creator>build996</dc:creator>
      <pubDate>Tue, 13 Oct 2026 02:50:24 +0000</pubDate>
      <link>https://dev.to/build996/tierwatch-gemma-4-reads-the-free-tier-pages-i-depend-on-and-has-to-quote-every-number-40dl</link>
      <guid>https://dev.to/build996/tierwatch-gemma-4-reads-the-free-tier-pages-i-depend-on-and-has-to-quote-every-number-40dl</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 Open-Source AI 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;&lt;strong&gt;tierwatch&lt;/strong&gt; is a small CLI that watches the free-tier and pricing pages your projects depend on and tells you when a limit changes. It reports changed &lt;em&gt;facts&lt;/em&gt;, not changed HTML.&lt;/p&gt;

&lt;p&gt;I keep a list of free-tier limits for a side project, and I have had to correct that list after the fact more than once. Two of the entries that went stale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cerebras.&lt;/strong&gt; In February the rate-limits page showed a "Free" tier at 30 requests/min and 60K tokens/min on six models. The October version shows a "Free Trial" at 5 requests/min and 30K uncached tokens/min on two models, plus a $5 credit that needs a verified payment method.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brave Search API.&lt;/strong&gt; In January the page listed a "Free AI" plan at $0 for up to 2,000 queries a month. Today it lists "$5 in free credits every month" against $5 per 1,000 requests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither change is hidden. Both sit on a docs page that nobody re-reads until their app starts throwing 429s.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who would pay for it:&lt;/strong&gt; freelancers and small agencies who leave client projects running on free or near-free plans: a contact form on a free email API, site search on a free search API, a summariser on a free inference tier. They have a dozen of these dependencies spread across clients, and nobody re-reads the pricing pages. My guess is a few dollars a month per watchlist, with alerts delivered to email or Slack. It's a guess: I haven't put it in front of a paying user yet, so there's no "first user" section in this post.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Video supplement — added October 20, 2026
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/build996/tierwatch/releases/download/demo-supplement-2026-10-20/tierwatch-supplement-2026-10-20.mp4" rel="noopener noreferrer"&gt;Watch the video demo (MP4, about 72 seconds)&lt;/a&gt; · &lt;a href="https://github.com/build996/tierwatch/releases/tag/demo-supplement-2026-10-20" rel="noopener noreferrer"&gt;Transcript and recording details&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This recording was made &lt;strong&gt;after the submission deadline&lt;/strong&gt;. It renders output captured from an actual &lt;code&gt;python tierwatch.py --compare --state demo/state&lt;/code&gt; run using unchanged October 13 code (&lt;code&gt;927b7a5&lt;/code&gt;) and the snapshots already committed in &lt;code&gt;demo/state/&lt;/code&gt;. It demonstrates the saved-snapshot comparison, not fresh extraction or live pricing verification. API keys were deliberately unset, so the optional model summary is unavailable; the deterministic fact diff still runs.&lt;/p&gt;

&lt;p&gt;The original submission included textual demo output but omitted a deployed-project or video-demo link. This is a transparently dated supplement, not a claim that the video existed before the deadline. No project functionality has changed. The README distinguishes pre-deadline work from this documentation addition; acceptance of the supplement is up to the organizers.&lt;/p&gt;

&lt;p&gt;These are two real replays. For each page, tierwatch took a snapshot from an old Wayback Machine capture, then a second snapshot from a newer capture or the live page, and diffed the two. The output below is copied from those runs. I only lined up the table columns. Every snapshot it was built from is in the repo under &lt;code&gt;demo/state/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My watchlist note for Brave said &lt;em&gt;"my client's site search calls the Brave Search API on the free plan, about 1,500 queries a month"&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;## Brave Search API
https://brave.com/search/api/

Compared 2026-01-13 (Wayback) with 2026-10-13 (3 vs 4 verified facts;
extracted by google-ai-studio:gemma-4-31b-it).

The free plan no longer provides 2,000 free queries per month. Instead, new
users now receive $5 in monthly credits. The developer should check if these
credits cover their 1,500 monthly queries.

| fact                   | before          | now                   |
|------------------------|-----------------|-----------------------|
| answers.*              | not on the page | 2 limits              |
| free.monthly_credits   | —               | $5                    |
| free_ai.api.*          | 3 limits        | gone from the page    |
| search.qps             | —               | 50 queries per second |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;For Cerebras, the note said &lt;em&gt;"my side project's summariser runs on the Cerebras free tier with gpt-oss-120b and I have no card on file"&lt;/em&gt;. Here is the summary and a selection of rows (the full table has 19 rows and is in the repo):&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Compared 2026-02-12 (Wayback) with 2026-10-06 (Wayback) (49 vs 14 verified facts)

Rate limits for gpt_oss_120b have decreased to 5 requests per minute and 30K
tokens per minute, and daily/hourly limits have been removed. The new free
offering requires adding a verified payment method to receive $5 in credits.
Since you have no card on file, you should check if you can still access the model.

| free.gpt_oss_120b.rpm   | 30              | 5                  |
| free.gpt_oss_120b.tpm   | 60K             | 30K                |
| free.gpt_oss_120b.rph   | 900             | —                  |
| free.gpt_oss_120b.rpd   | 14.4K           | —                  |
| free.llama_3_3_70b.*    | 6 limits        | gone from the page |
| free.qwen_3_8_27b.*     | not on the page | 4 limits           |
| free_trial.*            | not on the page | 2 limits           |
| requires_payment_method | —               | True               |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That summary contains a mistake, and I've left it in on purpose. "Daily/hourly limits have been removed" is only half true. The &lt;em&gt;request&lt;/em&gt;-per-hour and per-day columns went away, but tokens-per-hour and tokens-per-day are still 1M. The table is the part the code checked; the prose is the model's. More on that below.&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/build996" rel="noopener noreferrer"&gt;
        build996
      &lt;/a&gt; / &lt;a href="https://github.com/build996/tierwatch" rel="noopener noreferrer"&gt;
        tierwatch
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Notice when a free tier or pricing page you depend on changes. Gemma 4 extracts the limits, code verifies every quote, facts get diffed.
    &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;tierwatch&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;Tells you when a free tier or pricing page you depend on changes — in facts, not in HTML diffs.&lt;/p&gt;

&lt;p&gt;Free-tier limits change quietly, on a docs page nobody re-reads. Cerebras's rate-limits page went
from a "Free" tier at 30 requests/min to a "Free Trial" at 5 requests/min with a $5 credit that
needs a verified payment method; Brave Search
replaced "2,000 free queries a month" with "$5 of monthly credits". If a side project or a
client's site runs on one of those plans, you usually find out when it starts returning 429s.&lt;/p&gt;
&lt;p&gt;tierwatch keeps a snapshot of each page you list, has an open-weight model (Gemma 4) pull the
limits out as structured facts, throws away every fact whose quoted evidence is not actually on
the page, and diffs the facts against the previous snapshot. A second model call turns the diff
into a few sentences about…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/build996/tierwatch" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;It's one Python file using only the standard library, under the MIT license. Running it looks like this:&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="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GEMINI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;...
python tierwatch.py                            &lt;span class="c"&gt;# snapshot + diff everything in watchlist.json&lt;/span&gt;
python tierwatch.py &lt;span class="nt"&gt;--wayback&lt;/span&gt; 20260113061705   &lt;span class="c"&gt;# replay an old capture&lt;/span&gt;
python tierwatch.py &lt;span class="nt"&gt;--compare&lt;/span&gt;                  &lt;span class="c"&gt;# diff the two newest saved snapshots&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The pipeline has five steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fetch and flatten.&lt;/strong&gt; The page, or a Wayback capture of it, is converted to text. Each table row stays on one line as &lt;code&gt;cell | cell | cell&lt;/code&gt;, and &lt;code&gt;&amp;lt;sup&amp;gt;&lt;/code&gt; footnote markers become &lt;code&gt;^1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extract with Gemma 4.&lt;/strong&gt; The model returns &lt;code&gt;{free_offering, requires_payment_method, facts: [{key, value, quote}]}&lt;/code&gt;. On later runs, the previous snapshot's keys go back into the prompt so the same fact keeps the same key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify in code.&lt;/strong&gt; A fact's quote has to appear in the page text, and its value has to appear inside the quote. For a table row, the value has to match one whole cell. Anything that fails is stored as &lt;code&gt;dropped_unverified&lt;/code&gt; and never reported.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff by key.&lt;/strong&gt; When a whole model disappears from a plan, the diff collapses its limits into one row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Summarise&lt;/strong&gt; the diff against the user's one-line note about what they depend on.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 started out weaker, and it let a real error through. On October's Cerebras page, the row for &lt;code&gt;qwen-3.8-27b&lt;/code&gt; has a footnote marker right after the model name. With the HTML flattened to plain lines, Gemma read that footnote &lt;code&gt;1&lt;/code&gt; as the requests-per-minute value and shifted every other column over by one. The quote check still passed, because every one of those numbers really is on the page. It just wasn't under the header Gemma assigned it to. That's when I added the table-row flattening and the whole-cell rule. Both are cheap, and that error doesn't get through any more. I don't claim column mistakes are impossible now. What the check guarantees is narrower: it can make a number disappear from the report, but a number that isn't on the page can't get into it.&lt;/p&gt;

&lt;p&gt;The check is also strict enough to throw away correct facts. Brave prints &lt;code&gt;$5&lt;/code&gt; and &lt;code&gt;per 1,000 requests&lt;/code&gt; as separate elements. Gemma quoted only the second half, the value wasn't inside the quote, and the price was dropped. For this tool that's the right way to fail. Missing a change costs you one more day before you notice. A made-up limit could push a client into a plan they don't need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model hosting.&lt;/strong&gt; I didn't run inference on my own machine for this build. Every call goes to a hosted copy of the same open weights. &lt;code&gt;PROVIDERS&lt;/code&gt; lists Gemma 4 on Google AI Studio (26B A4B and 31B), then OpenRouter's free Gemma 4 31B, then NVIDIA NIM's Gemma 4 31B. Each host gets three attempts before the next one is tried. All requests stream, because Gemma thinks for a while before it answers, and long silent requests kept getting cut by my network.&lt;/p&gt;

&lt;p&gt;The fallback wasn't a nice-to-have. In the runs behind this post I logged these failures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Google returned HTTP 500, HTTP 429, and streams that ended with only thinking tokens and no answer.&lt;/li&gt;
&lt;li&gt;OpenRouter's free Gemma returned 429 "temporarily rate-limited upstream" on every attempt.&lt;/li&gt;
&lt;li&gt;NVIDIA answered one extraction after 91 seconds, after two dropped connections.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The calls that did succeed took between 34 and 284 seconds. That's fine for a daily cron job and far too slow for a web form.&lt;/p&gt;

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

&lt;p&gt;This tool exists because a provider can change its terms whenever it likes. A watcher like that shouldn't depend on one provider's terms itself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same weights, different hosts.&lt;/strong&gt; When Google's endpoint was returning 500s, the next host in the list ran the &lt;em&gt;same model&lt;/em&gt;. The extraction prompt, the key naming, and the behaviour the verification step was tuned against all stayed the same. With a closed model, a fallback means a different model with different habits, and I'd have to re-tune the quote check against it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A host retiring a model doesn't retire the weights.&lt;/strong&gt; While setting this up, NVIDIA's &lt;code&gt;google/gemma-3-27b-it&lt;/code&gt; returned &lt;code&gt;410 Gone&lt;/code&gt; with an end-of-life date of 2026-05-12. That's one host dropping one model. The weights are still published, and I could serve them myself or pick another host. When a closed model ID is retired, the model is simply gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The economics.&lt;/strong&gt; The work is one extraction per page per day, and nobody waits on it. Right now it costs nothing on free hosted Gemma. If those free tiers disappear (which is exactly what this tool watches for), the same weights can run on a small rented GPU or a cheap batch endpoint. Either way the price stays under my control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fine-tuning is possible later.&lt;/strong&gt; Pricing pages follow a handful of layouts. With open weights, I could fine-tune a small Gemma on verified extractions from this tool's own &lt;code&gt;state/&lt;/code&gt; folder. A closed API wouldn't let me do that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where the closed models I've used would still beat this: speed, and fewer empty or failed responses. For a cron job that runs once a day, I'll take the open option.&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 (26B A4B and 31B) does every extraction and summary, served by Google AI Studio with OpenRouter and NVIDIA NIM hosting the same weights as fallbacks.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>openclaw doctor --fix reported the problems and fixed none of them</title>
      <dc:creator>build996</dc:creator>
      <pubDate>Mon, 12 Oct 2026 02:19:49 +0000</pubDate>
      <link>https://dev.to/build996/openclaw-doctor-fix-reported-the-problems-and-fixed-none-of-them-1d24</link>
      <guid>https://dev.to/build996/openclaw-doctor-fix-reported-the-problems-and-fixed-none-of-them-1d24</guid>
      <description>&lt;p&gt;I run OpenClaw as a systemd user service inside WSL, mostly as a local playground for agent setups. It had been sitting on 2026.4.1 for a while, and I upgraded it to 2026.8.1 in one jump. That is four months and four major versions.&lt;/p&gt;

&lt;p&gt;The install finished. The gateway did not start. It exited with code 78 (&lt;code&gt;EX_CONFIG&lt;/code&gt;), and the log told me to run &lt;code&gt;openclaw doctor --fix&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I ran it. It printed a list of problems. I started the gateway again. Exit 78.&lt;/p&gt;

&lt;p&gt;This post is the order of steps that actually got it booting, and the one habit I would take into any upgrade like this: treat "run doctor --fix" as a suggestion, not a fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What doctor did and did not do
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;doctor&lt;/code&gt; was useful for &lt;em&gt;finding&lt;/em&gt; things. But on this jump the problems it reported were still there after &lt;code&gt;--fix&lt;/code&gt;, and two of its messages were actively misleading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It warned that &lt;code&gt;agents.list&lt;/code&gt; had to become &lt;code&gt;agents.entries&lt;/code&gt;. It did not rewrite it. The new shape is an object keyed by agent id instead of an array of objects with an &lt;code&gt;id&lt;/code&gt; field, so this is a two-minute edit, but you have to do it yourself.&lt;/li&gt;
&lt;li&gt;It got stuck in a loop on a legacy session store:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deferred legacy-main session migration; run openclaw doctor --fix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running &lt;code&gt;doctor --fix&lt;/code&gt; again produced the same line. And again. The message points at the command you just ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real blocker was in journalctl
&lt;/h2&gt;

&lt;p&gt;The gateway's own output only gave me the exit code and the doctor hint. The service log had the actual reasons:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; openclaw-gateway
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is where the actual blockers showed up. Once I stopped re-running the hint and started reading the journal, the rest was mechanical.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order that worked
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Node first.&lt;/strong&gt; 2026.8.1 requires Node &lt;code&gt;&amp;gt;=22.22.3&lt;/code&gt;. The box had 22.22.1, two patch versions short, and npm refused at preinstall. I installed Node 24 with nvm and made it the default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the install scripts run.&lt;/strong&gt; npm skipped the package's &lt;code&gt;postinstall&lt;/code&gt; by default, so the bundled plugins never landed. Reinstalling with &lt;code&gt;--allow-scripts&lt;/code&gt; for &lt;code&gt;openclaw&lt;/code&gt; and its native dependencies fixed that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;openclaw gateway install&lt;/code&gt;&lt;/strong&gt; again. The systemd unit still pointed at the old Node binary path, so the service was launching the new code with the old runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete the config keys the new schema rejects, one boot at a time.&lt;/strong&gt; This was the slow part. Each failed boot named &lt;em&gt;some&lt;/em&gt; of the rejected keys, not all of them. For me the full set was &lt;code&gt;meta.lastTouchedAt&lt;/code&gt;, &lt;code&gt;commands.ownerDisplay&lt;/code&gt;, &lt;code&gt;gateway.tailscale.resetOnExit&lt;/code&gt;, &lt;code&gt;gateway.nodes.denyCommands&lt;/code&gt; and &lt;code&gt;plugins.installs&lt;/code&gt;. I found them over several boots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;agents.list&lt;/code&gt; → &lt;code&gt;agents.entries&lt;/code&gt;&lt;/strong&gt;, by hand, as above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;code&gt;agents.ownership: "explicit"&lt;/code&gt;.&lt;/strong&gt; It is now required when more than one agent is defined.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move the legacy stores aside.&lt;/strong&gt; &lt;code&gt;agents/*/sessions/sessions.json&lt;/code&gt; and &lt;code&gt;exec-approvals.json&lt;/code&gt; were what kept the migration loop alive. Renaming them let the new SQLite stores be created. The cost: old chat history is gone, which is why I tarred the whole &lt;code&gt;~/.openclaw&lt;/code&gt; directory before touching anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disable third-party plugins written against the old SDK.&lt;/strong&gt; One chat-integration plugin could no longer be loaded from its TypeScript source and blocked boot on its own.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After step 8 it came up. The new UI is a full redesign, with sessions as a first-class pane, which was the thing I wanted from the upgrade in the first place. But nothing about getting there was automatic.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more trap while iterating
&lt;/h2&gt;

&lt;p&gt;Steps 4 to 8 mean restarting the service a lot. OpenClaw has a crash-loop breaker: three unclean boots within five minutes, and it suppresses provider auto-start on the next boot. At that point the gateway is up, but agent turns fail for a reason that has nothing to do with whatever you just fixed. It looks like your fix broke something else.&lt;/p&gt;

&lt;p&gt;What worked: stop the service, wait about 45 seconds, start it once, and leave it alone while you read the journal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do next time
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Back up the whole config directory before upgrading, not just the config file. Some of the fixes are "rename this store aside", and that is only safe with a copy.&lt;/li&gt;
&lt;li&gt;Expect schema rejections to arrive in batches. Plan for several boots, not one.&lt;/li&gt;
&lt;li&gt;When an error message tells you to run a repair command, run it once. If the message comes back unchanged, the repair command is not going to help, and the answer is in the service log.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have upgraded a self-hosted agent stack across several majors at once: did its migration tool do the work, or did you end up editing config by hand too?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>selfhosted</category>
      <category>devops</category>
    </item>
    <item>
      <title>Groq returned the same 403 for my real key, a fake key, and no key</title>
      <dc:creator>build996</dc:creator>
      <pubDate>Thu, 08 Oct 2026 02:22:51 +0000</pubDate>
      <link>https://dev.to/build996/groq-returned-the-same-403-for-my-real-key-a-fake-key-and-no-key-10j8</link>
      <guid>https://dev.to/build996/groq-returned-the-same-403-for-my-real-key-a-fake-key-and-no-key-10j8</guid>
      <description>&lt;p&gt;I was running a small Node benchmark script against a few hosted LLM APIs from a Windows machine. One provider failed with a bare &lt;code&gt;fetch failed&lt;/code&gt;, which at least looked like a network problem. Groq did something more convincing. It answered, with a status code and a JSON body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;HTTP 403
{"error":{"message":"Forbidden"}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 403 from an API that has your key reads like "your key is not allowed to do this". Revoked, suspended, wrong project, account flagged. What stopped me from treating it that way was one boring test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three requests, one answer
&lt;/h2&gt;

&lt;p&gt;If the 403 is about the key, then changing the key should change the answer. So I sent the same request three ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;with my real key&lt;/li&gt;
&lt;li&gt;with a key I made up (&lt;code&gt;gsk_&lt;/code&gt; plus random characters)&lt;/li&gt;
&lt;li&gt;with no &lt;code&gt;Authorization&lt;/code&gt; header at all&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three came back identical: &lt;code&gt;403&lt;/code&gt;, &lt;code&gt;{"error":{"message":"Forbidden"}}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That settles more than it looks like. A server that has actually looked at your credentials cannot give the same verdict to a valid key, a fabricated one and a missing one. Whatever said "Forbidden" made its decision before anyone checked the key. The key was never the problem, so rotating it, regenerating it or emailing support about it would all have been wasted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a real auth failure looks like
&lt;/h2&gt;

&lt;p&gt;For contrast, here is the same endpoint today, from a network path that works, with a fake key and then with no key at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://api.groq.com/openai/v1/models &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer gsk_fake123"&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://api.groq.com/openai/v1/models
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both return:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Invalid API Key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"invalid_request_error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"invalid_api_key"&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two differences from the 403, and either one is enough to tell them apart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The status.&lt;/strong&gt; A missing or wrong key gets 401. A 403 for a request that carried no credentials at all doesn't mean "this key lacks permission", because there was no key to judge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The body shape.&lt;/strong&gt; The real auth error has &lt;code&gt;type&lt;/code&gt; and &lt;code&gt;code&lt;/code&gt; fields, the same shape as the API's other errors. The 403 body had only &lt;code&gt;message&lt;/code&gt;. When an error doesn't look like the rest of the API's errors, it probably didn't come from the API's application code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I can't reproduce the 403 any more because the network path it came from is gone. Where exactly it was produced, I can't say for certain: something between my machine and the key check, whether a network hop or an edge policy that looks at where a request comes from. What I can say is that it was upstream of authentication, and the three-key test is what proved it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why curl worked and Node didn't
&lt;/h2&gt;

&lt;p&gt;The second half of this was a red herring of my own making. The machine had a proxy configured through &lt;code&gt;HTTPS_PROXY&lt;/code&gt;, and &lt;code&gt;curl&lt;/code&gt; through that proxy reached everything fine. So I assumed my script was taking the same path. It wasn't.&lt;/p&gt;

&lt;p&gt;Node's built-in &lt;code&gt;fetch&lt;/code&gt; (undici) does not read &lt;code&gt;HTTP_PROXY&lt;/code&gt; / &lt;code&gt;HTTPS_PROXY&lt;/code&gt; by default. The script inherited the variables and ignored them, so its requests went out directly, which is where the 403 and the &lt;code&gt;fetch failed&lt;/code&gt; both came from. One provider answering normally over that same direct path made it look provider-specific instead of path-specific, which sent me further in the wrong direction.&lt;/p&gt;

&lt;p&gt;On Node 24 there's an opt-in for this:&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;NODE_USE_ENV_PROXY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;HTTPS_PROXY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://127.0.0.1:PORT node script.mjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that set, the same script got normal answers from every provider, including Groq with the same key that had been "forbidden" before.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist I use now
&lt;/h2&gt;

&lt;p&gt;When an API rejects a key that I believe is good:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Send the request with no &lt;code&gt;Authorization&lt;/code&gt; header.&lt;/strong&gt; If you get the same status and body as with your key, the key isn't being evaluated. Stop debugging the key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare the error body's shape with the API's documented errors.&lt;/strong&gt; Missing fields usually means a different layer wrote it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make sure the tool that fails takes the same path as the tool that works.&lt;/strong&gt; &lt;code&gt;curl&lt;/code&gt; honouring &lt;code&gt;HTTPS_PROXY&lt;/code&gt; says nothing about Node's &lt;code&gt;fetch&lt;/code&gt;, Python's &lt;code&gt;httpx&lt;/code&gt; with &lt;code&gt;trust_env=False&lt;/code&gt;, or anything else with its own idea of proxies.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 1 takes ten seconds, and it's the one I skipped. I'm curious whether other people have a "this error isn't from where it claims to be" test they reach for first, because the general problem (an error message that blames the wrong layer) seems to come up everywhere, not just with API keys.&lt;/p&gt;

</description>
      <category>node</category>
      <category>networking</category>
      <category>debugging</category>
      <category>api</category>
    </item>
    <item>
      <title>How do you check that an agent actually did what it says it did?</title>
      <dc:creator>build996</dc:creator>
      <pubDate>Mon, 05 Oct 2026 02:30:32 +0000</pubDate>
      <link>https://dev.to/build996/how-do-you-check-that-an-agent-actually-did-what-it-says-it-did-302o</link>
      <guid>https://dev.to/build996/how-do-you-check-that-an-agent-actually-did-what-it-says-it-did-302o</guid>
      <description>&lt;p&gt;A while ago I gave a model a boring job inside an agent harness: sort eight files into subfolders by type. It reported success in 20 seconds. Not one file had moved.&lt;/p&gt;

&lt;p&gt;Since then I don't grade an agent on its final message. I look at whatever the task was supposed to change, like the folder, the table or the API record, and the agent's summary is the last thing I read, if I read it at all.&lt;/p&gt;

&lt;p&gt;That works when the task has an obvious end state. It gets harder fast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open-ended tasks.&lt;/strong&gt; "Clean up this module" has no single state to diff against. What counts as done?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Side effects you didn't expect.&lt;/strong&gt; Checking that the file moved doesn't tell you whether something else got deleted on the way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost.&lt;/strong&gt; Writing a state check for every task can take longer than doing the task yourself, which defeats the point of handing it off.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I'm curious what other people actually do, day to day, not in theory:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Do you run the tests and trust green, read the diff, ask the agent for evidence (command output, file paths), or spot-check a sample?&lt;/li&gt;
&lt;li&gt;Has an agent ever told you it finished something it hadn't? How did you find out, and how long did it take?&lt;/li&gt;
&lt;li&gt;If you check by state, who writes the check: you, before the task, or the agent, after?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third one is the one I haven't settled. A check the agent writes after doing the work tends to share its blind spots. A check I write beforehand is more independent, but it costs me time on every single task, and I'm not sure where the line is.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>ai</category>
      <category>agents</category>
    </item>
    <item>
      <title>WordPress rewrote the &amp; inside my JavaScript regex, and only the live page broke</title>
      <dc:creator>build996</dc:creator>
      <pubDate>Thu, 01 Oct 2026 02:05:35 +0000</pubDate>
      <link>https://dev.to/build996/wordpress-rewrote-the-inside-my-javascript-regex-and-only-the-live-page-broke-2bep</link>
      <guid>https://dev.to/build996/wordpress-rewrote-the-inside-my-javascript-regex-and-only-the-live-page-broke-2bep</guid>
      <description>&lt;p&gt;I shipped a small tool page about GitHub projects as a WordPress post, with the JavaScript embedded in a Custom HTML (&lt;code&gt;&amp;lt;!-- wp:html --&amp;gt;&lt;/code&gt;) block. Opened from disk, it worked. On the live site, every number on the page came out like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;180,491   -&amp;gt;  1undefinedundefined,491
v0.33.3   -&amp;gt;  vundefined.undefinedundefined.undefined
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some digits survived and some turned into the string &lt;code&gt;undefined&lt;/code&gt;. That pattern is specific enough to trace back to one character.&lt;/p&gt;

&lt;h2&gt;
  
  
  The helper that broke
&lt;/h2&gt;

&lt;p&gt;The page had the usual HTML-escape helper:&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;esc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;&amp;amp;&amp;lt;&amp;gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAP&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the live page, the source of that line read:&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;&amp;amp;#038;&amp;lt;&amp;gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAP&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WordPress's &lt;code&gt;wptexturize&lt;/code&gt; filter ran over the post content, found a lone &lt;code&gt;&amp;amp;&lt;/code&gt;, and turned it into the numeric entity &lt;code&gt;&amp;amp;#038;&lt;/code&gt;. The Custom HTML block didn't protect the script, and being inside a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag didn't either.&lt;/p&gt;

&lt;p&gt;Inside a regex character class, &lt;code&gt;&amp;amp;#038;&lt;/code&gt; isn't one character. It's six, so the class now also matched &lt;code&gt;#&lt;/code&gt;, &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;3&lt;/code&gt;, &lt;code&gt;8&lt;/code&gt; and &lt;code&gt;;&lt;/code&gt;. Every &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;3&lt;/code&gt; and &lt;code&gt;8&lt;/code&gt; in my numbers got matched, looked up in &lt;code&gt;MAP&lt;/code&gt;, found nothing, and was replaced with &lt;code&gt;undefined&lt;/code&gt;. That's why &lt;code&gt;180,491&lt;/code&gt; kept its &lt;code&gt;1&lt;/code&gt;, &lt;code&gt;4&lt;/code&gt;, &lt;code&gt;9&lt;/code&gt; and &lt;code&gt;1&lt;/code&gt; and lost the &lt;code&gt;8&lt;/code&gt; and the &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it didn't touch
&lt;/h2&gt;

&lt;p&gt;The same script had nine &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; operators, and all nine came through unchanged. It's specifically a single, free-standing &lt;code&gt;&amp;amp;&lt;/code&gt; that gets converted. That's also why the rest of the page logic ran fine and the only symptom was in the output of one helper, which made it look like a data problem rather than a broken script.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Don't write a bare &lt;code&gt;&amp;amp;&lt;/code&gt; in JavaScript that WordPress will filter. In a regex or a string literal, the Unicode escape means the same thing to the JS engine and gives the filter nothing to rewrite:&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\u&lt;/span&gt;&lt;span class="sr"&gt;0026&amp;lt;&amp;gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAP&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other route is to keep the script out of post content entirely, in a separate &lt;code&gt;.js&lt;/code&gt; file, since the content filters only run on the post body.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I didn't catch it before publishing
&lt;/h2&gt;

&lt;p&gt;The bug can't exist locally. The file I tested from &lt;code&gt;file://&lt;/code&gt; was exactly what I wrote; the &lt;code&gt;&amp;amp;#038;&lt;/code&gt; only appears after WordPress renders the post. My local check was honest and also testing a different file from the one visitors get.&lt;/p&gt;

&lt;p&gt;The check I run now after publishing anything with inline JS is to fetch the live HTML and grep the script region for &lt;code&gt;&amp;amp;#038;&lt;/code&gt;. It takes a few seconds, and it's the only test that looks at what the browser actually receives.&lt;/p&gt;

&lt;p&gt;Has anyone found a WordPress content filter that touches something other than &lt;code&gt;&amp;amp;&lt;/code&gt; inside embedded scripts? I'd like to know what else belongs in that grep.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>javascript</category>
      <category>debugging</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
