Skip to content

fix(core): skip models.dev refresh event when the catalog is unchanged - #44282

Merged
thdxr merged 1 commit into
v2from
models-dev-refresh
Aug 23, 2026
Merged

fix(core): skip models.dev refresh event when the catalog is unchanged#44282
thdxr merged 1 commit into
v2from
models-dev-refresh

Conversation

@thdxr

@thdxr thdxr commented Aug 23, 2026

Copy link
Copy Markdown
Member

Problem

The models.dev service repeats refresh() every 5 minutes (Schedule.spaced(ttl)), and because the repeat interval equals the freshness TTL, every cycle fetched a new body, rewrote the multi-MB KV entry, invalidated the memoized catalog, and published ModelsDev.Event.Refreshedeven when api.json was byte-identical.

That triggered the full downstream chain on every cycle with no content change:

  1. Refreshed → the opencode.models.dev plugin re-runs its integration/catalog transforms (ctx.catalog.reload())
  2. Catalog.finalize publishes catalog.updated unconditionally (no diffing)
  3. Every listener churns: the TUI subscribes to catalog.updated, and the event logger writes it

Fix

Persist a sha256 digest of the raw response body alongside the KV cache entry. refresh() still fetches each cycle (same cadence as before), but when the new body is byte-identical to the cached one it now skips:

  • the cache rewrite (saves a multi-MB KV write per cycle — notable given Durable Object SQLite's 2 MB value cap noted in the existing comment)
  • the catalog invalidation
  • the Refreshed publish

refresh(true) (explicit force) bypasses both the freshness check and the digest skip, preserving its "reload now" semantics for callers like tests/CLI.

Legacy cache entries written before this change have no digest; they decode fine via the optional schema field and publish once before self-correcting.

Notes

  • The server does serve an ETag (models.opencode.ai/api.json, Cloudflare), so conditional If-None-Match requests could additionally save bandwidth. That is left out of scope here: 304 handling would need special-casing around HttpClient.filterStatusOk + retryTransient (304 is not an ok status and would be retried). The digest comparison achieves the behavioral goal with no protocol complexity.
  • Only subscriber of Refreshed is the models-dev plugin, so no other call sites are affected.

Testing

  • Extended refresh(false) fetches when stale to assert one Refreshed event is published on a real content change
  • New: unchanged byte-identical body → HTTP still hit once, but no Refreshed event and cache entry untouched
  • New: legacy entry without digest → republishes exactly once and persists the digest
  • All 15 tests in packages/core/test/models.test.ts pass; bun typecheck clean in packages/core

The 5-minute refresh loop republished ModelsDev.Event.Refreshed on every
cycle even when api.json was byte-identical, causing the models.dev
plugin to reload its transforms and Catalog.finalize to emit
catalog.updated unconditionally — spamming downstream listeners (TUI,
event logger) and rewriting a multi-MB KV entry each time.

Persist a sha256 digest of the raw body alongside the cached entry;
refresh now fetches but skips the cache write, invalidation, and
Refreshed publish when the body matches. Legacy entries without a
digest publish once and then self-correct.
@thdxr
thdxr merged commit 358a53c into v2 Aug 23, 2026
9 checks passed
@thdxr
thdxr deleted the models-dev-refresh branch August 23, 2026 01:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant