package proxy import ( "encoding/json" "fmt" "io" "net/http" "strings" "sync" "time" ) // ZenBaseURL is the upstream OpenCode Zen API base URL. const ZenBaseURL = "https://opencode.ai/zen/v1" // OpenCodeBackend forwards to OpenCode's free Zen models. // // It is a Discoverer: it polls GET /zen/v1/models and merges the IDs with the // curated base layer. OpenCode's /models returns only IDs (no metadata, no // free flag), so discovery is heuristic: curated models always pass with full // metadata; additionally any ID carrying a "free" token (suffix -free / :free) // is added with minimal metadata. This catches newly-rotated free models // (e.g. hy3-free) while avoiding paid models. type OpenCodeBackend struct { mu sync.RWMutex discovered []ModelInfo } // zenModels is the curated base layer: full metadata + alias anchors. var zenModels = []ModelInfo{ { ID: "big-pickle", Name: "Big Pickle (Free)", OwnedBy: "opencode-zen", ContextWindow: 200_000, MaxOutputTokens: 32_000, Description: "Permanentemente gratis. Código legacy, refactors, mantenimiento.", }, { ID: "deepseek-v4-flash-free", Name: "DeepSeek V4 Flash (Free)", OwnedBy: "deepseek", ContextWindow: 1_000_000, MaxOutputTokens: 384_000, Description: "El todoterreno. 1M contexto, 284B params (13B activos), razonamiento.", }, { ID: "mimo-v2.5-free", Name: "MiMo V2.5 (Free)", OwnedBy: "xiaomi", ContextWindow: 1_000_000, MaxOutputTokens: 32_000, Description: "Multimodal de Xiaomi. 1M contexto, imágenes → código. MIT license.", SupportsVision: true, }, { ID: "north-mini-code-free", Name: "North Mini Code (Free)", OwnedBy: "cohere", ContextWindow: 256_000, MaxOutputTokens: 64_000, Description: "Cohere. 30B total / 3B activos (MoE). Respuesta rápida, scripts.", }, { ID: "nemotron-3-ultra-free", Name: "Nemotron 3 Ultra (Free)", OwnedBy: "nvidia", ContextWindow: 1_000_000, MaxOutputTokens: 16_384, Description: "NVIDIA. 550B params (55B activos). Mamba-2 + MoE híbrido. Razonamiento.", }, } // modelAliases maps short/company/Claude names to OpenCode Zen model IDs. var modelAliases = map[string]string{ // Short aliases "pickle": "big-pickle", "big-pickle": "big-pickle", "deepseek": "deepseek-v4-flash-free", "deepseek-v4": "deepseek-v4-flash-free", "ds": "deepseek-v4-flash-free", "mimo": "mimo-v2.5-free", "mimo-v2.5": "mimo-v2.5-free", "xiaomi": "mimo-v2.5-free", "north": "north-mini-code-free", "north-mini": "north-mini-code-free", "cohere": "north-mini-code-free", "nemotron": "nemotron-3-ultra-free", "nemotron-3": "nemotron-3-ultra-free", "nvidia": "nemotron-3-ultra-free", // Exact IDs "deepseek-v4-flash-free": "deepseek-v4-flash-free", "mimo-v2.5-free": "mimo-v2.5-free", "north-mini-code-free": "north-mini-code-free", "nemotron-3-ultra-free": "nemotron-3-ultra-free", // Claude model name aliases (Claude Code validates model names client-side). "claude-sonnet-4-6": "deepseek-v4-flash-free", "claude-sonnet-4-5": "deepseek-v4-flash-free", "claude-sonnet-4": "deepseek-v4-flash-free", "claude-opus-4-8": "deepseek-v4-flash-free", "claude-opus-4-5": "deepseek-v4-flash-free", "claude-opus-4": "deepseek-v4-flash-free", "claude-haiku-4-5": "north-mini-code-free", "claude-haiku-4": "north-mini-code-free", "claude-3.5-sonnet": "north-mini-code-free", "claude-3.5-haiku": "big-pickle", } // Name returns the backend identifier. func (*OpenCodeBackend) Name() string { return "opencode" } // Models returns the OpenCode Zen free models (discovered, or curated fallback). func (b *OpenCodeBackend) Models() []ModelInfo { b.mu.RLock() defer b.mu.RUnlock() if len(b.discovered) == 0 { return zenModels } return b.discovered } // Resolve maps a requested name to an OpenCode Zen model ID. func (b *OpenCodeBackend) Resolve(model string) (string, bool) { if mapped, ok := modelAliases[model]; ok { return mapped, true } for _, m := range zenModels { if m.ID == model { return model, true } } b.mu.RLock() defer b.mu.RUnlock() for _, m := range b.discovered { if m.ID == model { return model, true } } return "", false } // ChatURL is the OpenCode Zen chat completions endpoint. func (*OpenCodeBackend) ChatURL() string { return ZenBaseURL + "/chat/completions" } // Headers builds the headers OpenCode Zen expects (x-opencode-* + public bearer). func (*OpenCodeBackend) Headers(requestID, sessionID string) map[string]string { return map[string]string{ "Authorization": "Bearer public", "User-Agent": "opencode/1.15.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.13", "x-opencode-client": "cli", "x-opencode-project": "global", "x-opencode-request": requestID, "x-opencode-session": sessionID, "Content-Type": "application/json", "Accept": "text/event-stream, application/json", } } // ---------- Auto-discovery ---------- // Refresh fetches GET /zen/v1/models and merges the IDs with the curated base // layer. Curated models keep their metadata; additionally any ID carrying a // "free" token is added with minimal metadata (heuristic free detection, // because OpenCode's /models has no free flag). func (b *OpenCodeBackend) Refresh() error { req, err := http.NewRequest("GET", ZenBaseURL+"/models", nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer public") req.Header.Set("x-opencode-client", "cli") req.Header.Set("Accept", "application/json") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return fmt.Errorf("opencode discovery: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("opencode discovery: upstream status %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return err } ids := parseZenModelIDs(body) merged := mergeZenCatalogue(zenModels, ids) b.mu.Lock() b.discovered = merged b.mu.Unlock() return nil } // zenModelsResponse is the minimal OpenCode Zen /models schema. type zenModelsResponse struct { Data []struct { ID string `json:"id"` OwnedBy string `json:"owned_by"` } `json:"data"` } // parseZenModelIDs returns the list of model IDs advertised upstream. func parseZenModelIDs(body []byte) []struct{ ID, OwnedBy string } { var resp zenModelsResponse if err := json.Unmarshal(body, &resp); err != nil { return nil } out := make([]struct{ ID, OwnedBy string }, 0, len(resp.Data)) for _, m := range resp.Data { out = append(out, struct{ ID, OwnedBy string }{m.ID, m.OwnedBy}) } return out } // looksFreeHeuristic reports whether a model ID looks free. Used because // OpenCode's /models endpoint has no isFree flag. func looksFreeHeuristic(id string) bool { low := strings.ToLower(id) return strings.Contains(low, "-free") || strings.Contains(low, ":free") || strings.HasSuffix(low, "free") } // mergeZenCatalogue keeps all curated models (with full metadata) plus any // discovered ID that looks free and isn't already curated. func mergeZenCatalogue(curated []ModelInfo, discovered []struct{ ID, OwnedBy string }) []ModelInfo { byID := make(map[string]bool, len(curated)+len(discovered)) merged := make([]ModelInfo, 0, len(curated)+len(discovered)) for _, m := range curated { if !byID[m.ID] { merged = append(merged, m) byID[m.ID] = true } } for _, d := range discovered { if byID[d.ID] { continue } if !looksFreeHeuristic(d.ID) { continue } owner := d.OwnedBy if owner == "" { owner = "opencode-zen" } merged = append(merged, ModelInfo{ ID: d.ID, Name: d.ID + " (Free)", OwnedBy: owner, Description: "Detectado automáticamente (heurística free).", }) byID[d.ID] = true } return merged } // ModelByID returns the OpenCode Zen ModelInfo for an ID, or nil if not found. // Used by the Anthropic path for vision auto-routing. Checks curated first, // then the discovered catalogue. func (b *OpenCodeBackend) ModelByID(id string) *ModelInfo { for _, m := range zenModels { if m.ID == id { return &m } } b.mu.RLock() defer b.mu.RUnlock() for i := range b.discovered { if b.discovered[i].ID == id { return &b.discovered[i] } } return nil } // ZenChatURL returns the OpenCode Zen chat completions URL. // Kept for backwards compatibility with existing call sites. func ZenChatURL() string { return ZenBaseURL + "/chat/completions" } // ZenModelsURL returns the OpenCode Zen models listing URL. func ZenModelsURL() string { return ZenBaseURL + "/models" }