Files
zero/internal/modelregistry/modelsdev.go
Kevin CodexandClaude Fable 5 8b10c43cc6 feat(modelregistry): live models.dev overlay for context limits and pricing
The curated catalog stays the source of truth for model identity (ids,
aliases, match patterns, deprecations, escalation targets), but its
volatile facts went stale between releases. A cached snapshot of
https://models.dev/api.json now overlays context window, max output
tokens, and base per-million pricing onto known entries at registry
construction. The overlay never adds models, never touches curated
tiered pricing (models.dev has no tier data), and never does network on
the registry hot path: fetching happens only in an explicit background
refresh (exec/TUI startup), TTL 24h, atomic write, validated before it
can clobber a good cache, capped at 32MiB.

Opt-in by design: only the CLI entrypoint enables the overlay
(EnableModelsDevOverlay), so library consumers and hermetic tests are
never perturbed by a cache file on the machine. ZERO_MODELS_URL
overrides the source, ZERO_DISABLE_MODELS_FETCH disables fetch+overlay,
ZERO_MODELS_CACHE_PATH relocates the cache. Snapshots older than 7 days
are ignored (the shipped catalog is likely fresher). Parser validated
against the live api.json shape (150 providers).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 11:43:48 +08:00

270 lines
8.6 KiB
Go

