package main import ( "context" "fmt" "io" "log" "math/rand" "net/http" "regexp" "sort" "strings" "sync" "time" ) const ( freeAgentsSourceURL = "https://raw.githubusercontent.com/CodebuffAI/codebuff/main/common/src/constants/free-agents.ts" modelRefreshInterval = 6 * time.Hour ) // hardcodedFallback is used when the remote fetch fails on startup. var hardcodedFallback = map[string][]string{ "base2-free": {"minimax/minimax-m2.7", "z-ai/glm-5.1"}, "file-picker": {"google/gemini-2.5-flash-lite"}, "file-picker-max": {"google/gemini-3.1-flash-lite-preview"}, "file-lister": {"google/gemini-3.1-flash-lite-preview"}, "researcher-web": {"google/gemini-3.1-flash-lite-preview"}, "researcher-docs": {"google/gemini-3.1-flash-lite-preview"}, "basher": {"google/gemini-3.1-flash-lite-preview"}, "editor-lite": {"minimax/minimax-m2.7", "z-ai/glm-5.1"}, "code-reviewer-lite": {"minimax/minimax-m2.7", "z-ai/glm-5.1"}, } // ModelRegistry fetches and caches the agent→model mapping for all free agents // from the upstream free-agents.ts source file. type ModelRegistry struct { client *http.Client logger *log.Logger mu sync.RWMutex agentModels map[string][]string // agentID → []model modelToAgent map[string]string // model → chosen agentID allModels []string // deduplicated, sorted lastOK time.Time stopCh chan struct{} wg sync.WaitGroup } func NewModelRegistry(client *http.Client, logger *log.Logger) *ModelRegistry { return &ModelRegistry{ client: client, logger: logger, agentModels: make(map[string][]string), modelToAgent: make(map[string]string), stopCh: make(chan struct{}), } } func (r *ModelRegistry) Start(ctx context.Context) { if err := r.refresh(ctx); err != nil { r.logger.Printf("model registry: initial fetch failed, loading hardcoded fallback: %v", err) r.loadFallback() } r.wg.Add(1) go func() { defer r.wg.Done() ticker := time.NewTicker(modelRefreshInterval) defer ticker.Stop() for { select { case <-ticker.C: ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) if err := r.refresh(ctx); err != nil { r.logger.Printf("model registry: refresh failed: %v", err) } cancel() case <-r.stopCh: return } } }() } func (r *ModelRegistry) Stop() { close(r.stopCh) r.wg.Wait() } // Models returns the deduplicated list of all available model names. func (r *ModelRegistry) Models() []string { r.mu.RLock() defer r.mu.RUnlock() out := make([]string, len(r.allModels)) copy(out, r.allModels) return out } // HasModel checks if the given model is available. func (r *ModelRegistry) HasModel(model string) bool { r.mu.RLock() defer r.mu.RUnlock() _, ok := r.modelToAgent[model] return ok } // AgentForModel returns the agent ID that should serve the given model. func (r *ModelRegistry) AgentForModel(model string) (string, bool) { r.mu.RLock() defer r.mu.RUnlock() agent, ok := r.modelToAgent[model] return agent, ok } // AgentIDs returns the list of all known agent IDs. func (r *ModelRegistry) AgentIDs() []string { r.mu.RLock() defer r.mu.RUnlock() ids := make([]string, 0, len(r.agentModels)) for id := range r.agentModels { ids = append(ids, id) } return ids } func (r *ModelRegistry) refresh(ctx context.Context) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, freeAgentsSourceURL, nil) if err != nil { return fmt.Errorf("create request: %w", err) } req.Header.Set("Accept", "text/plain") resp, err := r.client.Do(req) if err != nil { return fmt.Errorf("fetch free-agents source: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("unexpected status %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("read response: %w", err) } all := parseAllFreeModels(string(body)) if len(all) == 0 { return fmt.Errorf("no free agents found in source") } modelToAgent, allModels := buildModelMapping(all) r.mu.Lock() r.agentModels = all r.modelToAgent = modelToAgent r.allModels = allModels r.lastOK = time.Now() r.mu.Unlock() r.logger.Printf("model registry: updated %d agents, %d models: %v", len(all), len(allModels), allModels) return nil } func (r *ModelRegistry) loadFallback() { modelToAgent, allModels := buildModelMapping(hardcodedFallback) r.mu.Lock() r.agentModels = hardcodedFallback r.modelToAgent = modelToAgent r.allModels = allModels r.mu.Unlock() r.logger.Printf("model registry: loaded fallback models: %v", allModels) } // parseAllFreeModels extracts ALL agent→models mappings from the free-agents.ts source. func parseAllFreeModels(source string) map[string][]string { blockPattern := regexp.MustCompile(`'([^']+)':\s*new\s+Set\(\[([^\]]*)\]\)`) modelPattern := regexp.MustCompile(`'([^']+)'`) result := make(map[string][]string) for _, match := range blockPattern.FindAllStringSubmatch(source, -1) { agentID := match[1] modelsStr := match[2] var models []string for _, modelMatch := range modelPattern.FindAllStringSubmatch(modelsStr, -1) { model := strings.TrimSpace(modelMatch[1]) if model != "" { models = append(models, model) } } if len(models) > 0 { result[agentID] = models } } return result } // buildModelMapping creates the model→agent reverse mapping and deduplicated model list. // When a model appears in multiple agents, one is chosen at random. func buildModelMapping(agentModels map[string][]string) (map[string]string, []string) { modelAgents := make(map[string][]string) for agentID, models := range agentModels { for _, model := range models { modelAgents[model] = append(modelAgents[model], agentID) } } modelToAgent := make(map[string]string, len(modelAgents)) allModels := make([]string, 0, len(modelAgents)) for model, agents := range modelAgents { modelToAgent[model] = agents[rand.Intn(len(agents))] allModels = append(allModels, model) } sort.Strings(allModels) return modelToAgent, allModels }