Repository navigation
feat(catalog): persist, refresh, and report the display model catalog - #401
Merged
caigee-cmd merged 2 commits intoOct 26, 2026
Merged
Conversation
The display catalog was in-memory only: a restart served an empty model list until the first upstream request succeeded, nothing reloaded it on a schedule, and a failed refresh was invisible. - Persist the last successful merged catalog in app_secrets and restore it at boot, so /v1/models answers from disk before any upstream call. - Reload it in the background every 10 minutes (env CLI2API_CATALOG_REFRESH_INTERVAL, Go duration or minutes, 0 disables) from app.New, reusing the existing shutdown channel. - Serve the last known list when a forced refresh fails instead of returning an empty catalog. - Record per-provider last attempt, last success, model count, and error in control.CatalogHealth so a half-fresh catalog is visible. - Report sync state: catalog.synced_at / catalog.stale beside data on /v1/models (freshness only, no upstream error text for scoped keys) and GET/POST /api/model-catalog on the console for the full per-provider state plus a manual refresh. Each provider still discovers its own upstream list; nothing about model identity, routing, or the pool catalog changes. Lazy TTLs and the console ?refresh=1 path keep working.
caigee-cmd
approved these changes
Oct 26, 2026
caigee-cmd
left a comment
Owner
There was a problem hiding this comment.
This is the right layer for the display catalog: persist the last successful merged view, restore it on boot, keep serving it after a failed refresh, and schedule a background reload. Pool catalogs, model identity, and routing are left alone. Public /v1/models only exposing synced_at / stale (errors stay on the console endpoint) is the correct API-key boundary.
Tests cover persist/restore, last-known-after-failure, the refresher tick, and interval=0. The live-acceptance notes are useful.
Nits, not blockers:
catalogRefreshIntervalcomments say invalid values log a line; the code silently falls back to the default.- Storing the snapshot in
app_secretsis a reasonable way to avoid a migration. It is not a secret; a later numbered migration can move it if the payload ever grows. - The per-account fan-out on each tick is documented. Fine at the default 10 minutes for a small pool.
Please land this before #404 if both stay open — both touch internal/app/app.go.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The
/v1/modelsdisplay catalog was in-memory only. That produced three user-visible gaps:This change makes the display catalog durable and observable. Each provider still discovers its own upstream list exactly as before; nothing about model identity, routing, the pool catalog, or the per-account catalogs changes.
app_secretsundercatalog_snapshot_v1and loaded at boot, so/v1/modelsanswers from disk before any upstream call.control.Catalog.RunRefresherreloads the merged view on an interval, started fromapp.Newon the existing shutdown channel. Default 10 minutes;CLI2API_CATALOG_REFRESH_INTERVALaccepts a Go duration (15m) or plain minutes, and0disables the loop. Invalid values fall back to the default.?refresh=1path still blocks for a live fetch.control.CatalogHealthrecords last attempt, last success, model count, last error, and a stale flag for every provider request (CatalogSourcereports them; no behavior change).GET /v1/modelsgainscatalog.synced_atandcatalog.stalebesidedata— freshness only, so a provider-scoped API key cannot read another provider's upstream error. The console getsGET /api/model-catalog(full per-provider state) andPOST /api/model-catalog(force a refresh).Positioning against existing work: #142 already keeps pool models through a catalog outage, and #104/#129 own the 5-minute cache and stale-while-revalidate. This change is about the display catalog: durability across restart, scheduled freshness, and sync observability. It does not add a second cache — it reuses
control.Catalogand its TTL.Test plan
go build ./...go vet ./...go test ./internal/control ./internal/gateway ./internal/app ./internal/console ./internal/config -count=1internal/control/catalog_refresh_test.go: persistence round-trip, restore-before-fetch, last-known-after-failure, status reporting, refresher tick, refresher disabled atintervalMs=0, per-provider health transitions.python scripts/release-notes.py validate(changelog fragmentcatalog-auto-refresh.md, 4 EN / 4 ZH bullets)internal/webui/staticuntouched (git diff --statshows no frontend files).go test ./...— not green on a Windows host onmaineither; the 19 failing tests are pre-existing POSIX/clock/file-lock assumptions listed below, and CI runs them on Ubuntu.go test -race ./...— no cgo toolchain on this Windows host; CI covers it.Windows-host test status (pre-existing, unchanged by this PR)
go test ./internal/...fails identically onmainand on this branch:internal/api(10:t.TempDircannot unlink an open SQLite handle, plus a POSIX backup-path assertion),internal/executor(2: Windows clock granularity breaks a strict cooldownAfter),internal/providers/qoder+internal/runtime(3: POSIX path assertions),internal/store(4: file mode 0666 vs 0600 and cleanup locks).Live acceptance (real accounts, locally built binary)
Ran the branch build against a copy of a populated data directory with one WorkBuddy CN, one WorkBuddy Global, one QwenWork, and one Codex account, plus two Qoder accounts whose workers cannot start on this host:
synced_atset within 2 s of boot;codex10 models,workbuddy19,qwenwork3qoderrow:stale=true,last_error= the worker connection refusal; other providers unaffectedcatalog_snapshot_v1present inqoder.dbwith the full model list and health rowsCLI2API_CATALOG_REFRESH_INTERVAL=0/v1/modelsand/api/model-catalogserved the pre-restartsynced_atand models before any fetch — the snapshot came from diskPOST /api/model-catalogadvancedsynced_at, refreshed every per-provider row, kept the qoder failure/v1/modelsmetadata{"object":"list","data":[…],"catalog":{"synced_at":"…","stale":false}}reasoning_options,max_output_tokens,supports_imagesKnown limitations
CLI2API_CATALOG_REFRESH_INTERVALis process-scoped, likeCLI2API_LOG_*. Making it console-editable would need aSystemSettingsfield and a resettable ticker; not done here.app_secretswith other settings and is included in SQLite backups. Payload size is bounded in practice by the catalog itself (tens of KB).GET /api/model-catalogis a new leaf under/api/; it does not shadow/api/models/{provider}/{model}.