package modelregistry
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
)
// Live models.dev overlay for the curated catalog. The hand-maintained
// DefaultModelEntries list is the source of truth for identity — ids, aliases,
// match patterns, deprecations, escalation targets — but its VOLATILE facts
// (context window, max output tokens, per-million pricing) go stale between
// releases. When a cached snapshot of https://models.dev/api.json is present,
// those fields are refreshed from it at registry construction; everything else
// stays curated. The overlay never adds models (an auto-added entry would lack
// aliases, match patterns, and provider wiring) and never touches the network
// on the registry hot path — fetching happens only in the explicit background
// refresh, cached to disk with a TTL.
const (
modelsDevDefaultURL = "https://models.dev/api.json"
// modelsDevRefreshAfter is how old the cache may get before a background
// refresh re-fetches it.
modelsDevRefreshAfter = 24 * time.Hour
// modelsDevMaxAge is the oldest cache still applied as an overlay. Beyond
// this the curated catalog (updated with the binary) is likely fresher than
// the snapshot, so a stale file is ignored rather than trusted.
modelsDevMaxAge = 7 * 24 * time.Hour
modelsDevFetchLimit = 32 << 20 // 32MiB guard on the response body
modelsDevFetchWindow = 15 * time.Second
)
// modelsDevModel is the subset of a models.dev model record the overlay uses.
type modelsDevModel struct {
Limit struct {
Context int `json:"context"`
Output int `json:"output"`
} `json:"limit"`
Cost struct {
Input float64 `json:"input"`
Output float64 `json:"output"`
CacheRead float64 `json:"cache_read"`
CacheWrite float64 `json:"cache_write"`
} `json:"cost"`
}
// modelsDevProvider matches one provider object in api.json.
type modelsDevProvider struct {
Models map[string]modelsDevModel `json:"models"`
}
// parseModelsDev decodes an api.json document into provider-slug -> api-model
// -> record. api.json is a top-level object keyed by provider id.
func parseModelsDev(data []byte) (map[string]map[string]modelsDevModel, error) {
var doc map[string]modelsDevProvider
if err := json.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("modelregistry: parse models.dev: %w", err)
}
providers := make(map[string]map[string]modelsDevModel, len(doc))
for slug, provider := range doc {
if len(provider.Models) == 0 {
continue
}
providers[slug] = provider.Models
}
if len(providers) == 0 {
return nil, fmt.Errorf("modelregistry: models.dev document has no providers")
}
return providers, nil
}
// modelsDevSlugs maps an entry's provider kind to the models.dev provider ids
// worth checking. Only first-party slugs are used: router entries on models.dev
// carry generic or stale numbers.
func modelsDevSlugs(kind ProviderKind) []string {
switch kind {
case ProviderAnthropic:
return []string{"anthropic"}
case ProviderOpenAI:
return []string{"openai"}
case ProviderGoogle:
return []string{"google", "google-vertex"}
default:
return nil
}
}
// applyModelsDevOverrides refreshes each curated entry's context limits and
// base pricing from the snapshot, when the snapshot knows the model. Tiered
// pricing is never touched: models.dev has no tier data, and mixing a live
// base rate with curated tiers would misprice the tier boundaries.
func applyModelsDevOverrides(entries []ModelEntry, providers map[string]map[string]modelsDevModel) []ModelEntry {
if len(providers) == 0 {
return entries
}
for i := range entries {
entry := &entries[i]
var record modelsDevModel
found := false
for _, slug := range modelsDevSlugs(entry.Provider) {
if models, ok := providers[slug]; ok {
if candidate, ok := models[strings.TrimSpace(entry.APIModel)]; ok {
record = candidate
found = true
break
}
}
}
if !found {
continue
}
if record.Limit.Context > 0 {
entry.ContextLimits.ContextWindow = record.Limit.Context
}
if record.Limit.Output > 0 {
entry.ContextLimits.MaxOutputTokens = record.Limit.Output
}
if len(entry.Cost.Tiers) == 0 && record.Cost.Input > 0 && record.Cost.Output > 0 {
entry.Cost.InputPerMillion = record.Cost.Input
entry.Cost.OutputPerMillion = record.Cost.Output
if record.Cost.CacheRead > 0 {
entry.Cost.CachedInputPerMillion = record.Cost.CacheRead
}
if record.Cost.CacheWrite > 0 {
entry.Cost.CacheWritePerMillion = record.Cost.CacheWrite
}
entry.Cost.Source = "models.dev/api.json (cached)"
}
}
return entries
}
// modelsDevCachePath returns the on-disk cache location. ZERO_MODELS_CACHE_PATH
// overrides it (used by tests and unusual setups).
func modelsDevCachePath() (string, error) {
if override := strings.TrimSpace(os.Getenv("ZERO_MODELS_CACHE_PATH")); override != "" {
return override, nil
}
base, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(base, "zero", "modelsdev.json"), nil
}
var (
modelsDevOnce sync.Once
modelsDevCached map[string]map[string]modelsDevModel
modelsDevEnabled atomic.Bool
)
// EnableModelsDevOverlay opts the process into applying the cached models.dev
// snapshot on top of the curated catalog. The CLI entrypoint calls it; library
// consumers and tests that never do get the curated catalog byte-identical to
// before, so hermetic tests can't be perturbed by a cache file on the machine.
// ZERO_DISABLE_MODELS_FETCH disables both the overlay and the fetch.
func EnableModelsDevOverlay() {
modelsDevEnabled.Store(true)
}
// cachedModelsDevProviders loads the cached snapshot once per process. Not
// enabled, missing, stale (> modelsDevMaxAge), or malformed all yield nil and
// the curated catalog is used untouched. Read once deliberately:
// DefaultRegistry is called on hot paths (pickers, cost views) and must not
// re-stat the file every time; a background refresh benefits the NEXT process.
func cachedModelsDevProviders() map[string]map[string]modelsDevModel {
if !modelsDevEnabled.Load() || strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
return nil
}
modelsDevOnce.Do(func() {
path, err := modelsDevCachePath()
if err != nil {
return
}
info, err := os.Stat(path)
if err != nil || time.Since(info.ModTime()) > modelsDevMaxAge {
return
}
data, err := os.ReadFile(path)
if err != nil {
return
}
providers, err := parseModelsDev(data)
if err != nil {
return
}
modelsDevCached = providers
})
return modelsDevCached
}
// resetModelsDevCacheForTest clears the process-level cache memoization and
// disables the overlay.
func resetModelsDevCacheForTest() {
modelsDevOnce = sync.Once{}
modelsDevCached = nil
modelsDevEnabled.Store(false)
}
// RefreshModelsDevCache fetches models.dev/api.json into the on-disk cache
// when the cache is missing or older than modelsDevRefreshAfter. It is safe to
// call fire-and-forget from startup (use a goroutine); it never affects the
// current process's registry (see cachedModelsDevProviders). Disabled entirely
// by ZERO_DISABLE_MODELS_FETCH. The URL can be overridden with ZERO_MODELS_URL.
func RefreshModelsDevCache(ctx context.Context) error {
if strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
return nil
}
path, err := modelsDevCachePath()
if err != nil {
return err
}
if info, err := os.Stat(path); err == nil && time.Since(info.ModTime()) < modelsDevRefreshAfter {
return nil
}
url := strings.TrimSpace(os.Getenv("ZERO_MODELS_URL"))
if url == "" {
url = modelsDevDefaultURL
}
fetchCtx, cancel := context.WithTimeout(ctx, modelsDevFetchWindow)
defer cancel()
request, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, url, nil)
if err != nil {
return err
}
request.Header.Set("User-Agent", "zero-models-refresh/0.1")
response, err := http.DefaultClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("modelregistry: models.dev fetch: HTTP %d", response.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(response.Body, modelsDevFetchLimit))
if err != nil {
return err
}
// Validate before persisting: a bad body must never clobber a good cache.
if _, err := parseModelsDev(data); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
temp, err := os.CreateTemp(filepath.Dir(path), "modelsdev-*.json")
if err != nil {
return err
}
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
_ = os.Remove(temp.Name())
return err
}
if err := temp.Close(); err != nil {
_ = os.Remove(temp.Name())
return err
}
return os.Rename(temp.Name(), path)
}