# Benchmarking Local LLMs: Where VRAM Limits, MoE, and PLE Architecture Collide URL: https://ajaydandge.dev/blog/local-llm-inference-benchmark-gpu-vs-cpu-only Published: 2026-08-08 Running large language models locally has evolved rapidly with new architectural patterns like Mixture of Experts (MoE) and Parameter-Level Effective (PLE) architectures. To understand real-world inference characteristics across consumer hardware, I benchmarked a series of local models using [Ollama](https://ollama.com). The test suite covers two distinct hardware environments: 1. **GPU Setup:** NVIDIA GeForce RTX 4060 Ti (16GB VRAM), Intel Core i7-12700F, 32GB RAM testing `gemma4:26b` (MoE), `gemma4:12b` (dense), and `mistral-small3.2:24b` (dense). 2. **CPU-only Setup:** Intel Core i5-13420H (16GB RAM, no discrete GPU) testing the lightweight `gemma4:e4b` and `gemma4:e2b` models. The two setups sit side by side as reference points for different bottleneck classes (high-bandwidth VRAM vs. system RAM bandwidth, and dense vs. sparse/PLE architectures). --- ## Benchmark Results ### 1. GPU Results (NVIDIA GeForce RTX 4060 Ti 16GB VRAM, Intel Core i7-12700F, 32GB RAM) All models tested on GPU were quantized using standard `Q4_K_M` for an apples-to-apples comparison on quantization level. | Model | Placement | VRAM Used | Prompt Eval Rate | Generation Rate | Load Time (Cold) | |---|---|---|---|---|---| | `gemma4:26b` (MoE, 26B A4B) | 56% CPU / 44% GPU | 15 ± 1 GB | 53 ± 13 tok/s | 32 ± 1 tok/s | 22s ± 1s | | `gemma4:12b` (dense) | 100% GPU | 13 ± 1 GB | 113 ± 34 tok/s | 32 ± 1 tok/s | 1s ± 0.5s | | `mistral-small3.2:24b` (dense) | 63% CPU / 37% GPU | 13 ± 1 GB | 571 ± 240 tok/s | 4 ± 1 tok/s | 1s ± 0.5s | ### 2. CPU-only Results (Intel Core i5-13420H, 16GB RAM) Tested on battery-efficient mobile silicon with integrated graphics, running fully on CPU: | Model | Placement | Prompt Eval Rate | Generation Rate | Load Time (Cold) | |---|---|---|---|---| | `gemma4:e4b` | 100% CPU | 26 ± 4 tok/s | 10 ± 4 tok/s | 2s ± 0.5s | | `gemma4:e2b` | 100% CPU | 72 ± 5 tok/s | 21 ± 5 tok/s | 2s ± 0.5s | --- ## Model Specifications (`ollama show`) Here is the structural breakdown of each model retrieved via `ollama show`: | Field | `gemma4:e2b` | `gemma4:e4b` | `gemma4:26b` | `gemma4:12b` | `mistral-small3.2:24b` | |---|---|---|---|---|---| | **Architecture** | gemma4 | gemma4 | gemma4 (MoE, 26B A4B) | gemma4 (dense) | mistral3 (dense) | | **Total Parameters** | 5.1B | 8.0B | 25.8B | 11.9B | 24.0B | | **Active Params / Token** | ~2.3B effective (PLE) | ~4.5B effective (PLE) | 3.8B | 11.9B (all active) | 24.0B (all active) | | **Context Length** | 131,072 | 131,072 | 262,144 | 262,144 | 131,072 | | **Embedding Length** | 1,536 | 2,560 | 2,816 | 3,840 | 5,120 | | **Quantization** | Q4_K_M | Q4_K_M | Q4_K_M | Q4_K_M | Q4_K_M | | **Vision** | Yes | Yes | Yes | Yes | Yes | | **Audio** | Yes | Yes | No | Yes | No | | **Tools** | Yes | Yes | Yes | Yes | Yes | | **Thinking** | Yes | Yes | Yes | Yes | No | | **Default Temperature** | 1.0 | 1.0 | 1.0 | 1.0 | 0.15 | --- ## Key Findings & Architecture Insights ### 1. Cold-Start Overhead on Large Footprints The cold start for `gemma4:26b` required **22s ± 1s** while streaming **15 ± 1 GB** of model weights across PCIe into VRAM and system memory. On the Intel Core i5-13420H (16GB RAM, no discrete GPU), `e2b` and `e4b` had much faster cold starts (**2s ± 0.5s**) due to their smaller footprint. ### 2. Full GPU Offload Doubles Prompt Evaluation When a model fits entirely into VRAM (`gemma4:12b` at 100% GPU offload), prompt evaluation hits **113 ± 34 tok/s**, roughly doubling the split-memory `gemma4:26b` (53 ± 13 tok/s). Interestingly, on CPU-only, `gemma4:e2b` reached **72 ± 5 tok/s prompt eval**. The PLE architecture minimizes the active computation path during prompt ingest, making it feel remarkably snappy even on the Intel Core i5-13420H processor. ### 3. Generation Speed is Memory-Bandwidth Bound A common misconception is that generation rate correlates linearly with active parameter count. However: - `gemma4:12b` (11.9B active params, 100% GPU) produced **32 ± 1 tok/s**. - `gemma4:26b` (3.8B active params, 56% CPU / 44% GPU) produced **32 ± 1 tok/s**. Why did the MoE model match the fully offloaded 12B model? - In `gemma4:26b`, only 3.8B parameters are activated per token step, dramatically reducing per-token memory read demands over the PCIe/system RAM bus. - In `gemma4:12b`, all 11.9B parameters must be read from VRAM on every single token step. The two differing bottlenecks (slower PCIe/RAM fetching 3.8B params vs. faster VRAM fetching 11.9B params) converged at the exact same output generation rate of **32 ± 1 tok/s**. ### 4. The 262K Context Tax on VRAM Allocation Both `gemma4:26b` and `gemma4:12b` support a massive **262,144 token context window**. When Ollama calculates layer offloading, it pre-allocates KV cache headroom for this context window. This large default KV allocation is why `26b` split 56/44 across CPU and GPU on the 16GB NVIDIA GeForce RTX 4060 Ti rather than fitting more layers into VRAM. ### 5. Why Mistral Small 3.2 24B Dropped to 4 ± 1 tok/s Despite having fewer total parameters (24.0B) than Gemma 4 26B (25.8B), `mistral-small3.2:24b` produced a sluggish **4 ± 1 tok/s**. Checking `ollama ps` revealed the culprit: - Mistral Small 3.2 uses a wider embedding dimension (5120) and a dense transformer architecture. - Its uncompressed on-disk footprint at Q4_K_M is **~36 GB**. - On the 16GB NVIDIA GeForce RTX 4060 Ti, Ollama was forced to place **63% on CPU RAM and only 37% on GPU**. - Because every token generation step must sweep all 24B dense parameters across the slow system RAM bus, generation throughput ground to a halt. In contrast, its prompt eval remained high (571 ± 240 tok/s) because batch matrix multiplication is compute-bound and saturates parallel execution units. --- ## Actionable Recommendations ### 1. Right-Sizing CPU-only Deployments If running on an Intel Core i5-13420H (16GB RAM, no discrete GPU): - Use **`gemma4:e2b`** as the default interactive driver: at **21 ± 5 tok/s**, it feels fluid for conversational chat and agentic workflows. - Reserve **`gemma4:e4b`** (**10 ± 4 tok/s**) for tasks requiring deeper reasoning or higher fidelity tool execution. - Avoid running `12b` or `26b` CPU-only on 16GB machines; memory starvation and single-digit token rates will result. ### 2. Clamp `num_ctx` Unless Long-Context is Essential The default 131k–262k context sizes consume gigabytes of VRAM/RAM for KV caches. If working on standard tasks, restrict the context window in your Modelfile or API call: ```dockerfile FROM gemma4:26b PARAMETER num_ctx 16384 ``` Clamping `num_ctx` to 16k or 32k frees several gigabytes of VRAM, allowing Ollama to offload more layers to the GPU. ### 3. Leverage Multi-Token Prediction (MTP) Drafters Google released dedicated speculative-decoding drafter weights alongside Gemma 4. MTP drafters predict multiple future tokens per step and verify them in a single forward pass, providing up to **~3x generation throughput gains** on memory-bandwidth-bound setups. Ollama introduced native Gemma 4 MTP support in [v0.23.1](https://github.com/ollama/ollama/releases/tag/v0.23.1). ### 4. Switch to Quantization-Aware Training (QAT) Checkpoints For memory-constrained setups, standard post-training quantization (PTQ) can degrade output quality at low bitrates. DeepMind's official QAT checkpoints retain higher fidelity per byte, making 3-bit or 4-bit runs much more capable on limited RAM. --- ## References & Further Reading - [Google DeepMind — Gemma 4 Overview](https://deepmind.google/models/gemma/gemma-4/) - [Google AI for Developers — Gemma 4 Model Card](https://ai.google.dev/gemma/docs/core/model_card_4) - [Google AI for Developers — Speed-up Gemma 4 with Multi-Token Prediction](https://ai.google.dev/gemma/docs/mtp/overview) - [Google Blog — Accelerating Gemma 4 with Multi-Token Prediction Drafters](https://blog.google/innovation-and-ai/technology/developers-tools/multi-token-prediction-gemma-4/) - [Hugging Face — Welcome Gemma 4: Frontier Multimodal Intelligence on Device](https://huggingface.co/blog/gemma4) - [Ollama — Gemma 4 MTP Speculative Decoding (PR #15980)](https://github.com/ollama/ollama/pull/15980) --- # Optimizing Mobile PageSpeed Insights: How I Reached 99 on Performance URL: https://ajaydandge.dev/blog/optimizing-mobile-pagespeed-insights-how-i-reached-99-on-performance Published: 2026-07-29 When testing this blog on [Google PageSpeed Insights](https://pagespeed.web.dev/), the Accessibility, Best Practices, and SEO scores were all sitting at a clean 100/100. But the **Mobile Performance** score was stuck at **85/100**. While 85 isn't terrible, a static Next.js site shouldn't be lagging on mobile CPU emulation. Here is how I diagnosed what was blocking the main thread and optimized it to **99/100**. --- ## 1. Finding the Bottlenecks Running a Lighthouse audit revealed that my **Total Blocking Time (TBT)** was **580ms** (the standard recommended target is under 200ms). Digging into the diagnostic trace showed three main contributors: 1. **Synchronous Google Analytics Script (`gtag.js`)**: Injected via `@next/third-parties/google`, it transferred ~154 KiB of JavaScript and locked the main thread for over 370ms during Next.js hydration. 2. **Eager Component Imports**: Interactive retro pet animation components (`Oneko`, `Oinu`, `Ousagi`) were statically imported at the top of my layout component. All three sprite controllers were bundled into the main shared JavaScript chunk even though only one pet is randomly rendered after mount. 3. **Legacy JavaScript Polyfills**: The project's TypeScript compilation target was set to `ES2017`. Next.js automatically injected `core-js` polyfills (`Array.prototype.at`, `Object.hasOwn`, `Array.prototype.flatMap`), adding ~11.6 KiB of redundant JavaScript for features native in modern browsers. ### Automating Audits with Lighthouse CLI To diagnose and verify these bottlenecks locally without clicking through the web UI, I ran the **Lighthouse CLI** via `bunx`: ```bash bunx --bun lighthouse https://ajaydandge.dev \ --form-factor=mobile \ --output=json \ --chrome-flags="--headless" > mobile-report.json ``` Running Lighthouse CLI in headless mode produces an exact JSON trace containing detailed metric scores, long main-thread task timelines, and specific resource savings that can be inspected directly from the terminal. --- ## 2. The Fixes ### Deferring Google Analytics with `lazyOnload` Instead of letting Google Tag Manager compete with initial page hydration for CPU time, I replaced the default `` component with Next.js ` ); } ``` `strategy="lazyOnload"` schedules script execution during browser idle time (`requestIdleCallback`) *after* the page is fully interactive. --- ### Code-Splitting Interactive Client Components Next, I updated the `Pet` component to lazily load the pet animation modules using Next.js `dynamic()`. **Before (Static Imports Included All Pets upfront)**: ```tsx 'use client'; import { useState, useEffect } from 'react'; import { Oneko } from './oneko'; import { Oinu } from './oinu'; import { Ousagi } from './ousagi'; export function Pet() { const [activePet, setActivePet] = useState<'cat' | 'dog' | 'rabbit' | null>(null); useEffect(() => { const rand = Math.random(); if (rand < 0.33) setActivePet('cat'); else if (rand < 0.66) setActivePet('dog'); else setActivePet('rabbit'); }, []); if (activePet === 'cat') return ; if (activePet === 'dog') return ; if (activePet === 'rabbit') return ; return null; } ``` **After (Dynamic Code-Splitting with `next/dynamic`)**: ```tsx 'use client'; import { useState, useEffect } from 'react'; import dynamic from 'next/dynamic'; const Oneko = dynamic(() => import('./oneko').then((m) => m.Oneko), { ssr: false }); const Oinu = dynamic(() => import('./oinu').then((m) => m.Oinu), { ssr: false }); const Ousagi = dynamic(() => import('./ousagi').then((mod) => mod.Ousagi), { ssr: false }); export function Pet() { const [activePet, setActivePet] = useState<'cat' | 'dog' | 'rabbit' | null>(null); useEffect(() => { const rand = Math.random(); if (rand < 0.33) setActivePet('cat'); else if (rand < 0.66) setActivePet('dog'); else setActivePet('rabbit'); }, []); if (activePet === 'cat') return ; if (activePet === 'dog') return ; if (activePet === 'rabbit') return ; return null; } ``` Now, only the chosen pet chunk is loaded on mount, keeping unused pet code out of the initial bundle. --- ### Upgrading Target to ES2022 I updated `tsconfig.json` to target `ES2022`: ```json { "compilerOptions": { "target": "ES2022" } } ``` This prevents the bundler from polyfilling native modern array and object methods. --- ## 3. Results After deploying the changes to GitHub Pages, the new Mobile PageSpeed Insights run showed significant gains: - **Performance Score (Mobile)**: Improved from **85/100** to **99/100** (+14 points). - **Total Blocking Time (TBT)**: Slashed from **580ms** down to **~40–50 ms** (over 90% reduction). - **Speed Index**: Faster from **2.2s** to **1.8s** (18% speedup). - **First Load Shared JS**: Reduced from **~125 kB** to **106 kB** (15% smaller bundle). Small, targeted changes to script loading strategy and dynamic imports can unlock near-perfect PageSpeed scores without stripping features. --- # Deploying Claude Usage: SQLite, Postgres, or MySQL — and Google Sign-In URL: https://ajaydandge.dev/blog/deploying-claude-usage-sqlite-postgres-or-mysql-and-google-sign-in Published: 2026-07-27 A while back I wrote about [building Claude Usage](https://ajaydandge.dev/blog/building-claude-usage-per-person-cost-tracking), a plugin and server that track per-person token usage when a team shares Claude accounts. That post ended on a happy note: one container, one SQLite file, self-host in an afternoon. An afternoon project is a great way to find out what the afternoon left out. This is what it took to actually hand it to my team. ## What it looks like Here's the dashboard everyone lands on after signing in — totals across the team, a daily activity heatmap, the token and cost trend, and a per-user breakdown you can drill into by session or by account. The numbers below are from a demo dataset, not my team's actual spend. Claude Usage dashboard — team totals, activity heatmap, token and cost trend, and per-user summary It follows your system theme, so the screenshot above is dark if you're reading this in dark mode. ## What is a turn, really The plugin always read the whole transcript the first time it synced a session, so no history was lost. But it collapsed that history into a single aggregated total, stamped with the moment it synced. You'd learn that a session cost some amount — not which turn cost what, and not when any of it actually happened. A week-old session and a fresh one both landed on today's date. So the backfill now splits that same history into individual turns, each carrying its real timestamp from the transcript. After the first sync it does what it did before: reads only the bytes appended since last time. The catch was defining a turn. A transcript is an append-only log of entries, and both a real user prompt and a tool result come back as `type: "user"`. If you split on every user entry, a single turn that made five tool calls becomes six rows with six timestamps, none of them real. So a turn starts on a *genuine* user prompt and swallows everything up to the next one: ```python def _is_user_prompt(entry): # A tool result is also type "user" — tell them apart by shape. content = entry.get("message", {}).get("content") if isinstance(content, list): return not any(b.get("type") == "tool_result" for b in content) return True ``` Getting this wrong doesn't error. It just quietly smears one turn across several timestamps, and you don't notice until the timeline looks off. I found out by backfilling a month-old transcript and checking the per-day totals against what I remembered spending. Backfill also forced a change I'd been avoiding. The old events each carried a UUID for idempotency, which is fine when every event is new. But a backfilled turn has to produce the *same* ID every time, or a re-sync double-counts the history. So the ID is now `session:turn_index` — deterministic, so the server recognizes a turn it has already stored no matter how many times the plugin re-reads the file. ## One codebase, three databases "One SQLite file" is the right answer right up until you want to deploy somewhere that doesn't keep a local disk between restarts. A lot of hosts hand you an ephemeral filesystem, and SQLite on an ephemeral disk means your data resets on every deploy. To put this anywhere real, it needed to talk to a managed database. So the storage layer moved to SQLAlchemy, and `DATABASE_URL` now picks the backend: SQLite by default, PostgreSQL, or MySQL. What I *didn't* do was adopt the ORM. The queries here are analytical — sums grouped by day, by person, by account — and they read far more clearly as SQL than as a chain of query-builder calls. SQLAlchemy Core gives you the connection, the parameter binding, and the schema-as-metadata that migrations need, and lets you keep writing SQL. That was the sweet spot. Portability is the part nobody warns you about, because none of it fails on SQLite — SQLite is forgiving, and every one of these only bites on Postgres or MySQL: - **No `INSERT OR IGNORE`.** That's SQLite dialect. The idempotency check is now a plain "have I seen this id" lookup before the insert. - **No dialect date functions.** I precompute a `day` column (`YYYY-MM-DD`) at insert time, so the daily rollup groups by a plain indexed string instead of calling `date()` or `substr()` differently on each backend. - **No rounding doubles in SQL.** `ROUND(x, 6)` on a float behaves differently across engines and outright errored on Postgres, so cost is rounded in Python after the query. - **Strict `GROUP BY`.** SQLite lets you select a column you didn't group by; Postgres and MySQL don't. Every selected column is either aggregated or in the `GROUP BY` now. - **No `lastrowid` everywhere.** Reading back an auto-increment ID after an insert isn't portable, so where I need the new ID I select it back by a unique column. Alembic handles the schema. `init_db()` runs `alembic upgrade head` on startup, which both creates the tables on a fresh database and applies any pending migration on an existing one, so a deploy never lands on a stale schema. The Postgres and MySQL drivers ship as optional extras, and the Docker image installs all of them — the database is chosen at runtime by `DATABASE_URL`, not baked into the build. ## Two ways to log in, one person behind them The dashboard started with Microsoft Entra ID because that's what my team already had. But "share a Claude account" and "everyone's on the same identity provider" are not the same assumption, so I added Google as a second option. The rule that made this simple: a person is their email, not their provider. Whether you sign in with Microsoft or Google, you land on the same user row keyed by the verified email from the ID token. There's no "Google user" and "Microsoft user" to reconcile later. The login button you see is just whichever providers are actually configured — set the Google client ID and secret and the Google button appears; leave them out and it doesn't. And if *nothing* is configured, the sign-in UI doesn't render at all in production, because a login screen with no way to log in is just a confusing error. Both providers go through the same OpenID Connect flow. The one asymmetry worth noting: Microsoft Entra ID's discovery URL is tenant-specific, so it's built from your tenant ID, while Google's is the same fixed URL for everyone. For Google I also check `email_verified` on the token before trusting the address, since the whole identity model rests on that email being real. ## Getting it onto a teammate's machine The plugin installs from a Claude Code marketplace — which is just a `marketplace.json` in a GitHub repo — and there turned out to be three ways to do it, a distinction I only learned by watching people get stuck. The clean path is the command line: ```bash claude plugin marketplace add iajaydandge/claude-usage-plugin claude plugin install claude-usage@claude-usage \ --config API_KEY= --config BASE_URL= ``` Interactive works too: run `/plugin`, install `claude-usage`, and answer the config prompts. The one thing to remember there is to run `/reload-plugins` afterward, or the hooks don't come alive until your next session. The one that tripped everyone up is VS Code. The graphical `/plugins` panel looks like the obvious place, and it is — right up until you realize there's nowhere to type the API key or server URL. It can't take config. The workaround isn't something you'd guess: open a terminal from the Claude panel's `/` menu ("Open Claude in Terminal"), then run `/plugin` there. Same command as the command-line path, reached through a side door. Watching that confusion also made me split the docs up by who's reading them: the top-level README is just "install the plugin," the server's README is "host the server," and the plugin's README is the internals for anyone hacking on it. Three audiences, three files, so nobody has to scroll past hosting instructions to find an install command. ## The price table will drift, and that's fine One small thing that's easy to forget: the cost estimate is only as current as your price table. A new model ships, someone runs a turn on it, and the dashboard shows `$0.00` with a little marker meaning "I couldn't price this." That's not a bug so much as a reminder to add a row. It happened the day Claude Opus 5 landed — a session showed up unpriced, and the fix was one line in the model table. The `~` marker on estimated costs earns its keep here: it's the difference between "this is free" and "I don't know what this cost yet." None of this changed what the tool *is*. It still observes, still never blocks Claude Code, still trusts the API key over the payload. What changed is everything around the edges that turns a thing that runs on your laptop into a thing your team can log into. As usual, the edges were most of the work. The full source is on GitHub: [iajaydandge/claude-usage-plugin](https://github.com/iajaydandge/claude-usage-plugin). Issues, ideas, and pull requests are all welcome — if you run it on a setup I haven't tried, I'd love to hear how it goes. --- # Building Claude Usage: Per-Person Cost Tracking on a Shared Claude Account URL: https://ajaydandge.dev/blog/building-claude-usage-per-person-cost-tracking Published: 2026-07-25 While we were adopting Claude Code, we had more developers than Claude accounts, so a few people shared each one. That's fine until you want to know who on an account is actually using it. Billing shows a total per account, not the split between the people behind it. So I built **Claude Usage**. A plugin records usage on each machine, a small FastAPI + SQLite server collects it, and a dashboard breaks it down per person. Most of the work wasn't capturing the data. Claude Code hands you that through hooks. It was what to do with it afterward. ## The shape of the problem Claude Code already writes a transcript for every session, and it fires hooks at well-defined points. That's the whole opportunity: I don't need to intercept anything or wrap the CLI. I just need to *observe*. Two constraints shaped everything else: 1. **Never get in the way.** A tracker that slows down or blocks Claude Code is worse than no tracker. It has to be a pure observer. 2. **Attribute usage to a person, not the account.** A session's usage is billed to whichever account it runs under, not to the person at the keyboard. The interesting axis is the human running the session. ## The plugin is a pure observer The plugin registers two hooks: `Stop` (end of a turn) and `SessionEnd`. The `Stop` hook reads the transcript, records the turn, and exits `0`. Always `0`, no matter what: ```python try: hook_data = json.load(sys.stdin) except Exception: sys.exit(0) # malformed input — never block Claude Code ``` If parsing fails, if the server is down, if the disk is full, Claude Code still stops normally. You shouldn't be able to tell the tracker broke. The other half of "never get in the way" is not blocking on the network. The hook doesn't POST anything. It writes to a **local SQLite outbox** and returns. A separate background step drains the queue to the server, and the `SessionEnd` hook does a final drain of whatever is still pending. If the server is unreachable, events sit in the queue as `pending` and go out on a later run, so nothing is lost when you're on a plane or the server is mid-deploy. The sync worker retries with exponential backoff, and after enough failures it gives up on an event instead of hammering forever: ```python BACKOFF_SECONDS = [0, 2, 5, 15, 30, 60, 300, 900, 1800, 3600] MAX_ATTEMPTS = len(BACKOFF_SECONDS) ``` Delivery is idempotent. Every event carries a UUID, and the server ignores one it has already seen, so a retry after a half-succeeded POST never double-counts. ## Identity comes from the key, not the payload This one is easy to get wrong. The plugin could just put `email` in the payload, but anything the client self-reports, the client can lie about. Instead, each machine is configured with an API key, and the server resolves the key to a user. The payload's job is to carry *metrics*, not *identity*: ```python # Identity is the API key, resolved server-side — never self-reported. _insert_usage(conn, user["email"], event.payload) ``` There's a test that exists purely to keep me honest. It sends a payload claiming to be `attacker@evil.com` and asserts the row lands under the key's real owner. ## Per-turn deltas, not cumulative snapshots The first version re-read the whole transcript on every `Stop` and summed all the tokens. That's simple, but each event then holds the session's *running total*, which means the server has to aggregate with `MAX()`, and you can never see what a single turn cost. The fix is to read only what was appended since last time. I keep a byte offset per session in a small `state.json`: ```python offset = 0 if start_offset > size else start_offset # file shrank? re-read from the top with path.open("rb") as fh: fh.seek(offset) chunk = fh.read() ``` Now each event is one turn's **delta**. The server stores a turn-by-turn timeline, aggregation becomes a plain `SUM()`, and the dashboard can show you which turn in a session blew the budget. The `> size` check handles compaction: if the transcript got shorter than our offset, it was rewritten, so we start over. ## Cost: use the real number, compute it when you can't Claude Code sometimes reports `total_cost_usd` in the transcript and sometimes it's zero. Rather than pick one source, I use both: the transcript's number when it's there, otherwise a value computed from the token counts and a per-model price table. ```python reported = float(p.get("cost_usd") or 0) if reported > 0: return round(reported, 6), "transcript" # else: tokens × model_pricing → ("computed" or "unpriced") ``` Every row records where its cost came from: `transcript`, `computed`, or `unpriced`. The dashboard marks estimated costs with a small `~` so nobody mistakes a computed number for a billed one. ## Three roles, one visibility rule The access model took a few tries to get right. It comes down to three roles: - **Member:** sees only their own usage. - **Account owner:** the person whose login email *is* the shared account's email. They see everyone billed to that account. - **Admin:** sees everything, across all accounts. That "account owner" definition is the neat part. I don't maintain a separate ownership table; ownership is just `login_email == account_email`. The whole rule is one WHERE clause: ```python if not user["is_admin"]: conditions.append("(email = ? OR account_email = ?)") params += [user["email"], user["email"]] ``` The same clause guards the summary, the sessions list, the per-session timeline, and the accounts view. One rule, applied everywhere, is a lot easier to trust than four. ## What it never sends People will ask, so it's worth stating plainly: the plugin sends metrics only. Token counts, model, cost, session ID, and the working directory. Prompts, responses, and tool inputs and outputs never leave your machine. ## Where it landed The server runs as a single container: one FastAPI process, one SQLite file, and the built dashboard served from the same origin. Dashboard auth is Microsoft Entra ID. The plugin ships as a **Claude Code marketplace**, which is just a `marketplace.json` hosted in a GitHub repo. You point Claude Code at the repo, then install with the key and server URL you got from the dashboard: ```bash claude plugin marketplace add iajaydandge/claude-usage-plugin claude plugin install claude-usage@claude-usage \ --config API_KEY= --config BASE_URL= ``` The hooks start recording on your next session. It's small enough to self-host in an afternoon, which was the point. Almost none of this was about fighting Claude Code. It hands you transcripts and hooks and gets out of the way. The work was in what came after the data: surviving an offline laptop, not trusting the client, and figuring out what a "turn" even is. Though, as usual, that last one was tougher than it looked. The full source (plugin, server, and dashboard) is on GitHub: [iajaydandge/claude-usage-plugin](https://github.com/iajaydandge/claude-usage-plugin). --- # Using Claude Code with Non-Anthropic Models via LiteLLM URL: https://ajaydandge.dev/blog/using-claude-code-with-non-anthropic-models-via-litellm Published: 2026-06-30 Was working on a project with Claude Code when the session limit ran out. Tried routing Claude Code through non-Anthropic models instead — using a local [LiteLLM](https://docs.litellm.ai/) proxy in front of it. Here's the setup using [OpenRouter](https://docs.litellm.ai/docs/providers/openrouter) as the backend, since it has free models available. ## Install LiteLLM ```bash uv tool install 'litellm[proxy]' ``` If `uv` is not installed, see the [install guide](https://docs.astral.sh/uv/getting-started/installation/). ## Configure the model LiteLLM is controlled by a `config.yaml`. Each entry maps a `model_name` (what you'll reference from Claude Code) to `litellm_params` (how LiteLLM actually calls the provider): ```yaml model_list: - model_name: gpt-oss-120b litellm_params: model: openrouter/openai/gpt-oss-120b:free api_key: os.environ/OPENROUTER_API_KEY ``` `model` under `litellm_params` is the provider-prefixed identifier LiteLLM uses to route the request — `openrouter/` followed by the model path. Browse [free, coding-capable models on OpenRouter](https://openrouter.ai/models?max_price=0&categories=programming) and copy the model name from there (e.g. `openai/gpt-oss-120b:free`). Generate an API key at [openrouter.ai/workspaces/default/keys](https://openrouter.ai/workspaces/default/keys). ## Start the proxy Set a master key — this is what authenticates requests to your local proxy, separate from the OpenRouter key: ```bash export LITELLM_MASTER_KEY=$(openssl rand -base64 32) export OPENROUTER_API_KEY=... litellm --config /path/to/config.yaml ``` If `openssl` is not installed, any random string works — `uv run python -c "import secrets; print(secrets.token_urlsafe(32))"` or [generate-secret.vercel.app/32](https://generate-secret.vercel.app/32). LiteLLM listens on `http://localhost:4000` by default. ## Point Claude Code at it In a new terminal: ```bash export ANTHROPIC_BASE_URL=http://localhost:4000 export ANTHROPIC_AUTH_TOKEN= export ANTHROPIC_MODEL=gpt-oss-120b claude --model gpt-oss-120b ``` `ANTHROPIC_MODEL` should match the `model_name` from `config.yaml`, not the underlying provider model string. ## Claude Code extension in VS Code Same three environment variables, set before launching: ```bash export ANTHROPIC_BASE_URL=http://localhost:4000 export ANTHROPIC_AUTH_TOKEN= export ANTHROPIC_MODEL=gpt-oss-120b code ``` --- References: - [LiteLLM — non-Anthropic models with Claude Code](https://docs.litellm.ai/docs/tutorials/claude_non_anthropic_models) - [Claude Code — credential management](https://code.claude.com/docs/en/agent-sdk/secure-deployment#credential-management) --- # Burrow Adds a Read-Only Guard and Agent Skills URL: https://ajaydandge.dev/blog/burrow-adds-read-only-guard-and-agent-skills Published: 2026-06-22 A few more additions to [burrow](https://github.com/iajaydandge/burrow) since the [last post](https://ajaydandge.dev/blog/burrow-adds-mysql-secure-passwords-and-claude-code-skill). ## Read-only guard The use case that prompted this: I wanted to point a coding agent at a production database so it could explore schema and data without any risk of it accidentally issuing a write. The credentials had full read-write access, but I only needed reads — and reducing the risk surface meant adding a guard that fails early, before the tunnel even opens, rather than relying on discipline or hoping the agent never constructs a valid `INSERT`. Burrow now has an `access_mode` field per profile: ```toml [readonly] db_type = "postgres" use_ssh = true ssh_host = "ssh.example.com" ssh_user = "sshuser" ssh_key_path = "~/.ssh/id_rsa" db_host = "db-replica.example.com" db_user = "appuser" db_name = "appdb" access_mode = "read" ``` When `access_mode = "read"`, burrow inspects the statement before opening the tunnel. If it detects a write operation — `INSERT`, `UPDATE`, `DELETE`, `TRUNCATE`, `REPLACE`, `MERGE`, or any DDL (`CREATE`, `DROP`, `ALTER`, `RENAME`) — it exits immediately: ``` error: profile is configured for read-only access (access_mode = read) statement type 'DELETE' is not allowed to allow writes, set access_mode = readwrite in your profile ``` The check is keyword-based and handles a few edge cases worth mentioning. Comments are stripped first, so a line comment before `SELECT` correctly resolves to `SELECT` rather than being blocked. CTEs are also handled — `WITH ids AS (...) UPDATE users SET active = false` correctly resolves to `UPDATE`, not `WITH`. The check works identically for PostgreSQL and MySQL. The default is `readwrite`, so existing profiles are unaffected. One caveat: this is a client-side guard. It blocks common write statements but does not enforce read-only at the database level. For true read-only access, use database credentials that only have SELECT privileges. ## Non-interactive `config set` Before this change, updating a single field in a profile required either editing the TOML file by hand or running the full interactive wizard and tabbing through every prompt. Neither is great when you are scripting environment setup or just need to rotate a value. `burrow config set` now accepts an optional key and value: ```bash burrow config set db_host db.example.com burrow config set access_mode read burrow config set db_port 5433 burrow --profile staging config set db_name appdb_staging ``` Without arguments it still runs the full interactive wizard — the existing behaviour is preserved. With a key and value it updates just that field and exits. Types are handled correctly: integer fields are stored as integers, booleans accept `true/false/yes/no/on/off`, enum fields (`db_type`, `access_mode`) validate the value before writing. Passwords work too, with the same separate-file behaviour as the wizard: ``` $ burrow config set db_password Database password: ``` ## Agent Skills The `burrow skill install` command previously installed the skill file to `~/.claude/skills/burrow/SKILL.md` — Claude Code was the only agent available to test with at the time. The plan was always to support more. [Agent Skills](https://agentskills.io) is an open standard, and each agent has its own installation path. The default install path is now `~/.agents/skills/burrow/SKILL.md` — the universal path picked up by Codex, Cursor, GitHub Copilot, OpenCode, and others. For agents with their own specific path, use `--agent`: ```bash burrow skill install # installs to ~/.agents/skills/ burrow skill install --agent claude-code # installs to ~/.claude/skills/ burrow skill install --agent cursor,copilot # installs to both ``` Supported agent names: `agents`, `claude-code`, `codex`, `copilot`, `cursor`, `opencode`. Codex uses `~/.agents/skills/` natively so `--agent codex` and `--agent agents` resolve to the same path. The outdated-skill warning at startup checks all known install locations, so if you have the skill installed for multiple agents, any one of them being stale will prompt you to update. --- The source is at [github.com/iajaydandge/burrow](https://github.com/iajaydandge/burrow). --- # Burrow Adds MySQL, Secure Passwords, and a Claude Code Skill URL: https://ajaydandge.dev/blog/burrow-adds-mysql-secure-passwords-and-claude-code-skill Published: 2026-06-01 A few updates to [burrow](https://github.com/iajaydandge/burrow) since the [last post](https://ajaydandge.dev/blog/burrow-now-supports-direct-connections). This one is more substantial. ## MySQL support Burrow started as a PostgreSQL tool because that was what I needed. But the tunnel-and-query pattern is not specific to Postgres — MySQL sits behind bastions just as often. So burrow now supports both. You declare the database type in your profile: ```toml [default] db_type = "postgres" ... [analytics] db_type = "mysql" ... ``` Everything else — `query`, `describe`, output formats, SSH tunnel mode, direct connection mode, profile switching — works the same regardless of type. Port defaults are type-aware: postgres uses 5432, mysql uses 3306, unless you override them. ## Passwords out of config.toml The original design stored the database password directly in `~/.config/burrow/config.toml`. The problem surfaced with Claude Code: when exploring the project or resolving config issues, Claude would `cat` the config file and the password would appear in plain text in the conversation. Passwords are now stored in a separate file at `~/.config/burrow/profiles/.password`, with permissions set so only the current user can read it. The config file stays clean. The `config set` wizard writes the password file for you, so the setup flow is unchanged — you still just type your password when prompted. If you prefer not to use a file at all, `BURROW_DB_PASSWORD` in your environment still takes priority over everything. ## `burrow skill install` Burrow has shipped a `SKILL.md` for Claude Code since the beginning — it tells Claude when to reach for burrow and how to use it. Previously, you had to copy this file manually from the repository to `~/.claude/skills/`. That was easy to forget and easy to get wrong. Now the skill file is bundled inside the installed package, and a single command handles it: ```bash burrow skill install ``` It also stays in sync with your installed version. If you upgrade burrow without reinstalling the skill, you get a warning the next time you run any burrow command — a nudge to keep the two aligned. ## `burrow shell` is gone The interactive REPL held an open tunnel and connection for the duration of the session. It seemed useful in theory, but one-shot queries cover the same use case and are easier to reason about. Removing it simplifies both the codebase and the mental model of what burrow does. --- The source is at [github.com/iajaydandge/burrow](https://github.com/iajaydandge/burrow). --- # Burrow Now Supports Direct Connections URL: https://ajaydandge.dev/blog/burrow-now-supports-direct-connections Published: 2026-05-13 A small addition to [burrow](https://github.com/iajaydandge/burrow) since the [last post](https://ajaydandge.dev/blog/building-burrow-cli-for-querying-databases-behind-bastion). Burrow is primarily a tool for querying databases behind a bastion — that has not changed. But occasionally you want to run the same queries against a local development database that does not need a tunnel. Rather than context-switch to `psql`, you can now add a profile with `use_ssh = false`: ```toml [local] use_ssh = false db_host = "localhost" db_user = "myuser" db_password = "secret" db_name = "mydb" ``` When `use_ssh` is false, burrow skips the SSH transport and connects to PostgreSQL directly. Everything else — `burrow query`, `burrow describe`, `burrow shell`, output formats, profile switching — works exactly the same. `use_ssh` defaults to `true`, so existing configs continue to work without any changes. The `config set` wizard also picks this up: it asks about SSH mode first and only prompts for bastion details when needed. --- The source is at [github.com/iajaydandge/burrow](https://github.com/iajaydandge/burrow). --- # The OS Knows the Timezone, Not Your Language URL: https://ajaydandge.dev/blog/the-os-knows-the-timezone-not-your-language Published: 2026-04-28 Every language asks the OS for the current time. The language itself does not know what timezone the machine is in — the OS does, from system settings. What the language controls is whether it asks the OS for local time or for UTC. That one distinction is the root of most timezone bugs in production. Everything else — ambiguous strings, broken queries, wrong date partitions — is a consequence of getting it wrong. ## How this plays out When you ask for local time, you get the raw clock reading with no label attached. Run this on machines in different countries and you get different strings for the same moment:
Machine location Local time String produced
India (IST +5:30) 10:30 2026-04-28T10:30:00
UK (UTC +0) 05:00 2026-04-28T05:00:00
US (EST -5) 00:00 2026-04-28T00:00:00
When you ask for UTC, the OS applies its own timezone config and returns the converted value. The language never sees the local time at all — it just receives UTC directly:
Machine location Local time String produced
India (IST +5:30) 10:30 2026-04-28T05:00:00+00:00
UK (UTC +0) 05:00 2026-04-28T05:00:00+00:00
US (EST -5) 00:00 2026-04-28T05:00:00+00:00
Every machine, the same string. The syntax for asking varies by language, but the mechanism is always the same — you are delegating the conversion to the OS, not implementing it yourself: ``` Python datetime.now(timezone.utc).isoformat() JavaScript new Date().toISOString() Go time.Now().UTC().Format(time.RFC3339) Java Instant.now().toString() ``` All of these ask the OS for UTC. The OS does the work. ## The ambiguity problem A string like `2026-04-28T10:30:00` has no meaning on its own. Is that London time? India time? US time? When you store it, compare it, or ship it across a network, there is no way to know — which is exactly what the table above shows. A timezone-aware datetime like `2026-04-28T05:00:00+00:00` is a fixed point in time, globally. The `+00:00` is proof that the OS already did the conversion. No guessing required. ## Should you always use aware datetimes? Not always — it depends on what the datetime represents. **Use aware datetimes when the value crosses a boundary** — stored in a database, sent over a network, compared across machines, or written to a log. Anything where two different systems need to agree on what moment you mean. This is the majority of production code. **Naive datetimes are fine when the timezone is implicit and fixed for the entire computation.** A few real cases where this holds: _Scheduling relative to local time._ "Send this notification at 9am every day" is actually a problem for UTC. If you store `09:00 UTC` and the user is in India, they get it at 2:30pm. Worse, when daylight saving shifts for other users, the UTC equivalent moves by an hour. The intent is "9am in the user's local timezone" — which is inherently a local, naive concept. The right model is to store the time and the IANA timezone name separately (`09:00` + `Asia/Kolkata`), then combine them at dispatch time using a proper timezone library. _Purely local computation._ Measuring elapsed time on a single machine, generating a report that will only ever be read in one fixed office timezone, processing a file where every row is implicitly in the same timezone and nothing leaves the system. If the datetime never travels, ambiguity cannot hurt you. The bugs come from mixing the two accidentally — a naive datetime from one path ending up compared or stored alongside an aware one from another. Most languages raise an error or silently misbehave when this happens. The fix is to be deliberate: choose naive intentionally for local-only work, aware for everything that crosses a boundary. ## Why it actually matters The consequences are not always obvious at first, which is what makes them dangerous. **Database storage.** Most databases store UTC when configured to do so. Writing a naive datetime stores it as-is with no conversion, so range queries silently return wrong results. Everything looks fine until you query across a midnight boundary. **Date partitioning.** If you build storage paths or cache keys from year, month, and day, a wrong timezone shifts data into the wrong partition. A record that should land in `2026/04/28/` ends up in `2026/04/27/` for a server in the US. The data is there — it is just unfindable. **Cross-system consistency.** Clients running in different countries must produce the same UTC timestamp for the same event. A naive timestamp from an Indian client and one from a UK client will differ by five and a half hours even if they recorded the same moment. **Comparisons.** Comparing a naive datetime with an aware one either raises an error or produces a nonsensical result depending on the language. If both types end up in the same collection, sorting and filtering break. ## Formats in transit Once you have an aware datetime, you need to serialize it to send it over a network. There are a few formats you will encounter. **ISO 8601** is the right choice for almost everything. The shape is `2026-04-28T05:00:00+00:00` — the `T` separates the date and time parts, and the suffix is the UTC offset. This is what most languages produce from a UTC-aware datetime and what most parsers expect. `Z` is shorthand for `+00:00` — nothing more. `2026-04-28T05:00:00Z` and `2026-04-28T05:00:00+00:00` are identical. JavaScript's `new Date().toISOString()` produces `Z`. Python's `isoformat()` produces `+00:00`. They mean the same thing; only the spelling differs. The offset is not the timezone — it is just a number. `+05:30` means "this time is 5 hours and 30 minutes ahead of UTC." IST happens to carry that offset, but `+01:00` could be UK in summer, France in winter, or several other regions. A parser does not need to identify the region. It just does the arithmetic: `10:30 − 05:30 = 05:00 UTC`. **Milliseconds vs microseconds** vary by language. JavaScript timestamps carry milliseconds, Python carries microseconds, Go carries nanoseconds. ISO 8601 supports fractional seconds at any precision — `2026-04-28T05:00:00.123Z` is valid. Most APIs accept fractional seconds but some reject them. When in doubt, truncate to whole seconds. **Unix timestamps** are the other common format — an integer counting seconds (or milliseconds) since `1970-01-01T00:00:00Z`. No timezone ambiguity is possible because the epoch is fixed at UTC. You see this in JWT `iat`/`exp` fields, Stripe webhooks, and most Unix-native tooling. The only gotcha is milliseconds vs seconds — JavaScript `Date.now()` returns milliseconds, most Unix tools expect seconds. Off by a factor of 1000 is a recognisable bug at least. **RFC 2822** is the format email and HTTP headers use — `Mon, 28 Apr 2026 05:00:00 +0000`. You will rarely produce this yourself but you will occasionally need to parse it from email headers or old RSS feeds. **RFC 3339** is often cited in API docs and is effectively a stricter subset of ISO 8601 — the `T` separator and a timezone offset are both mandatory. A standard UTC-aware ISO 8601 string already satisfies it. In practice: use ISO 8601 with an explicit offset for REST APIs, Unix timestamps for internal event systems and queues, and only reach for the others when a specific protocol requires it. The syntax changes by language. The rules do not. --- # How I Understand LLMs URL: https://ajaydandge.dev/blog/how-i-understand-llms Published: 2026-04-15 This is a simple way to think about how large language models work. An LLM is not a knowledge base or a reasoning engine. It is a system that has learned to continue text in a way that looks correct. ## The vocabulary problem Language is messy. Computers are not. So the first step is turning text into something a machine can work with. That starts with a fixed **vocabulary** of tokens. A token is not necessarily a word. It is a chunk of text the model treats as a unit. Common words may be single tokens. Others get split: - `"cat"` → one token - `"something"` → `"some"` + `"thing"` Instead of storing every possible word, the model uses a limited set of subword pieces — typically around 30,000. Any word can be constructed from these pieces. This vocabulary is fixed after training. Each token is mapped to an integer: ``` "cat" → 2368 "some" → 1045 "thing" → 902 ``` There are also special tokens — for stopping, padding, and formatting — treated the same way. ## The core loop At its simplest, the model does one thing: > Given a sequence of tokens, predict what token comes next. You pass in tokens. The model processes all of them together and produces a probability distribution over the entire vocabulary. For example: ``` "sat" → 0.40 "slept" → 0.25 "ran" → 0.10 ... ``` This happens through a neural network — a stack of transformer layers that combine attention (looking at relationships between tokens) and feed-forward transformations. You do not need to know the math to understand the behaviour: > It is a system trained to guess what comes next, based on patterns it has seen before. ## Temperature — shaping the output The model gives probabilities. Something still has to pick a token. That is where sampling comes in. If you always pick the highest probability, the output is deterministic. Same input, same result every time. Temperature reshapes the distribution before sampling: - Low temperature → sharper distribution → predictable - High temperature → flatter distribution → more variation Higher values introduce diversity, but also increase the chance of poor output. Lower values produce more stable, repeatable responses. Temperature does not add randomness directly. It changes how likely each option is before one is selected. ## One token at a time The model predicts one token. A response is many tokens. So the process repeats: 1. Take input tokens 2. Predict next token 3. Append it 4. Run again This loop continues until a stop condition is reached. The model itself runs once per step. The looping is handled externally by an inference engine. ## The context window The model does not see everything. It sees a fixed number of recent tokens. This is the **context window**. It includes: - your input - previous messages - the model's own output Everything inside this window is processed together. Everything outside it does not exist to the model. There is no long-term memory. No hidden state across conversations. Just the current context. ## What actually runs the model There are three distinct layers involved: **The model** - Takes tokens - Produces probabilities - Has no memory of past interactions **The inference engine** - Runs the model repeatedly - Handles sampling and stopping - Maintains a short-term cache so previous tokens are not recomputed **The application** - Stores chat history - Builds the prompt - Decides what fits into the context window When you send a message, the application reconstructs the conversation, formats it, and sends it to the model. The model only sees that final input. ## Chat models Chat models are not a different kind of system. They still predict the next token. What changes is training. They are further trained to: - follow instructions - produce helpful responses - maintain conversational tone They also use structured formats like: ``` User: Hello Assistant: Hi ``` This is just text. The model has learned how to behave within it. ## Training — where the knowledge comes from Training uses the same idea as inference: predict the next token. Large amounts of text are fed into the model. At each step, it predicts what comes next. The difference between prediction and reality is measured as error. The weights of the network are then adjusted using optimization algorithms to reduce that error. This process repeats across massive datasets. Over time, the model learns patterns of: - language - facts - structure - style These patterns are stored in the weights — billions of numerical parameters. ## Weights and size A model described as “7B” has 7 billion parameters. Each parameter is a number. Typically stored in 16-bit precision: ``` 7 billion × 2 bytes ≈ 14 GB ``` That is the model file. Quantization reduces precision (for example, to 8-bit or 4-bit) to make models smaller and faster, with some loss in accuracy. ## System prompts Before your input, the system often inserts hidden instructions: ``` You are a helpful assistant. Respond concisely. ``` This shapes behaviour — tone, constraints, style. It is part of the input and counts toward the context window. ## Hallucination The model generates what is most plausible, not what is guaranteed to be true. It does not: - check facts - access external sources - verify correctness It has no built-in mechanism for truth — only for plausibility. This is why it can produce confident but incorrect answers. ## The whole thing in one line Text is broken into tokens. Tokens become numbers. A trained neural network predicts what comes next. One token is chosen. The process repeats. Everything else — conversation, reasoning, personality — emerges from that loop. --- # Building Burrow: A CLI for Querying Databases Behind a Bastion URL: https://ajaydandge.dev/blog/building-burrow-cli-for-querying-databases-behind-bastion Published: 2026-04-10 At work, our PostgreSQL database sits behind a bastion host. You cannot connect to it directly — every connection has to go through an SSH tunnel first. This is standard practice for securing RDS instances in a VPC, but it makes ad-hoc querying tedious. You have to open a tunnel in one terminal, connect with `psql` in another, and remember to clean up after yourself. I wanted something simpler: a single command that opens the tunnel, runs the query, prints the result, and disappears. That became [burrow](https://github.com/iajaydandge/burrow). ## Why the name A burrow is the tunnel a mole digs underground to reach somewhere hidden. It does not knock. It does not ask. It just finds a way through. That felt right for a tool that quietly bores through a bastion over SSH and surfaces inside a database that was never meant to be directly reachable. ## The tunnel The first thing I reached for was [`sshtunnel`](https://pypi.org/project/sshtunnel/), a popular PyPI package that wraps paramiko and promises to make SSH port forwarding a one-liner. It did not get far. `sshtunnel v0.4.0` expects `paramiko >= 2.7.2`, but the project was using `paramiko 3.x`. The two do not play well together — paramiko 3.x removed and restructured several internals that `sshtunnel` depended on, so you get `AttributeError` at runtime on things like `DSSKey` that simply no longer exist in the same form. The package has not been updated to track paramiko's major releases, which means you are stuck: pin to an old paramiko to keep `sshtunnel` happy, or drop `sshtunnel` and use paramiko directly. I dropped it. `paramiko` is what `sshtunnel` wraps anyway — by using it directly I got full control over the transport, the channel lifecycle, and error handling, without a wrapper that was going to fight me on versions. The implementation is more code, but it is code I understand and can debug. The core of burrow is a `PostgresSSHTunnel` class built on `paramiko`. The idea is straightforward: open an SSH connection to the bastion, bind a local socket to a random free port, and forward any connections on that port to the RDS host through the SSH transport. ```python self.server_socket.bind(("127.0.0.1", 0)) # 0 = let the OS pick a free port self.local_port = self.server_socket.getsockname()[1] ``` Binding to port `0` rather than a hardcoded `5432` is a small but important detail. It means two burrow sessions can run simultaneously without colliding, and you never have to worry about a stale process already holding the port. Once the tunnel is up, `psycopg` connects to `127.0.0.1:` as if it were a local database. The forwarding thread shuttles bytes between the local socket and the SSH channel transparently. The whole lifecycle is wrapped in a context manager, so the tunnel is always cleaned up: ```python with PostgresSSHTunnel(config) as tunnel: conn = tunnel.get_connection() ... ``` ## Configuration: the aws cli pattern Early on the config was loaded from a `.env` file using `pydantic-settings`. It worked, but it felt wrong — it tied the tool to a working directory and made it awkward to switch between environments. The better model is how `aws` and `gh` handle configuration: a file at `~/.config//config.toml` with named profiles, and environment variables that override everything. ```toml [default] ssh_host = "bastion.example.com" ssh_key_path = "~/.ssh/id_rsa" db_host = "mydb.cluster.us-east-1.rds.amazonaws.com" db_user = "myuser" db_password = "secret" db_name = "mydb" [staging] ssh_host = "bastion-staging.example.com" ... ``` Priority is: env vars beat the config file, which beats built-in defaults. The config module walks those three sources in order and exits with a helpful message if a required field is missing: ``` error: missing required config for profile 'staging': db_host (env: BURROW_DB_HOST) set them via environment variables, or add a config file: ~/.config/burrow/config.toml ``` The `--profile` flag is global, so it works across all subcommands: ```bash burrow --profile staging query "SELECT count(*) FROM users" ``` ## Interactive setup with `burrow config set` Typing out a TOML file is fine once — but if you want profiles to feel like a first-class feature, you need an interactive setup flow. `burrow config set` walks through every field, shows the current value in brackets, masks password input, and writes the result back to the config file. ``` $ burrow config set Bastion host (IP or hostname): bastion.example.com SSH username [ec2-user]: Path to SSH private key: ~/.ssh/id_rsa SSH port [22]: Database host: mydb.cluster.us-east-1.rds.amazonaws.com Database port [5432]: Database name: mydb Database username: ajay Database password: ******** Default schema [public]: Profile 'default' saved to ~/.config/burrow/config.toml ``` Re-running it is non-destructive — existing values are preserved unless you overwrite them. This is the same behaviour as `aws configure`, and it matters: you should be able to update a single field without re-entering everything. ## Output formats for Claude Code One of the use cases I had in mind from the start was using burrow inside [Claude Code](https://github.com/anthropics/claude-code). Claude Code can run shell commands and read stdout — which means if burrow outputs clean JSON, Claude can parse it and reason over it directly. ```bash burrow query "SELECT * FROM orders WHERE status = 'pending'" --output json ``` Supporting three output formats — `table` for humans, `json` for machines, `csv` for spreadsheets — covers most workflows without overcomplicating the interface. ## Packaging with uv The project is packaged as a proper Python package using `hatchling` and `uv`. The entry point is declared in `pyproject.toml`: ```toml [project.scripts] burrow = "burrow.cli:main" ``` Installing is one command: ```bash uv tool install git+https://github.com/iajaydandge/burrow.git ``` `uv tool install` puts the binary in `~/.local/bin` in its own isolated environment, so burrow's dependencies never conflict with anything else on your system. No virtualenv management, no `pip install --user` quirks. ## The easter egg There is a hidden command that does not appear in `--help`. If you find it, it prints a poem about what burrow does and why it is named that way. I will not say what the command is. --- Burrow is small and does one thing. The source is at [github.com/iajaydandge/burrow](https://github.com/iajaydandge/burrow) — install instructions, config reference, and usage examples are all in the README. --- # OAuth vs OpenID Connect: Stop Mixing Them Up URL: https://ajaydandge.dev/blog/oauth-vs-openid-connect Published: 2025-08-19 If you've ever clicked a “Log in with Google” button, you've touched protocols like **OAuth 2.0** and **OpenID Connect (OIDC)**. But here's the thing: OAuth was never meant for logging in. It was designed for something entirely different — and that's where OIDC comes in. In this post, we'll cut through the jargon and explain, in plain language, what OAuth is, why people misuse it, and how OIDC fixes the gap. ## OAuth: Delegated Authorization At its core, **OAuth (Open Authorization)** is a framework for _delegated access_. The idea is simple: > _“I want this app to access some of my resources, without handing it my password.”_ Think of it like giving a valet your car keys. You don't want them driving to another city — just parking your car. OAuth defines the rules and limits of that delegation. ### A Real OAuth Example: GitHub + CI/CD Tool Say you're using a CI/CD service (like Netlify, Vercel, or CircleCI). You want it to deploy code from your **private GitHub repos**. Instead of giving the service your GitHub credentials, you go through an OAuth flow: 1. The CI/CD tool redirects you to GitHub's OAuth page. 2. GitHub asks: _“This app wants to read and write your repos, and manage webhooks. Do you approve?”_ 3. You consent. 4. The tool gets an **access token** from GitHub. 5. It uses that token to call GitHub's API on your behalf. Notice what's missing here: the CI/CD tool doesn't really care _who you are_. It just needs the ability to act on your repos. That's pure OAuth. ## Where Things Got Messy: Using OAuth for Authentication Over time, developers noticed OAuth's flow worked well for redirecting users, getting tokens, and establishing trust. So they started using it for **authentication** (logging users in). But here's the problem: an **access token** only says, _“This token holder can access these resources.”_ It doesn't prove _who the user is_. That gap opened the door for security issues like: - **Token substitution attacks** (a stolen token could log someone else in). - **Ambiguity** (apps had to invent their own way to figure out user identity). Clearly, something was missing. ## Enter OpenID Connect: Authentication Layer on Top of OAuth To solve this, the industry created **OpenID Connect (OIDC)**. It's not a separate protocol from OAuth 2.0 — it's a layer on top of it. OIDC keeps OAuth's machinery (authorization codes, tokens, redirects) but adds identity-specific features: - **ID Token** → a special JWT (JSON Web Token) that contains user identity claims (e.g., email, user ID). - **UserInfo endpoint** → a standard way to fetch profile information. - **Defined authentication flows** → making it safe and interoperable. ### Example: “Log in with Google” When you click _Log in with Google_: 1. You're redirected to Google's OAuth/OIDC endpoint. 2. You log in (if you aren't already). 3. You approve the app's request. 4. The app gets two tokens: - An **access token** (for Google APIs, if needed). - An **ID token** (JWT) that says _“This is Alice, email [alice@gmail.com](mailto:alice@gmail.com), verified by Google.”_ Now the app can log you in securely, without guessing who you are. ## OAuth vs OIDC - OAuth is a protocol for authorization, letting apps access resources on behalf of users with an access token. - OpenID Connect is a layer on top of OAuth for authentication, providing ID tokens that securely tell apps who the user is. ### OAuth 2.0 Flow (Authorization, e.g. GitHub → CI/CD Tool) ![OAuth 2.0 Flow (Authorization, e.g. GitHub → CI/CD Tool)](https://mermaid.ink/svg/pako:eNptU19vmzAQ_yrWPUyJRDJSSgk8VIpotUXaNLR1LxMvHlzBCviYbba2Ub77DmiidYmf7PPvn-7sPRRUIiRg8VePusA7JSsj21wLXp00ThWqk9qJ7xbNeXXTdUJa8VArUy4yvngeS7N0-z69Ew9Ezfyc9EG5j_3Pgfe62_SuJqNepFOkxTc0vy96Zdt_Odk21xNoiLa4vWXjRKSNKnYih5S0xsKJP8rVr5QcJjjjGD3VEvEVS2UGpCPxZQjyX5p7XXak2H42StmCOrTzN8ZHqU9UWaG0eCe4hdpZIYsCrZ2wE2hxzHmyHVXfWqY8kktR75-KWuoKR4B4JCM2owH3eYf6os05YJLk7nGvZNOMXZ0ynEOz7UkoM-Q4Lpac3FJvGCtmBjuynqiJdnYOHlRGlZA406MHLZpWDkfYD2o5uBpbzCHhbSnNbpjGgTk82h9E7ZFmqK9qSB5lY_nUd6V0xzd5gqAu0aTUawdJPCpAsocnSK7WN8s4iMLAD1dRGK1XHjxDEgXLtR_GYRBH8XV8tYoPHryMlv4yCsNVsI6vfWbcxH7kAc_Fkfk8_Yrxcxz-AoHyBKc) ### OIDC Flow (Authentication, e.g. Log in with Google) ![OIDC Flow (Authentication, e.g. Log in with Google)](https://mermaid.ink/svg/pako:eNptUt1P2zAQ_1dO9zC1WqgaspDGD2hViyamTZsGbNKUFys5UquJnTkOA6r-7zs7LQOBX_xxv4873-2wNBWhwJ7-DKRLWitZW9kWGnh10jpVqk5qBzc92devy64D2futUaV0yujXmE_G1A152OG0HNwGrsjekYXJt8v1Cr5bc6cqstPiwPduJ-fnrCtgxdJbKPCLqUFp-KuYPSoVOKIZxuDxTcAPqpSl0oEzENS9n7HqMeQHF7rqjNLuhdGRyx69N3kHsus4KepH2Bg_Oab0ZBGSeam_4g99K62L-3IjdU0BALfGwrXZkn7b4HI9RmHy-df1FN7Dsiyp78fH5-oB_VM2qpKOntEGrgv4S7VT7mH6n8EUX3KotKbK1-r7x19MMJF--1i3UjWz0rRTjLC2qkLh7EARtmQ5xFfcecEC3YZaboLgYyXt1rdjzxzu-m9j2iPNmqHeoLiVTc-3ofOZHsbsCUKau78yg3Yo8qCAYof3KE4XZ7M8ydJknsZZmi3iCB9QZMlsMU_zNMmz_EN-Guf7CB-D5XyWpWmcLPKzeRYHSoTcLGfs13HQw7zv_wECNvUO) ## Takeaways - OAuth was **never meant for authentication**. Its purpose is authorization. - OIDC builds on OAuth to provide **authentication** via ID tokens. - If your app needs to access APIs on behalf of a user → **use OAuth**. - If your app needs to log in users securely → **use OIDC**. - If you try to use OAuth alone for authentication, you're in risky territory. ## Closing Thought Next time someone asks, _“Is OAuth an authentication protocol?”_, you'll know the answer: **No — that's OpenID Connect.** OAuth lets apps act on your behalf. OIDC lets apps know who you are. Together, they power the login buttons and integrations we all use daily. --- # Building User Interface for Querying ChromaDB URL: https://ajaydandge.dev/blog/building-ui-for-querying-chromadb Published: 2025-05-25 We were trying to build a RAG-based chatbot for an internal use case using **LangChain**, **LangGraph**, **Ollama**, and **ChromaDB**. The core idea was straightforward: users upload documents, and then query them through natural language to retrieve useful context-rich responses. Everything seemed like it should work smoothly in theory. But as we started putting it into practice, something felt off. The answers we were getting back from the chatbot weren't really useful — often vague, sometimes irrelevant. My first instinct was that the prompt might not be well-crafted. So, like any of us would, I spent some time refining it, experimenting with different phrasings and prompt structures. Still, the output wasn't improving. At this point, I suspected that maybe the problem wasn't the language model at all — maybe it was the retrieval layer. So I went one level deeper and began inspecting the queries being made to **ChromaDB**. I started issuing retrieval queries directly to Chroma to better understand what documents were actually being returned for a given input. And sure enough, the quality of retrieved documents wasn't great. I figured the issue might be related to the vector search configuration itself. After diving into ChromaDB's documentation, particularly the section on [HNSW configuration](https://cookbook.chromadb.dev/core/configuration/#hnsw-configuration), I started tweaking parameters like `ef`, `M`, and the distance metric. With some trial and error, I managed to get better results — but only when testing against specific hardcoded queries. The problem was now shifting: how could I iterate quickly and visually explore the effects of these changes? There just wasn't a good way to see what was happening under the hood in real-time. I wanted something that let me view collections, inspect the documents being stored, and run queries with different embedding models. Ideally, it would also show me things like the similarity distance, the raw embeddings, and the matched content. So I went looking for a tool that could help. I found a couple of open-source UIs on GitHub that seemed relevant — [chromadb-admin](https://github.com/flanker/chromadb-admin) and [chroma-ui](https://github.com/thakkaryash94/chroma-ui). Both were promising. One had a decent UI and visual features, but didn't support querying. The other didn't support connecting to a locally deployed ChromaDB instance, which was a must for my use case. That's when I decided to build my own. I didn't want to over-engineer it, so I reached for the fastest and simplest tool I could think of — **Gradio**. It's an intuitive Python library for building web interfaces, especially useful for demos, internal tools, or quick experimentation. With a bit of Python and help from ChatGPT whenever I got stuck, I started putting together a lightweight interface. The result is **[chromadb-viewer](https://github.com/iajaydandge/chromadb-viewer)** — a minimal yet powerful web UI for querying ChromaDB collections using embeddings generated by **Ollama** models. The viewer lets you configure your ChromaDB connection parameters dynamically, point to any locally running Ollama instance, and pick the embedding model of your choice. Once connected, you can browse collections, run semantic queries, and view results including the matched document, similarity score, and raw embedding vectors — all in real-time. One of the nice touches is that it automatically detects and shows the distance metric (like `l2`, `cosine`, or `ip`) being used in the collection's underlying HNSW index, so you always know how similarity is being calculated. For now, it's focused on local Chroma instances and Ollama embeddings, but I've got plans to expand it. I want to add support for cloud-deployed ChromaDB setups and allow users to switch between different embedding providers — not just Ollama. That way, it becomes more flexible for various real-world use cases. If you're experimenting with RAG pipelines, vector databases, or just want an easier way to poke around your Chroma collections, this might help you too. Feel free to check it out, give it a spin, and contribute if you'd like: [iajaydandge/chromadb-viewer](https://github.com/iajaydandge/chromadb-viewer) Thanks for reading — more updates to come! --- # What CORS Is, What CORS Is Not URL: https://ajaydandge.dev/blog/what-cors-is-what-cors-is-not Published: 2025-05-12 If you've ever built a frontend that talks to an API, you've probably seen that big red CORS error in your browser console. It's annoying, confusing, and often misunderstood. In this post, we'll bust a couple of common myths — especially the idea that CORS is a security feature, or that it explains why something works in Postman but not in the browser. Let's get clear on what CORS actually does — and what it doesn't. ## **What is CORS, really?** CORS stands for **Cross-Origin Resource Sharing**. It's not a library, it's not a server plugin, and it's definitely not an API security tool. It's a **browser feature**. Full stop. When your frontend JavaScript tries to make a request to a different origin — for example: * Frontend: `http://localhost:3000` * API: `https://api.example.com` — the browser will say: "Hang on, are you allowed to talk to that domain?" Unless the API responds with the right headers, the browser will block the request before it even leaves your machine. Here's a basic success response header from the server: ```http Access-Control-Allow-Origin: http://localhost:3000 ``` And that's it. No header, no request. But again — **this only happens in browsers**. ## **Myth: CORS applies everywhere** Nope. **CORS only exists in the browser**. It has nothing to do with the server-side, Postman, curl, or your backend code. Postman will happily make the same request that the browser blocks. It doesn't care about CORS headers. That's why your API might "work in Postman but not in the browser." The browser is enforcing rules on behalf of the user. That's all. ## **A quick word on preflight** When you send anything other than a simple `GET` or `POST` (like `PUT`, `DELETE`, custom headers, etc.), the browser doesn't go straight to your API. It first sends an automatic **OPTIONS** request to check if it's allowed — this is called a **preflight request**. If your server doesn't handle that preflight properly — by sending back the right headers — your main request never gets sent. So the real issue isn't always your API logic. It might just be missing a proper response to a question the browser asked first. ## **Myth: Adding CORS makes your API secure** This one's dangerous. Just because your API only allows requests from `https://some-frontend.com` doesn't mean it's protected. Anyone can still hit your API directly using curl, Postman, a bash script, or their own frontend. CORS only tells the browser which sites are allowed to call your API **via JavaScript**. It doesn't restrict access at the network level. It doesn't check tokens. It doesn't block unauthorized users. CORS is a browser behavior filter — **not** a gatekeeper for your data. So if your API has no authentication, it's public. Period. Doesn't matter what your CORS policy says. ## **Conclusion** CORS isn't magic. It doesn't secure your API. It's just a browser rule that says "you can only talk to this server if the server says it's okay." It protects users, not servers. It's enforced by the browser, not the backend. And if your app works in Postman but not in Chrome, chances are you're just missing a header — not a whole security model. So next time you see a CORS error, take a breath. It's not a bug. It's just the browser doing its job. --- # Multi-Account Git Setup with SSH and Commit Signing URL: https://ajaydandge.dev/blog/multi-account-git-config Published: 2025-05-05 Managing multiple Git accounts (like work, personal, freelance) across different providers (GitHub, GitLab, etc.) can be a hassle—unless you set things up right. This guide shows you how to keep your identities organized using SSH keys, per-directory Git configs, and optional commit signing. Let's make Git behave nicely—one folder at a time. ## 1. Generate a New SSH Key Time to make a shiny new SSH key. ```bash ssh-keygen -t ed25519 -f /home//.ssh/ -C "" ``` This creates: - `/home//.ssh/` → your **private key** - `/home//.ssh/.pub` → your **public key** The `-C` flag just adds a helpful label so you remember what this key is for. ## 2. Add the SSH Key to Your Git Hosting Provider You'll need to copy your public key and paste it into your Git hosting platform (like GitHub or GitLab). Here's how to copy it: - **Linux:** ```bash cat /home//.ssh/.pub | xclip -selection clipboard ``` - **macOS:** ```bash pbcopy < /Users//.ssh/.pub ``` - **Windows (Git Bash):** ```bash cat /c/Users//.ssh/.pub | clip ``` Then: 1. Go to your Git provider's settings. 2. Look for **SSH and GPG Keys** (or similar). 3. Add the public key as an **authentication key**. 4. If you're planning to sign commits, also add it as a **signing key**. ## 3. Create a Directory for Your Repositories Windows folks call them folders. Same thing. This directory will house the repos for a specific identity (e.g., work, personal). ```bash mkdir /home// cd /home// ``` **Windows PowerShell:** ```powershell mkdir C:/Users// cd C:/Users// ``` ## 4. Create a Git Config for That Directory Inside the directory (``), create a `.gitconfig` file: ### Without Commit Signing: ```ini [user] name = email = [core] sshCommand = ssh -i /home//.ssh/ -o IdentitiesOnly=yes ``` ### With Commit Signing (Requires Git 2.34+): ```ini [user] name = email = signingkey = /home//.ssh/.pub [gpg] format = ssh [commit] gpgsign = true [core] sshCommand = ssh -i /home//.ssh/ -o IdentitiesOnly=yes ``` This sets your Git identity, links the correct SSH key, and (if enabled) signs all your commits using the SSH key. ## 5. Link the Directory Config in Your Global Git Config Tell Git to use this config whenever you're working inside that specific directory. Edit your global Git config (usually at `/home//.gitconfig` or `C:/Users//.gitconfig`) and add: ### On Linux/macOS: ```ini [includeIf "gitdir:/home///"] path = /home///.gitconfig ``` ### On Windows: ```ini [includeIf "gitdir:C:/Users///"] path = C:/Users///.gitconfig ``` > **Tip:** Use **forward slashes** (`/`) in paths—even on Windows. ## Bonus: Add More Identities To add more Git identities: - Create another SSH key (e.g., `github-personal`, `gitlab-client`, etc.) - Make a new folder (e.g., `/home//personal-projects`) - Add a `.gitconfig` with the right name/email/key - Add the key to the appropriate Git hosting provider - Add another `[includeIf]` block in your global config Now each directory will automatically use the correct Git identity, SSH key, and commit signing setup. *P.S. I made a CLI tool in Go that sets all this up for you—check it out here: [iajaydandge/git-config](https://github.com/iajaydandge/git-config)*