mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 19:13:28 -10:00
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]>
270 lines
8.6 KiB
Go
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)
|
|
}
|