Skip to content

feat: persist the live model catalog so restarts serve current models - #79

Merged
saucam merged 2 commits into
mainfrom
fix/persist-live-model-catalog
Jul 3, 2026
Merged

feat: persist the live model catalog so restarts serve current models#79
saucam merged 2 commits into
mainfrom
fix/persist-live-model-catalog

Conversation

@saucam

@saucam saucam commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Closes the model-catalog staleness class permanently (follow-up to #78), designed provider-agnostic from the start — codeoid is heading toward a meta-harness over multiple code harnesses (Claude Code, Gemini, OpenAI, …), so nothing here assumes a single backend.

Design

models.list resolves per provider, best source first:

  1. live — reported by that provider's backend this daemon lifetime (e.g. the Claude Code SDK's supportedModels())
  2. cached — the last live list for that provider, persisted to SQLite (provider_model_catalogs, one row per provider id, upsert on the first report of each boot) by a previous lifetime
  3. baked-in — the hardcoded MODEL_CATALOG (claude's tier 3 only; other providers serve empty until their backend reports, then persist like any other)

live: true is reported only for tier 1, so clients keep refetching until the backend has actually been asked this lifetime — but what they render in the meantime is the last real list (including e.g. Fable when the subscription serves it) instead of a hand-maintained snapshot that rots between releases.

Provider plumbing

  • Session.onModels tags reports with the provider's own provider.id — no hardcoded provider names in the wiring; new Session.providerId getter
  • SessionManager keeps live catalogs in a per-provider map; /model validation resolves against the session's own provider catalog
  • Wire-additive protocol: models.list takes optional provider; models.list.result reports provider. Older clients omit/ignore both and get the default (claude); the Rust protocol crate tolerates unknown fields (no deny_unknown_fields)
  • Deserialized rows are structurally validated (string value/displayName per entry) so shape drift degrades to the next tier instead of reaching pickers — plain type-guard filter, keeping the store dependency-free on the hot path
  • Persistence is best-effort: a failed write logs and falls back one tier next boot

Tests

  • Store: per-provider round-trip/isolation, upsert-latest-wins, reopen-across-lifetimes, null on first boot / unreadable or malformed JSON
  • End-to-end tiering through SessionManager.handle(models.list): baked-in fallback on first-ever boot → live after a report (and persisted under that provider) → persisted-but-live:false on the next lifetime → cross-provider no-leak → first report wins per provider, empty reports ignored

Full suite: 732 pass / 0 fail, tsc (root + web) + biome clean.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Model catalogs are now provider-aware and persist across daemon restarts, so models.list can return previously seen catalogs without waiting for new reports.
    • models.list can be requested for a specific provider, and results now include the provider plus whether the catalog is live, persisted, or built-in fallback.
  • Bug Fixes

    • Model validation during session.set_model now uses the session’s provider catalog, improving correctness on resume/import.
    • Empty model reports no longer override an already-active catalog.

The models.list fallback used to be only the baked-in MODEL_CATALOG,
which goes stale between codeoid releases — and it was ALWAYS what
clients saw right after a daemon restart, because resumed sessions spawn
their provider lazily and the live SDK supportedModels() list only
arrives once a turn runs.

The catalog now resolves in three tiers:

  1. live     — reported by a session's SDK query this daemon lifetime
  2. cached   — the last live list, persisted to SQLite by a previous
                lifetime (single-row upsert on first report per boot)
  3. baked-in — first-ever boot only

live=true is reported only for tier 1, so clients keep refetching until
the backend has actually been asked this lifetime, but what they render
in the meantime is the last real list (including e.g. Fable when the
subscription serves it) instead of a hand-maintained snapshot.

_cacheModels is TS-private (not #) so tests can exercise the persistence
path without a live SDK query, following the Session._applyInterrupted-
StateToTool convention.

Tests: store round-trip/upsert/reopen, and end-to-end tiering through
SessionManager.handle(models.list) — fallback on first-ever boot, live
after a report, persisted-but-not-live on the next lifetime, first
report wins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

SessionManager now resolves model catalogs per provider, persists the first live catalog in Store, and returns provider-tagged models.list results. Protocol types and session plumbing were updated to carry provider ids through model reporting and validation, with tests covering persistence and resolution.

Changes

Provider-aware model catalog

Layer / File(s) Summary
Protocol and session plumbing
src/protocol/types.ts, web/src/protocol/types.ts, src/daemon/session.ts
Adds provider to model-list messages, exposes Session.providerId, and routes model-report callbacks with the provider id.
Provider catalog persistence and resolution
src/daemon/store.ts, src/daemon/session-manager.ts
Adds provider-keyed catalog storage, per-provider cache persistence, provider-specific models.list selection, and session model validation against the session provider.
Persistence and models.list tests
src/tests/models.test.ts
Adds Store persistence coverage and models.list tests for fallback, live, persisted, and per-provider behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Session
  participant SessionManager
  participant Store
  participant Client
  Session->>SessionManager: onModels(providerId, models)
  SessionManager->>Store: saveModelCatalog(providerId, models)
  Client->>SessionManager: models.list request
  SessionManager->>Store: getModelCatalog(providerId)
  Store-->>SessionManager: persisted catalog or null
  SessionManager-->>Client: models.list result { provider, live, models }
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: persisting the live model catalog so restarts can reuse current models.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/persist-live-model-catalog

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.72222% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.86%. Comparing base (0c714fe) to head (3464e39).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/daemon/session-manager.ts 77.27% 10 Missing ⚠️
src/daemon/session.ts 66.66% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             main      #79       +/-   ##
===========================================
- Coverage   80.79%   66.86%   -13.93%     
===========================================
  Files          59       65        +6     
  Lines        8920    11165     +2245     
===========================================
+ Hits         7207     7466      +259     
- Misses       1713     3699     +1986     
Flag Coverage Δ
daemon 66.86% <84.72%> (-13.93%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/daemon/store.ts 74.63% <100.00%> (+3.52%) ⬆️
src/protocol/types.ts 92.85% <ø> (ø)
src/daemon/session.ts 73.75% <66.66%> (-0.10%) ⬇️
src/daemon/session-manager.ts 12.73% <77.27%> (ø)

... and 5 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/daemon/store.ts (1)

311-322: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Consider validating the deserialized catalog with Zod.

getModelCatalog JSON.parses persisted data and casts straight to ModelInfo[] with only an Array.isArray/length check. Data read from SQLite can outlive schema/shape changes across releases, and a malformed row would surface downstream as a structurally-invalid ModelInfo. A Zod safeParse here would reject stale/corrupt rows the same way it already returns null.

As per coding guidelines: "Use Zod for validation of runtime data and configuration".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/daemon/store.ts` around lines 311 - 322, getModelCatalog currently trusts
JSON.parse output too much; validate the persisted catalog with Zod before
returning it. In getModelCatalog, after parsing row.models_json, run a Zod
safeParse against the expected ModelInfo[] shape and return null on any
validation failure, keeping the existing null-on-bad-data behavior. Use the
existing ModelInfo type and the getModelCatalog method as the entry point for
the fix.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/daemon/store.ts`:
- Around line 311-322: getModelCatalog currently trusts JSON.parse output too
much; validate the persisted catalog with Zod before returning it. In
getModelCatalog, after parsing row.models_json, run a Zod safeParse against the
expected ModelInfo[] shape and return null on any validation failure, keeping
the existing null-on-bad-data behavior. Use the existing ModelInfo type and the
getModelCatalog method as the entry point for the fix.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 867711e3-1ffe-416d-a027-da04279c5c68

📥 Commits

Reviewing files that changed from the base of the PR and between 0c714fe and 72cf4fc.

📒 Files selected for processing (3)
  • src/daemon/session-manager.ts
  • src/daemon/store.ts
  • src/tests/models.test.ts

codeoid is heading toward being a meta-harness over multiple code
harnesses (Claude Code, Gemini, OpenAI, ...), so a single daemon-wide
model list bakes in a one-provider assumption. Rework the catalog
end-to-end to be provider-scoped:

- store: provider_model_catalogs(provider_id PRIMARY KEY, ...) — one
  persisted row per provider, upsert-latest-wins; the pre-release
  single-row table is dropped (never shipped in a tag). Deserialized
  rows are structurally validated so malformed entries degrade to the
  next tier instead of reaching pickers.
- Session.onModels now carries the reporting provider's id, sourced
  from the provider instance itself (provider.id) — no hardcoded
  provider names in the wiring; new Session.providerId getter.
- SessionManager caches live catalogs in a per-provider map, persists
  per provider, and resolves live → persisted → baked-in per provider.
  The baked-in MODEL_CATALOG is claude's tier 3 only; other providers
  serve empty until their backend reports. /model validation now
  resolves against the session's own provider catalog.
- protocol (wire-additive): models.list takes optional provider;
  models.list.result reports provider. Older clients omit/ignore both
  and get the default provider (claude) — verified the Rust protocol
  crate tolerates unknown fields.

Tests: per-provider store isolation + upsert + reopen; tiering through
SessionManager.handle for default and non-default providers; cross-
provider no-leak; first-report-wins per provider.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@saucam

saucam commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 3464e39 making the whole feature provider-agnostic (codeoid is heading toward a meta-harness over multiple code harnesses):

  • provider_model_catalogs table keyed by provider_id (one persisted row per provider, upsert-latest-wins); the earlier single-row table from this branch is dropped in-migration (never shipped in a tag)
  • Session.onModels now tags reports with the provider's own provider.id — no hardcoded provider names in the wiring; live cache, persistence, tiering, and /model validation are all per-provider (validation uses the session's own provider catalog)
  • Wire-additive protocol: models.list takes optional provider, the result reports provider; older clients omit/ignore both and get the default (claude) — the Rust protocol crate tolerates unknown fields (no deny_unknown_fields)
  • Baked-in MODEL_CATALOG is claude's tier 3 only; other providers serve empty until their backend reports, then persist like any other

Also addressed the review nitpick: getModelCatalog now structurally validates deserialized rows (string value/displayName per entry) so a shape drift degrades to the next tier instead of reaching pickers — done with a plain type-guard filter rather than Zod to keep the daemon's store dependency-free on the hot path.

New tests: per-provider store isolation/upsert/reopen, tiering for default + non-default providers, cross-provider no-leak, first-report-wins per provider. 732 pass / 0 fail, tsc + biome clean.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
web/src/protocol/types.ts (1)

380-387: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add provider to web/src/protocol/types.ts's ModelsListMsg. The result message already carries provider, but the request type here still omits the optional provider?: string, so the web client can’t type-safely request a non-default catalog.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/protocol/types.ts` around lines 380 - 387, The ModelsListMsg request
type is missing the optional provider field, so update the protocol typings in
ModelsListMsg to include provider?: string just like ModelsListResultMsg already
carries provider. Make the change in the protocol type definitions so the web
client can request a specific catalog in a type-safe way without affecting
existing default requests.
🧹 Nitpick comments (1)
src/daemon/store.ts (1)

310-337: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use a Zod schema instead of manual structural validation.

getModelCatalog hand-rolls structural validation (typeof m === "object", typeof value === "string", etc.) for untrusted JSON read back from SQLite. This is exactly the kind of runtime-data validation the project's guidelines call for Zod on, and a schema would also validate description/isDefault shapes that are currently unchecked.

As per coding guidelines, "Use Zod for validation of runtime data and configuration" (src/**/*.ts).

♻️ Proposed Zod-based validation
+const ModelInfoSchema = z.object({
+  value: z.string(),
+  displayName: z.string(),
+  description: z.string().optional(),
+  isDefault: z.boolean().optional(),
+});
+
 getModelCatalog(providerId: string): ModelInfo[] | null {
     const row = this.#db
       .prepare("SELECT models_json FROM provider_model_catalogs WHERE provider_id = ?")
       .get(providerId) as { models_json: string } | null;
     if (!row) return null;
     try {
       const parsed: unknown = JSON.parse(row.models_json);
       if (!Array.isArray(parsed)) return null;
-      // Structural validation — a row written by a future/older version with
-      // a different shape degrades to the next fallback tier instead of
-      // serving malformed entries to pickers.
-      const valid = parsed.filter(
-        (m): m is ModelInfo =>
-          !!m &&
-          typeof m === "object" &&
-          typeof (m as ModelInfo).value === "string" &&
-          typeof (m as ModelInfo).displayName === "string",
-      );
+      // Structural validation — a row written by a future/older version with
+      // a different shape degrades to the next fallback tier instead of
+      // serving malformed entries to pickers.
+      const valid = parsed
+        .map((m) => ModelInfoSchema.safeParse(m))
+        .filter((r): r is { success: true; data: ModelInfo } => r.success)
+        .map((r) => r.data);
       return valid.length > 0 ? valid : null;
     } catch {
       return null;
     }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/daemon/store.ts` around lines 310 - 337, The getModelCatalog method is
manually validating untrusted JSON from SQLite, which should be replaced with
Zod-based runtime validation. Add or reuse a Zod schema for ModelInfo in
src/daemon/store.ts, parse the JSON with it after JSON.parse, and return null on
schema failure instead of hand-rolled typeof checks. Make sure the schema covers
the full ModelInfo shape, including fields like description and isDefault, so
malformed or partial records are rejected consistently.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@web/src/protocol/types.ts`:
- Around line 380-387: The ModelsListMsg request type is missing the optional
provider field, so update the protocol typings in ModelsListMsg to include
provider?: string just like ModelsListResultMsg already carries provider. Make
the change in the protocol type definitions so the web client can request a
specific catalog in a type-safe way without affecting existing default requests.

---

Nitpick comments:
In `@src/daemon/store.ts`:
- Around line 310-337: The getModelCatalog method is manually validating
untrusted JSON from SQLite, which should be replaced with Zod-based runtime
validation. Add or reuse a Zod schema for ModelInfo in src/daemon/store.ts,
parse the JSON with it after JSON.parse, and return null on schema failure
instead of hand-rolled typeof checks. Make sure the schema covers the full
ModelInfo shape, including fields like description and isDefault, so malformed
or partial records are rejected consistently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9752d96a-0a8d-4790-b5be-4a12e8e672f3

📥 Commits

Reviewing files that changed from the base of the PR and between 72cf4fc and 3464e39.

📒 Files selected for processing (6)
  • src/daemon/session-manager.ts
  • src/daemon/session.ts
  • src/daemon/store.ts
  • src/protocol/types.ts
  • src/tests/models.test.ts
  • web/src/protocol/types.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/tests/models.test.ts
  • src/daemon/session-manager.ts

@saucam

saucam commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review nitpick (Zod vs type-guard): sticking with the plain type-guard per the rationale above — the store stays dependency-free on the hot path, the shape is two string fields, and a Zod schema here would be the only Zod usage in the daemon's persistence layer. Happy to revisit if the catalog shape grows.

@saucam
saucam merged commit ae00891 into main Jul 3, 2026
5 checks passed
@saucam
saucam deleted the fix/persist-live-model-catalog branch July 3, 2026 00:53
saucam added a commit that referenced this pull request Jul 6, 2026
…log (#117)

The 0.2.0 entry only covered the protocol/packages train (#100-#116) and
missed ten PRs that also ship in this release: the untrusted-content
sanitization and cross-tenant memory fixes (#91, #93 — now under a
proper Security heading), the performance run (#94-#99), and the model
catalog work (#78, #79).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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