mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* feat(agent): discover Claude Code's model catalog from the CLI
Claude was the last first-party runtime answering "what models do you
have?" from a hand-maintained list. A static catalog cannot know what the
CLI in front of it can run, so every new Anthropic model opened a window
where Multica offered a model that 400s:
API Error: 400 Claude Code 2.1.246 does not support this model;
version 2.1.251 or newer is required.
Claude Code does have a discovery hook, just not on the command line: the
stream-json control protocol answers a `list_models` control request with
the rows its own /model picker renders. Ask the installed binary and the
window closes at the source — a model it cannot run is either absent or
flagged disabled, computed against the real CLI version and the logged-in
account.
Verified against 2.1.223/228/246/258. The three older builds return Fable
5.1 as a disabled row carrying "Update to 2.1.255+ to use Fable 5.1";
2.1.258 returns it as a normal pick. Those rows are the reason no
per-model minimum-version table is needed here: upstream already computes
one, including the remedy copy, which we forward verbatim and grey out.
No version gate guards the request. An unsupported subtype is answered,
not ignored — every build tested replies "Unsupported control request
subtype" in ~2s and exits 0 — so an old CLI costs one cheap round trip and
falls back. Gating would mean hand-maintaining exactly the kind of number
this change removes.
Notes:
- The static catalog stays, now as the fallback and as the server-side
accept list. It is flagged Catalog.Fallback so a discovery failure is
not pinned in the server's day-long cache (MUL-5549).
- Discovery replaces rather than merges, as with every other dynamic
provider. The picker narrows to the CLI's own list; agents already
pinned to an older model keep working, and manual entry still reaches
them.
- Effort levels now come from each row instead of scraping `claude
--help` and narrowing through the hand-kept claudeModelEffortAllow.
- Catalog ids keep the context-window tag, because `claude-opus-5[1m]` is
what the CLI would really run for that row. That exposed a latent bug
in two capability lookups, which normalized the query side but not the
catalog side; both now normalize both, or a model would fail to find
its own effort picker.
MUL-6961
Co-authored-by: multica-agent <[email protected]>
* fix(agent): keep unrunnable Claude models out of the selectable catalog
Review found the first cut carried "this CLI cannot run it" as a flag on
the model itself. That was wrong in a way a flag cannot fix.
Only the create-agent dropdown learned the flag. The inspector picker and
the agent builder read the same list and kept offering the row, so
editing an existing agent still reached the 400 this change exists to
prevent. And the part no amount of teaching fixes: an already installed
desktop client has never heard of the field, so it renders the row as an
ordinary model and persists a model id the CLI rejects — exactly what the
repo's mixed-version API rule forbids.
Unavailable models now travel in their own list, end to end: daemon
report, store, catalog cache, and the client result. `models` contains
only selectable entries. The inspector and the builder needed no changes
to become correct — they read `models`, and the rows are no longer in it
— and an old client is correct for the same reason. Both pickers render
the advisory rows through one shared component that takes no click
handler, so nothing can turn them back into controls.
Two more review findings:
- The default row was dropped when no sibling row named the same model,
which an org-restricted install can produce. That is not one missing
picker entry: ValidateThinkingLevelWith resolves an empty model — the
common "follow the CLI default" configuration — through the entry
flagged Default, and fails closed without one, silently discarding the
user's effort on every such task. It is now materialised, keeping the
row's own capabilities. The test that claimed to cover this asserted
the opposite of its own comment; rewritten, plus a case proving an
unrunnable default is never materialised.
- "One cheap round trip" for a CLI without list_models was one round trip
PER TASK: every Claude task carrying a thinking_level loads the catalog
before it starts, and cachedDiscovery refuses to memoise a fallback.
The structured unsupported-subtype answer is now remembered per
(command, CLI version) — a property of the binary, not a bad moment —
so an old CLI is probed once. Transient failures stay uncached and
retryable, and the version in the key makes an upgrade take effect at
once rather than after a TTL. Detecting it costs ~0.01s against the
~1.7s it saves, and the fallback path already paid for it.
The discovery timeout keeps its 20s ceiling, now with the measurements
that justify it: 1.57-2.19s over five runs against 2.1.258, 1.64-1.79s
against 2.1.246. The ceiling is for a CLI that never answers, not a slow
one.
Verified against real binaries: 2.1.246 offers four selectable models and
one unavailable row carrying upstream's own upgrade hint, 2.1.258 offers
Fable 5.1 normally, a missing binary falls back to the flagged static
catalog.
MUL-6961
Co-authored-by: multica-agent <[email protected]>
* docs(agent): record the downgrade case as an accepted boundary
Dynamic discovery governs selection, and only selection. An agent already
configured with a model whose CLI is later downgraded still starts and
still gets the upstream 400 — discovery cannot reach a value already
persisted.
That is deliberate, not an oversight, so it is written down next to the
code that invites the wrong fix. Clearing the configured model turns a
legible error into a silent config change the user did not make;
substituting a runnable one is worse, because it succeeds using a model
nobody chose. The downgrade is rare and the upstream error is accurate,
so failing loudly at task start is a fine way to report it.
MUL-6961
Co-authored-by: multica-agent <[email protected]>
---------
Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
255 lines
10 KiB
Go
255 lines
10 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"`
|
|
// UnavailableModels rides along so a cache hit renders the same picker as
|
|
// the live round trip. Without it the greyed-out "needs a newer CLI" rows
|
|
// would blink out for 24h the moment the first snapshot was stored.
|
|
UnavailableModels []UnavailableModelEntry `json:"unavailable_models,omitempty"`
|
|
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, unavailable []UnavailableModelEntry, 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
|
|
}
|
|
|
|
// cloneUnavailableModelEntries mirrors cloneModelEntries for the advisory list.
|
|
// The entries hold no pointers, so a shallow element copy is a full one.
|
|
func cloneUnavailableModelEntries(models []UnavailableModelEntry) []UnavailableModelEntry {
|
|
if models == nil {
|
|
return nil
|
|
}
|
|
return append([]UnavailableModelEntry(nil), models...)
|
|
}
|
|
|
|
// 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)
|
|
snapshot.UnavailableModels = cloneUnavailableModelEntries(entry.UnavailableModels)
|
|
return &snapshot, nil
|
|
}
|
|
|
|
func (c *InMemoryModelCatalogCache) Put(_ context.Context, runtimeID string, models []ModelEntry, unavailable []UnavailableModelEntry, 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),
|
|
UnavailableModels: cloneUnavailableModelEntries(unavailable),
|
|
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
|
|
}
|