package registry import ( "context" _ "embed" "encoding/json" "fmt" "io" "net/http" "strings" "sync" "time" log "github.com/sirupsen/logrus" ) const ( modelsFetchTimeout = 30 * time.Second ) var modelsURLs = []string{ "https://raw.githubusercontent.com/router-for-me/models/refs/heads/main/models.json", "https://models.router-for.me/models.json", } //go:embed models/models.json var embeddedModelsJSON []byte type modelStore struct { mu sync.RWMutex data *staticModelsJSON } var modelsCatalogStore = &modelStore{} var updaterOnce sync.Once func init() { // Load embedded data as fallback on startup. if err := loadModelsFromBytes(embeddedModelsJSON, "embed"); err != nil { panic(fmt.Sprintf("registry: failed to parse embedded models.json: %v", err)) } } // StartModelsUpdater runs a one-time models refresh on startup. // It blocks until the startup fetch attempt finishes so service initialization // can wait for the refreshed catalog before registering auth-backed models. // Safe to call multiple times; only one refresh will run. func StartModelsUpdater(ctx context.Context) { updaterOnce.Do(func() { runModelsUpdater(ctx) }) } func runModelsUpdater(ctx context.Context) { // Try network fetch once on startup, then stop. // Periodic refresh is disabled - models are only refreshed at startup. tryRefreshModels(ctx) } func tryRefreshModels(ctx context.Context) { client := &http.Client{Timeout: modelsFetchTimeout} for _, url := range modelsURLs { reqCtx, cancel := context.WithTimeout(ctx, modelsFetchTimeout) req, err := http.NewRequestWithContext(reqCtx, "GET", url, nil) if err != nil { cancel() log.Debugf("models fetch request creation failed for %s: %v", url, err) continue } resp, err := client.Do(req) if err != nil { cancel() log.Debugf("models fetch failed from %s: %v", url, err) continue } if resp.StatusCode != 200 { resp.Body.Close() cancel() log.Debugf("models fetch returned %d from %s", resp.StatusCode, url) continue } data, err := io.ReadAll(resp.Body) resp.Body.Close() cancel() if err != nil { log.Debugf("models fetch read error from %s: %v", url, err) continue } if err := loadModelsFromBytes(data, url); err != nil { log.Warnf("models parse failed from %s: %v", url, err) continue } log.Infof("models updated from %s", url) return } log.Warn("models refresh failed from all URLs, using current data") } func loadModelsFromBytes(data []byte, source string) error { var parsed staticModelsJSON if err := json.Unmarshal(data, &parsed); err != nil { return fmt.Errorf("%s: decode models catalog: %w", source, err) } if err := validateModelsCatalog(&parsed); err != nil { return fmt.Errorf("%s: validate models catalog: %w", source, err) } modelsCatalogStore.mu.Lock() modelsCatalogStore.data = &parsed modelsCatalogStore.mu.Unlock() return nil } func getModels() *staticModelsJSON { modelsCatalogStore.mu.RLock() defer modelsCatalogStore.mu.RUnlock() return modelsCatalogStore.data } func validateModelsCatalog(data *staticModelsJSON) error { if data == nil { return fmt.Errorf("catalog is nil") } requiredSections := []struct { name string models []*ModelInfo }{ {name: "claude", models: data.Claude}, {name: "gemini", models: data.Gemini}, {name: "vertex", models: data.Vertex}, {name: "gemini-cli", models: data.GeminiCLI}, {name: "aistudio", models: data.AIStudio}, {name: "codex-free", models: data.CodexFree}, {name: "codex-team", models: data.CodexTeam}, {name: "codex-plus", models: data.CodexPlus}, {name: "codex-pro", models: data.CodexPro}, {name: "qwen", models: data.Qwen}, {name: "iflow", models: data.IFlow}, {name: "kimi", models: data.Kimi}, } for _, section := range requiredSections { if err := validateModelSection(section.name, section.models); err != nil { return err } } if err := validateAntigravitySection(data.Antigravity); err != nil { return err } return nil } func validateModelSection(section string, models []*ModelInfo) error { if len(models) == 0 { return fmt.Errorf("%s section is empty", section) } seen := make(map[string]struct{}, len(models)) for i, model := range models { if model == nil { return fmt.Errorf("%s[%d] is null", section, i) } modelID := strings.TrimSpace(model.ID) if modelID == "" { return fmt.Errorf("%s[%d] has empty id", section, i) } if _, exists := seen[modelID]; exists { return fmt.Errorf("%s contains duplicate model id %q", section, modelID) } seen[modelID] = struct{}{} } return nil } func validateAntigravitySection(configs map[string]*AntigravityModelConfig) error { if len(configs) == 0 { return fmt.Errorf("antigravity section is empty") } for modelID, cfg := range configs { trimmedID := strings.TrimSpace(modelID) if trimmedID == "" { return fmt.Errorf("antigravity contains empty model id") } if cfg == nil { return fmt.Errorf("antigravity[%q] is null", trimmedID) } } return nil }