Files
multica/server/internal/handler/runtime_model_catalog.go
Multica Eve c25a82eee0 perf(agents): fast model discovery on runtime switch (MUL-5444) (#6098)
* perf(agents): make runtime model discovery fast on runtime switch (MUL-5444)

Switching runtime in the agent creation form left the model picker
spinning for ~8-20s. Two costs stacked up:

- the list-models request sat in the store until the daemon's next
  scheduled heartbeat (0-15s, avg 7.5s of pure dead wait), and
- the daemon then enumerated the catalog locally (static for claude,
  but a CLI/ACP round trip up to ~15s for everyone else).

Both are addressed with the two standard techniques for a slow,
low-frequency, read-only operation: push instead of poll, and
stale-while-revalidate.

Push (removes the heartbeat wait):
- new additive `daemon:pending_work` hint, runtime-scoped, delivered
  through the existing daemon WS hub and the Redis relay so the API node
  holding the socket does the delivery.
- the daemon answers a hint with ONE immediate heartbeat and dispatches
  what it claimed. The hint deliberately carries no work, so nothing has
  to be un-claimed when delivery fails and a duplicate hint cannot
  duplicate work - PopPending stays the atomic claim.
- per-runtime coalescing plus a 1s floor keeps a caller-triggered hint
  from becoming a heartbeat amplifier.

Cache (removes the discovery wait on repeat opens):
- server-side per-runtime catalog cache (in-memory single-node, Redis
  multi-node) written on every successful report.
- a snapshot younger than 15min answers the POST immediately as an
  already-completed request; older than 60s it also enqueues a
  background refresh that only warms the cache.
- only supported, non-empty catalogs are cached; a completed-but-empty
  report invalidates instead, while a failed report keeps serving the
  last known good list.

Frontend: staleTime 60s -> 5min and gcTime 30min, so a runtime revisited
in the same session renders from cache and revalidates in the background
instead of showing the spinner again.

Compatibility: every wire change is additive. Old daemons ignore the
unknown hint type and keep using the scheduled heartbeat; new daemons
against an old server simply never receive one. The cached response is
shaped exactly like a completed live discovery apart from the optional
`cached` / `cached_at` markers.

Co-authored-by: multica-agent <github@multica.ai>

* fix(agents): address review on model discovery SWR (MUL-5444)

Sol-Boy's review on #6098 found the client cache could outlive the
server's own staleness promise, and that the two changed endpoints were
still cast rather than validated.

Must-fix 1 — client freshness now derives from the served answer.
`staleTime` was a flat 5min, so a 14-minute-old snapshot (which the
server returns while queueing its own refresh) was held as fresh for
another 5min: observable staleness became server window + client window,
and the refreshed catalog never reached the tab that triggered the
refresh. `staleTime` is now a function of the query data: a `cached`
answer is stale on arrival (bound stays the server's window alone, and
the next mount/focus picks up the refreshed snapshot), while a live
discovery — which just measured the truth — is trusted for the full 5min
so a cold runtime is never re-enumerated inside one form session.
`gcTime` stays 30min, so a revisited runtime still renders from cache and
revalidates in the background; the pickers gate their spinner on
`isLoading`, which stays false throughout.

Must-fix 2 — both model-discovery responses go through a zod schema.
`POST /api/runtimes/{id}/models` and its poll companion were casting
network JSON to `RuntimeModelListRequest`, which the root CLAUDE.md
API-compatibility rules forbid. Added a lenient schema (`status` stays
`z.string()`, `supported` defaults to true, `.loose()` keeps unknown
fields) plus a fallback record whose `status` is `failed`: a malformed
body now surfaces "discovery failed" with manual entry still usable
instead of a fabricated empty catalog or an endless spinner.
`resolveRuntimeModels` was tightened to match — only an explicit
`completed` is a catalog, so an unrecognised status is an error rather
than a silent empty list, and `supported` can no longer be `undefined`.

Nit — the in-memory catalog cache now deep-copies each entry's
`Thinking` (and its level slice) and `ServiceTiers`, so it delivers the
independent value its comment promises and matches the Redis backend's
JSON round-trip semantics.

Tests: staleTime policy for cached/live/no-data; a QueryObserver test
proving the refreshed catalog reaches the same client with no blank
loading state; unknown-status and omitted-`supported` handling; schema
tests for live, cached, old-backend and nine malformed shapes; client
tests that both endpoints degrade to an explicit failure; nested-field
mutation isolation for the cache.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 16:03:26 +08:00

182 lines
6.3 KiB
Go

package handler
import (
"context"
"sync"
"time"
)
// ---------------------------------------------------------------------------
// Runtime model catalog cache (stale-while-revalidate)
// ---------------------------------------------------------------------------
//
// Listing a runtime's models is a round trip to the user's machine: the request
// waits for the daemon's next heartbeat, the daemon shells out to the provider
// CLI (or drives an ACP handshake) and only then reports back. Even with the
// pending-work push hint (MUL-5444) that is seconds of latency on a UI surface
// people open repeatedly while filling in one form — switch runtime, look at the
// models, switch back.
//
// The catalog itself changes only when the user upgrades a CLI, logs into a
// different account, or edits a provider config, so it is a textbook
// stale-while-revalidate candidate: answer from the last known good snapshot
// immediately, and refresh in the background so the NEXT open is also warm.
//
// Windows are deliberately conservative:
// - modelCatalogServeWindow bounds how stale an answer the API will hand a
// client without waiting for the daemon.
// - modelCatalogRevalidateAfter bounds how long a snapshot can be served
// without triggering a background refresh, so a CLI upgrade converges
// within one open instead of lingering for the whole serve window.
//
// Only successful, non-empty, `supported` catalogs are cached. An empty list is
// almost always a transient discovery failure (CLI not logged in, timeout) —
// caching it would pin the picker empty (same reasoning as agent.cachedDiscovery
// in the daemon).
const (
// modelCatalogServeWindow is how long a cached catalog may answer a
// list-models request without waiting for the daemon.
modelCatalogServeWindow = 15 * time.Minute
// modelCatalogRevalidateAfter is the age past which serving from cache also
// enqueues a background refresh.
modelCatalogRevalidateAfter = 60 * time.Second
)
// ModelCatalogSnapshot is the last known good model list for one runtime.
type ModelCatalogSnapshot struct {
RuntimeID string `json:"runtime_id"`
Models []ModelEntry `json:"models"`
Supported bool `json:"supported"`
StoredAt time.Time `json:"stored_at"`
}
// Age reports how long ago the snapshot was captured.
func (s *ModelCatalogSnapshot) Age(now time.Time) time.Duration {
if s == nil {
return 0
}
return now.Sub(s.StoredAt)
}
// ModelCatalogCache stores the last successful model catalog per runtime. Both
// methods are best-effort from the caller's perspective: a Get error means
// "answer the slow way" and a Put error means "the next open is cold". Neither
// may fail a request.
//
// Implementations must be safe for concurrent use.
type ModelCatalogCache interface {
Get(ctx context.Context, runtimeID string) (*ModelCatalogSnapshot, error)
Put(ctx context.Context, runtimeID string, models []ModelEntry, supported bool) error
// Invalidate drops any snapshot for the runtime. Used when the cached
// catalog can no longer be trusted (e.g. the runtime row was deleted).
Invalidate(ctx context.Context, runtimeID string) error
}
// cacheableModelCatalog reports whether a completed discovery result is worth
// remembering. `supported=false` runtimes have no picker at all, and an empty
// catalog is treated as a transient failure rather than an authoritative
// "this runtime has no models".
func cacheableModelCatalog(models []ModelEntry, supported bool) bool {
return supported && len(models) > 0
}
// cloneModelEntries deep-copies a catalog so the in-memory backend hands out
// values a caller cannot mutate into the shared cache. A shallow slice copy is
// not enough: ModelEntry carries a *ModelThinking (with its own level slice) and
// a ServiceTiers slice, all of which would still alias the cached objects. The
// Redis backend gets this for free by round-tripping through JSON, and the two
// implementations must not differ in whether the returned value is independent.
func cloneModelEntries(models []ModelEntry) []ModelEntry {
if models == nil {
return nil
}
out := make([]ModelEntry, len(models))
for i, m := range models {
clone := m
if m.Thinking != nil {
thinking := *m.Thinking
if m.Thinking.SupportedLevels != nil {
thinking.SupportedLevels = append([]ThinkingLevel(nil), m.Thinking.SupportedLevels...)
}
clone.Thinking = &thinking
}
if m.ServiceTiers != nil {
clone.ServiceTiers = append([]ModelServiceTier(nil), m.ServiceTiers...)
}
out[i] = clone
}
return out
}
// InMemoryModelCatalogCache is the single-node implementation. Adequate for
// self-hosted and tests; multi-node deploys should use the Redis backend so
// every API replica shares one warm catalog.
type InMemoryModelCatalogCache struct {
mu sync.Mutex
entries map[string]ModelCatalogSnapshot
retainFor time.Duration
}
func NewInMemoryModelCatalogCache() *InMemoryModelCatalogCache {
return &InMemoryModelCatalogCache{
entries: make(map[string]ModelCatalogSnapshot),
retainFor: modelCatalogServeWindow,
}
}
func (c *InMemoryModelCatalogCache) Get(_ context.Context, runtimeID string) (*ModelCatalogSnapshot, error) {
if runtimeID == "" {
return nil, nil
}
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.entries[runtimeID]
if !ok {
return nil, nil
}
if time.Since(entry.StoredAt) > c.retainFor {
delete(c.entries, runtimeID)
return nil, nil
}
// Copy so a caller mutating the response cannot corrupt the cache.
snapshot := entry
snapshot.Models = cloneModelEntries(entry.Models)
return &snapshot, nil
}
func (c *InMemoryModelCatalogCache) Put(_ context.Context, runtimeID string, models []ModelEntry, supported bool) error {
if runtimeID == "" || !cacheableModelCatalog(models, supported) {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
// Garbage-collect expired entries so the map can't grow unbounded as
// runtimes come and go.
now := time.Now()
for id, entry := range c.entries {
if now.Sub(entry.StoredAt) > c.retainFor {
delete(c.entries, id)
}
}
c.entries[runtimeID] = ModelCatalogSnapshot{
RuntimeID: runtimeID,
Models: cloneModelEntries(models),
Supported: supported,
StoredAt: now,
}
return nil
}
func (c *InMemoryModelCatalogCache) Invalidate(_ context.Context, runtimeID string) error {
if runtimeID == "" {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, runtimeID)
return nil
}