mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 01:19:42 +02:00
* fix(agent): stop reporting a failed model discovery as a real catalog (MUL-5549) Selecting the CodeBuddy runtime showed a model list that shares no IDs with what the CLI actually supports, so every pick was an ID codebuddy rejects (GH #6180). The list in the report is codebuddyStaticModels() verbatim: the daemon had fallen back, but nothing downstream could tell. discoverCodebuddyModels returned (staticModels, nil) on all three failure paths, and copilot/cursor/grok do the same. A failed discovery therefore arrived as a successful one, which defeated every guard built to catch it: the daemon reported status "completed", the picker's discovery_failed hint only renders on isError, and cacheableModelCatalog — whose own comment says an empty list means transient failure — waves through a non-empty stand-in and stores it as last-known-good for the full 24h serve window. One blip got pinned as the answer for a day. Discovery now returns a Catalog carrying a Fallback marker, which the daemon forwards as an additive `fallback` field (older servers ignore it; an older daemon omitting it keeps the previous behaviour). A fallback catalog is still rendered — the picker stays populated and manual entry still works — but it is kept out of both the daemon's 60s discovery cache and the server's catalog cache. On the server it maps to Keep rather than Drop: a stand-in is no grounds to evict a real catalog, matching how a `failed` report is treated. Also stop codebuddyHelpOutput swallowing the exec error. CombinedOutput folds in stderr, so a codebuddy whose `#!/usr/bin/env node` interpreter is missing from a GUI-launched daemon's PATH had `env: node: No such file or directory` parsed as help text — and cached as such for 60s. Verified against CodeBuddy CLI v2.130.0: the parser itself is fine (16 models from real --help), so this fixes the reporting of the failure, not the parse. Co-authored-by: multica-agent <github@multica.ai> * fix(agent): run codebuddy --help at most once per model-list request (MUL-5549) Review catch on the previous commit. Model discovery and effort discovery both read `codebuddy --help`, and the effort pass called it independently. That was free while a failed --help was (wrongly) memoised, but once failures correctly stopped being cached, the failure path ran the 35s command twice in a single request — past the server's 60s running timeout, so the request timed out and the late report was then discarded as stale. The user got nothing, not even the fallback list the previous commit exists to preserve. discoverCodebuddyModels now owns the thinking annotation, so the one help capture feeds both catalogs, and the failure path uses codebuddyFallbackCatalog to apply the static effort levels without exec'ing at all: whatever broke --help for the model catalog breaks it for the effort catalog too. Also strengthen the handler tests. They decoded into a struct declared in the test rather than calling ReportModelListResult, so a wrong JSON tag or a mis-wired cache branch would have passed. They now drive the real endpoint with daemon auth and chi params, covering: a fallback report leaving a previously discovered catalog intact, an older daemon omitting the field still warming the cache, and an authoritative empty catalog still dropping the snapshot. Both fixes are mutation-tested — reverting either makes the new tests fail. Co-authored-by: multica-agent <github@multica.ai> * docs(agent): correct codebuddy --help comments after the single-capture refactor (MUL-5549) Review nit. The comments still described the pre-refactor call graph, where both discoverCodebuddyModels and codebuddyEffortSuperset called codebuddyHelpOutput and the cache was what stopped the duplicate run. The effort parser now takes an already-captured string, and the single-invocation guarantee is structural rather than cache-dependent — which matters, because a failed --help is deliberately not cached, so a second caller would re-run the full 35s timeout. Also note on codebuddyHelpOutput that it has exactly one caller and why a new one would reintroduce the bug. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
240 lines
9.4 KiB
Go
240 lines
9.4 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.
|
|
//
|
|
// The two windows do different jobs, and only one of them governs freshness:
|
|
// - modelCatalogRevalidateAfter is the freshness knob. Serving a snapshot
|
|
// older than this also queues a background refresh, so a CLI upgrade
|
|
// converges after one open no matter how long the serve window is.
|
|
// - modelCatalogServeWindow only bounds how long an UNUSED snapshot survives,
|
|
// and how stale the answer is for someone who opens the picker exactly once
|
|
// and never returns. It is deliberately day-scale: nothing keeps an entry
|
|
// warm in the background, the browser's own react-query cache dies with the
|
|
// tab, and agent CLIs are upgraded on a scale of days — so a minutes-scale
|
|
// window made every first-open-of-the-day a cold miss (the exact multi-second
|
|
// wait this cache exists to remove) while buying no real freshness.
|
|
//
|
|
// The window is not unbounded because a *failed* report deliberately leaves the
|
|
// snapshot in place (a transient discovery failure must not empty the picker).
|
|
// For a runtime whose discovery keeps failing — CLI uninstalled, logged out —
|
|
// expiry is the only thing that eventually retires the stale catalog.
|
|
//
|
|
// 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. Day-scale on purpose
|
|
// (MUL-5444): see the freshness discussion above — every served snapshot
|
|
// past modelCatalogRevalidateAfter queues its own refresh, so this bounds
|
|
// unused-entry lifetime and the open-once worst case, not staleness for an
|
|
// active user.
|
|
modelCatalogServeWindow = 24 * time.Hour
|
|
// modelCatalogRevalidateAfter is the age past which serving from cache also
|
|
// enqueues a background refresh. This is the knob that actually keeps the
|
|
// catalog honest; keep it short.
|
|
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".
|
|
//
|
|
// `fallback` closes the hole those two checks left open. Several providers
|
|
// answer a failed discovery with a non-empty static stand-in, which sails past
|
|
// the emptiness check and gets stored as last-known-good — so one transient
|
|
// failure pins a catalog the runtime never advertised for the full 24h serve
|
|
// window. For codebuddy the stand-in does not share a single ID with the real
|
|
// catalog, making every pick an ID the CLI rejects (MUL-5549).
|
|
func cacheableModelCatalog(models []ModelEntry, supported, fallback bool) bool {
|
|
return supported && !fallback && len(models) > 0
|
|
}
|
|
|
|
// modelCatalogCacheAction is what a completed discovery report should do to the
|
|
// runtime's cached catalog.
|
|
type modelCatalogCacheAction int
|
|
|
|
const (
|
|
// modelCatalogCacheStore writes the report as the new last-known-good.
|
|
modelCatalogCacheStore modelCatalogCacheAction = iota
|
|
// modelCatalogCacheDrop discards any snapshot: the report is authoritative
|
|
// and says this runtime no longer advertises the catalog we held.
|
|
modelCatalogCacheDrop
|
|
// modelCatalogCacheKeep leaves the cache untouched: the report is not
|
|
// authoritative, so it is neither worth storing nor grounds to discard a
|
|
// real catalog we already have.
|
|
modelCatalogCacheKeep
|
|
)
|
|
|
|
// modelCatalogCacheDecision maps a completed report onto its cache action.
|
|
//
|
|
// The fallback case is the MUL-5549 fix and is deliberately Keep, not Drop: a
|
|
// static stand-in tells us nothing about what the runtime supports, so letting
|
|
// it evict a real catalog would turn one transient discovery failure into a
|
|
// downgrade. That matches how a `failed` report is already handled — serving
|
|
// the last known good list through a transient failure is the point of the
|
|
// cache.
|
|
func modelCatalogCacheDecision(models []ModelEntry, supported, fallback bool) modelCatalogCacheAction {
|
|
if fallback {
|
|
return modelCatalogCacheKeep
|
|
}
|
|
if cacheableModelCatalog(models, supported, fallback) {
|
|
return modelCatalogCacheStore
|
|
}
|
|
return modelCatalogCacheDrop
|
|
}
|
|
|
|
// 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 {
|
|
// fallback=false: ReportModelListResult refuses to Put a fallback catalog
|
|
// at all, so anything reaching a cache backend is a real discovery result.
|
|
if runtimeID == "" || !cacheableModelCatalog(models, supported, false) {
|
|
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
|
|
}
|