Files
renato97 46127431a3 feat(proxy): auto-discovery de modelos + backend Kimchi
- Nueva interfaz Discoverer con Refresh() por backend
- Kilo: full auto (isFree + metadata completa del upstream)
- OpenCode: heurística -free para descubrir nuevos free
- Kimchi: agrega todos los IDs no-curados del upstream
- discoveryLoop cada 10 min en el Proxy
- Backends stateful (mutex + catálogo dinámico)
- CREDENCIALES.md y KIMCHI_ANALISIS.md documentan el setup
2026-07-12 19:58:30 -03:00

284 lines
8.6 KiB
Go

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" }