Dean.Taxi Blog
/find

This is the web log (blog) for the DEAN.TAXI network of original properties.

Most of the crawls are empty. That's the useful part.

19,245 crawls of OpenRouter. Three quarters of them change nothing that matters. The change feed is hiding in the other quarter, if you skip the clocks.

I have 19,245 crawls of OpenRouter in a pile. About three quarters of them contain no selected core change. The interesting number is not 19,245. It's the 22,947 field-level events hiding in the other quarter.

This is the bit of ORCA that looks like overengineering until you live with a catalog that twitches every hour.

Three steps, on purpose

The pipeline is deliberately boring:

  1. Crawl — a Convex cron, hourly, fetches the catalog, gzips the raw payload, stamps a crawl_id. Preview deploys do not run this. I learned that the expensive way.
  2. Materialize — project the blob into models / endpoints / providers. The UI never reads the archive. The archive is evidence; the views are disposable.
  3. Diff — consecutive snapshots through json-diff-ts, atomized into a change history. Monitor and Discord both drink from that.

Note

The snapshot code is marked legacy in its own AGENTS.md. That's not a confession, it's a plan. The raw bundles stay. The projections get to be rebuilt.

Retries on the fetch are quadratic, because linear retries just hammer a flapping origin:

delay=attempt2×1000ms

Three attempts. Then we record the failure against that model and exclude it from both sides of the next diff. A failed fetch is not a mass deletion. I will die on this hill.

The skip list is the product

If you diff everything, you get a change feed of clocks. This is the actual design work:

Field Why we skip it
updated_at, unavailable_at A clock is not news.
stats, status Latency jitter every hour. Nobody wants that pinged to Discord.
icon_url CDN churn. The whale does not need a new favicon event.
denormalised names on the endpoint The model didn't change. We copied the label again.

Arrays are the other trap. Naive index comparison on supported_parameters produces enormous groups of fake updates the moment someone shuffles the list. The fix is to tell the differ that membership is the identity:

const DIFF_OPTIONS = {
  keysToSkip: [
    "updated_at",
    "unavailable_at",
    "icon_url",
    "stats",
    "status",
  ],
  embeddedObjKeys: {
    input_modalities: "$value",
    output_modalities: "$value",
    supported_parameters: "$value",
    datacenters: "$value",
  },
  treatTypeChangeAsReplace: false,
}

That's it. That's the whole trick. A reordering is not a change. An add or a remove is.

A high-frequency price period is often three to twelve field updates per crawl — mostly endpoint prices — not a new universe. Adjacent crawls are frequently identical. Sparsity is the point of storing diffs at all.1

What a change looks like

Monitor doesn't show a blob. It shows this:

{
  "path": "pricing.text_input",
  "from": "0.90",
  "to": "0.42"
}

Green down, red up, a percentage if we can make one without lying. Discord gets the same event squeezed into an embed, which is why the keys are cache_read and not text_cache_read. I have opinions about pixels.

Pricing history for GLM 5.2 — thirty providers, none of them agreeing
Pricing history for GLM 5.2 — thirty providers, none of them agreeing

GLM 5.2, thirty days, too many providers. The chart is behind a flag. The skip list is not.

The raw bundles remain the source of truth. A changeset is a convenience on top of evidence we already kept. If the differ is wrong, we still have the gzip. That's the contract: capture what we use as raw artifacts, then build projections we are allowed to throw away.


  1. Reversible changesets — `diff` / `applyChangeset` / `revertChangeset` — are sitting in a notes folder, not in production. The archive does not depend on them. I am allowed to faff. I am not allowed to make the evidence unrecoverable.