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
This commit is contained in:
renato97
2026-07-12 19:58:30 -03:00
parent 05a52d29e2
commit 46127431a3
8 changed files with 1041 additions and 29 deletions
+4 -1
View File
@@ -665,7 +665,10 @@ func (p *Proxy) ForwardAnthropicMessages(w http.ResponseWriter, r *http.Request,
// a vision-capable free model). Kilo models are forwarded as-is.
hasImages := requestHasImages(&ar)
if hasImages && backend.Name() == "opencode" {
mi := ModelByID(resolved)
var mi *ModelInfo
if oc, ok := backend.(*OpenCodeBackend); ok {
mi = oc.ModelByID(resolved)
}
if mi == nil || !mi.SupportsVision {
fmt.Printf("[anthropic] model=%s lacks vision, auto-routing to mimo-v2.5-free\n", resolved)
resolved = "mimo-v2.5-free"
+15
View File
@@ -11,6 +11,8 @@ type Backend interface {
Name() string
// Models returns the free models this backend exposes, for /v1/models listing.
// Implementations may return a dynamic (discovered) catalogue; before the
// first successful Refresh() they fall back to the curated list.
Models() []ModelInfo
// Resolve maps a client-requested model name to the upstream model ID.
@@ -25,3 +27,16 @@ type Backend interface {
// request to the upstream (auth, client-id, request/session tracking, etc.).
Headers(requestID, sessionID string) map[string]string
}
// Discoverer is an optional capability a Backend may implement to
// auto-discover its model catalogue from the upstream /models endpoint.
//
// The Proxy runs a discovery loop that calls Refresh() periodically. Each
// implementation owns its parsing and free-model filtering logic, and merges
// discovered models with its curated base layer (curated metadata always wins
// for known IDs).
type Discoverer interface {
// Refresh fetches the upstream model list and updates the backend's
// dynamic catalogue. Safe to call concurrently with Models()/Resolve().
Refresh() error
}
+181 -15
View File
@@ -1,5 +1,15 @@
package proxy
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// KiloGatewayBase is the Kilo Code public gateway base URL.
// Confirmed working without auth for free models (HTTP 200 on both
// GET /api/gateway/models and POST /api/gateway/chat/completions).
@@ -7,14 +17,20 @@ const KiloGatewayBase = "https://api.kilo.ai/api/gateway"
// KiloBackend forwards to Kilo Code's free gateway models.
//
// Kilo is an opencode-based CLI whose gateway exposes a handful of free models
// (suffixed ":free") reachable without authentication, in standard OpenAI
// Chat Completions format.
type KiloBackend struct{}
// Kilo is an opencode-based CLI whose gateway exposes free models (flagged
// isFree:true in GET /api/gateway/models) reachable without authentication,
// in standard OpenAI Chat Completions format. This backend is a Discoverer:
// it auto-discovers its full free catalogue with rich metadata from the
// upstream /models endpoint.
type KiloBackend struct {
mu sync.RWMutex
discovered []ModelInfo
}
// kiloFreeModels are the free models advertised by the Kilo gateway.
// Sourced from GET /api/gateway/models filtering isFree:true.
var kiloFreeModels = []ModelInfo{
// kiloCuratedModels is the curated base layer: aliases + trusted metadata.
// Discovered models matching these IDs keep this metadata (curated wins).
// Discovered-only models use the metadata parsed from the upstream response.
var kiloCuratedModels = []ModelInfo{
{
ID: "stepfun/step-3.7-flash:free",
Name: "StepFun Step 3.7 Flash (Free)",
@@ -60,17 +76,34 @@ var kiloAliases = map[string]string{
}
// Name returns the backend identifier.
func (KiloBackend) Name() string { return "kilo" }
func (*KiloBackend) Name() string { return "kilo" }
// Models returns the Kilo free models.
func (KiloBackend) Models() []ModelInfo { return kiloFreeModels }
// Models returns the Kilo free models (discovered catalogue, or curated
// fallback before the first successful Refresh()).
func (b *KiloBackend) Models() []ModelInfo {
b.mu.RLock()
defer b.mu.RUnlock()
if len(b.discovered) == 0 {
return kiloCuratedModels
}
return b.discovered
}
// Resolve maps a requested name to a Kilo free model ID.
func (KiloBackend) Resolve(model string) (string, bool) {
// Resolve maps a requested name to a Kilo model ID. Checks curated aliases,
// curated IDs, then the discovered catalogue so newly discovered models route
// correctly instead of falling through to pass-through.
func (b *KiloBackend) Resolve(model string) (string, bool) {
if mapped, ok := kiloAliases[model]; ok {
return mapped, true
}
for _, m := range kiloFreeModels {
for _, m := range kiloCuratedModels {
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
}
@@ -79,13 +112,146 @@ func (KiloBackend) Resolve(model string) (string, bool) {
}
// ChatURL is the Kilo gateway chat completions endpoint.
func (KiloBackend) ChatURL() string { return KiloGatewayBase + "/chat/completions" }
func (*KiloBackend) ChatURL() string { return KiloGatewayBase + "/chat/completions" }
// Headers returns minimal headers for the Kilo gateway (no auth needed for free).
func (KiloBackend) Headers(_, _ string) map[string]string {
func (*KiloBackend) Headers(_, _ string) map[string]string {
return map[string]string{
"Content-Type": "application/json",
"Accept": "text/event-stream, application/json",
"User-Agent": "kilo/7.3.54",
}
}
// ---------- Auto-discovery ----------
// Refresh fetches GET /api/gateway/models, keeps only isFree:true entries,
// and merges them with the curated base layer. Curated metadata wins for
// known IDs; discovered-only models use upstream metadata.
func (b *KiloBackend) Refresh() error {
req, err := http.NewRequest("GET", KiloGatewayBase+"/models", nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "kilo/7.3.54")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("kilo discovery: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("kilo discovery: upstream status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
parsed := parseKiloModels(body)
merged := mergeKiloCatalogue(kiloCuratedModels, parsed)
b.mu.Lock()
b.discovered = merged
b.mu.Unlock()
return nil
}
// kiloModelsResponse is the subset of the Kilo /models schema we care about.
type kiloModelsResponse struct {
Data []struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
IsFree bool `json:"isFree"`
ContextLen int `json:"context_length"`
TopProvider struct {
ContextLength int `json:"context_length"`
MaxCompletionTokens *int `json:"max_completion_tokens"`
} `json:"top_provider"`
Architecture struct {
InputModalities []string `json:"input_modalities"`
} `json:"architecture"`
Pricing struct {
Prompt string `json:"prompt"`
Completion string `json:"completion"`
} `json:"pricing"`
} `json:"data"`
}
// parseKiloModels converts the upstream response into ModelInfo entries,
// keeping only free models (isFree:true).
func parseKiloModels(body []byte) []ModelInfo {
var resp kiloModelsResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil
}
var out []ModelInfo
for _, m := range resp.Data {
if !m.IsFree {
continue
}
ctx := m.ContextLen
if ctx == 0 {
ctx = m.TopProvider.ContextLength
}
maxOut := 0
if m.TopProvider.MaxCompletionTokens != nil {
maxOut = *m.TopProvider.MaxCompletionTokens
}
vision := false
for _, mod := range m.Architecture.InputModalities {
if mod == "image" {
vision = true
}
}
owner := ownerFromID(m.ID)
desc := m.Description
if desc == "" {
desc = fmt.Sprintf("%s (free). Detectado automáticamente.", m.Name)
}
out = append(out, ModelInfo{
ID: m.ID,
Name: m.Name,
OwnedBy: owner,
ContextWindow: ctx,
MaxOutputTokens: maxOut,
Description: desc,
SupportsVision: vision,
})
}
return out
}
// mergeKiloCatalogue merges curated + discovered. Curated entries are always
// present (with their metadata). Discovered entries not in curated are added.
func mergeKiloCatalogue(curated, discovered []ModelInfo) []ModelInfo {
byID := make(map[string]bool, len(curated)+len(discovered))
merged := make([]ModelInfo, 0, len(curated)+len(discovered))
// Curated first (trusted metadata + aliases still resolve).
for _, m := range curated {
if !byID[m.ID] {
merged = append(merged, m)
byID[m.ID] = true
}
}
// Discovered-only appended with upstream metadata.
for _, m := range discovered {
if !byID[m.ID] {
merged = append(merged, m)
byID[m.ID] = true
}
}
return merged
}
// ownerFromID extracts a provider name from a "provider/model" id.
func ownerFromID(id string) string {
if i := strings.Index(id, "/"); i >= 0 {
return id[:i]
}
return "unknown"
}
+232
View File
@@ -0,0 +1,232 @@
package proxy
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"sync"
"time"
)
// KimchiBaseURL is the upstream Kimchi Dev API base URL.
const KimchiBaseURL = "https://llm.kimchi.dev/openai/v1"
// KimchiBackend forwards to Kimchi Dev's free models.
//
// It is a Discoverer: it polls GET /openai/v1/models and merges the IDs with
// the curated base layer. Kimchi's /models returns only IDs (no metadata, no
// free flag). Because the Kimchi account is credit-based, every advertised
// model is treated as usable: all discovered IDs not already curated are added
// with minimal metadata (option B). Curated IDs keep their trusted metadata.
type KimchiBackend struct {
mu sync.RWMutex
discovered []ModelInfo
}
// kimchiCuratedModels is the curated base layer with full metadata.
var kimchiCuratedModels = []ModelInfo{
{
ID: "deepseek-v4-flash",
Name: "DeepSeek V4 Flash (Kimchi)",
OwnedBy: "deepseek",
ContextWindow: 1_048_576,
MaxOutputTokens: 1_048_576,
Description: "DeepSeek V4, 284B params, propósito general, razonamiento.",
},
{
ID: "glm-5.2-fp8",
Name: "GLM 5.2 FP8 (Kimchi)",
OwnedBy: "zhipu",
ContextWindow: 1_048_576,
MaxOutputTokens: 1_048_576,
Description: "GLM-5.2 cuantizado FP8, razonamiento, rendimiento.",
},
{
ID: "kimi-k2.7",
Name: "Kimi K2.7 (Kimchi)",
OwnedBy: "moonshot",
ContextWindow: 262_144,
MaxOutputTokens: 262_144,
Description: "Moonshot AI, multimodal con visión, razonamiento.",
SupportsVision: true,
},
{
ID: "minimax-m3",
Name: "MiniMax M3 (Kimchi)",
OwnedBy: "minimax",
ContextWindow: 1_048_576,
MaxOutputTokens: 1_048_576,
Description: "MiniMax M3, multimodal texto+imagen, razonamiento.",
SupportsVision: true,
},
{
ID: "nemotron-3-ultra-fp4",
Name: "Nemotron 3 Ultra FP4 (Kimchi)",
OwnedBy: "nvidia",
ContextWindow: 1_048_576,
MaxOutputTokens: 1_048_576,
Description: "NVIDIA Nemotron 3 Ultra 550B, FP4 cuantizado, razonamiento.",
},
}
// kimchiAliases lets clients use shorter names for Kimchi models.
// Uses prefixed aliases to avoid colliding with opencode backend aliases.
var kimchiAliases = map[string]string{
"kimchi": "deepseek-v4-flash",
"kimchi/deepseek": "deepseek-v4-flash",
"kimchi/glm": "glm-5.2-fp8",
"kimchi/kimi": "kimi-k2.7",
"kimchi/minimax": "minimax-m3",
"kimchi/nemotron": "nemotron-3-ultra-fp4",
"deepseek-v4-flash": "deepseek-v4-flash",
"glm-5.2-fp8": "glm-5.2-fp8",
"kimi-k2.7": "kimi-k2.7",
"minimax-m3": "minimax-m3",
"nemotron-3-ultra-fp4": "nemotron-3-ultra-fp4",
}
// Name returns the backend identifier.
func (*KimchiBackend) Name() string { return "kimchi" }
// Models returns the Kimchi free models (discovered, or curated fallback).
func (b *KimchiBackend) Models() []ModelInfo {
b.mu.RLock()
defer b.mu.RUnlock()
if len(b.discovered) == 0 {
return kimchiCuratedModels
}
return b.discovered
}
// Resolve maps a requested name to a Kimchi model ID.
func (b *KimchiBackend) Resolve(model string) (string, bool) {
if mapped, ok := kimchiAliases[model]; ok {
return mapped, true
}
for _, m := range kimchiCuratedModels {
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 Kimchi Dev chat completions endpoint.
func (*KimchiBackend) ChatURL() string { return KimchiBaseURL + "/chat/completions" }
// Headers returns the headers required for Kimchi Dev auth.
// Reads the API key from the KIMCHI_API_KEY env var.
// Falls back to the hardcoded key if the env var is empty.
func (*KimchiBackend) Headers(_, _ string) map[string]string {
key := os.Getenv("KIMCHI_API_KEY")
if key == "" {
key = "castai_v1_befd8c666ce652d47cc08accf8c5accc0a5aaca9e1f006392df69ac123198f9b_4f8f2d40"
}
return map[string]string{
"Authorization": "Bearer " + key,
"Content-Type": "application/json",
"Accept": "text/event-stream, application/json",
"User-Agent": "kimchi/0.1.50",
}
}
// ---------- Auto-discovery ----------
// Refresh fetches GET /openai/v1/models and merges the IDs with the curated
// base layer. All discovered IDs are treated as usable (credit-based account),
// so every non-curated ID is added with minimal metadata.
func (b *KimchiBackend) Refresh() error {
req, err := http.NewRequest("GET", KimchiBaseURL+"/models", nil)
if err != nil {
return err
}
key := os.Getenv("KIMCHI_API_KEY")
if key == "" {
key = "castai_v1_befd8c666ce652d47cc08accf8c5accc0a5aaca9e1f006392df69ac123198f9b_4f8f2d40"
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("User-Agent", "kimchi/0.1.50")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("kimchi discovery: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("kimchi discovery: upstream status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
ids := parseKimchiModelIDs(body)
merged := mergeKimchiCatalogue(kimchiCuratedModels, ids)
b.mu.Lock()
b.discovered = merged
b.mu.Unlock()
return nil
}
// kimchiModelsResponse is the minimal Kimchi /models schema.
type kimchiModelsResponse struct {
Data []struct {
ID string `json:"id"`
OwnedBy string `json:"owned_by"`
} `json:"data"`
}
// parseKimchiModelIDs returns the list of model IDs advertised upstream.
func parseKimchiModelIDs(body []byte) []struct{ ID, OwnedBy string } {
var resp kimchiModelsResponse
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
}
// mergeKimchiCatalogue keeps all curated models plus every discovered ID
// (Kimchi is credit-based, so all advertised models are usable).
func mergeKimchiCatalogue(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
}
owner := d.OwnedBy
if owner == "" {
owner = "castai"
}
merged = append(merged, ModelInfo{
ID: d.ID,
Name: d.ID + " (Kimchi)",
OwnedBy: owner,
Description: "Detectado automáticamente.",
})
byID[d.ID] = true
}
return merged
}
+47 -3
View File
@@ -21,6 +21,11 @@ type ModelInfo struct {
SupportsVision bool `json:"supports_vision"`
}
// discoveryInterval is how often backends re-fetch their upstream /models
// catalogue. Kept shorter than the health interval because model lists rotate
// more frequently than rate-limit windows.
const discoveryInterval = 10 * time.Minute
// healthCheckInterval is how often caged models get re-probed.
const healthCheckInterval = 30 * time.Minute
@@ -59,15 +64,20 @@ type session struct {
// the catch-all opencode aliases. The default backend is the fallback for
// unknown models (pass-through as-is).
func NewProxy(apiKey string) *Proxy {
oc := OpenCodeBackend{}
kl := KiloBackend{}
// Backends are stateful (they hold a mutex + discovered catalogue), so we
// store pointers to avoid copying the mutex when the Backend interface
// value is passed around.
oc := &OpenCodeBackend{}
kl := &KiloBackend{}
km := &KimchiBackend{}
p := &Proxy{
apiKey: apiKey,
backends: []Backend{kl, oc},
backends: []Backend{km, kl, oc},
defaultBackend: oc,
sessions: make(map[string]*session),
health: make(map[string]*modelHealth),
}
go p.discoveryLoop()
go p.healthLoop()
return p
}
@@ -142,6 +152,40 @@ func randomHex(n int) string {
return hex.EncodeToString(b)
}
// ---------- Model discovery ----------
// discoveryLoop runs an initial full refresh on startup, then re-fetches each
// backend's upstream /models catalogue every discoveryInterval. Backends that
// do not implement Discoverer are skipped (they stay on their curated list).
func (p *Proxy) discoveryLoop() {
p.refreshAllBackends()
ticker := time.NewTicker(discoveryInterval)
defer ticker.Stop()
for range ticker.C {
p.refreshAllBackends()
}
}
// refreshAllBackends calls Refresh() on every backend that implements
// Discoverer. Failures are logged but do not stop the loop; a failed refresh
// leaves the backend on its previous (curated or last-good) catalogue.
func (p *Proxy) refreshAllBackends() {
var names []string
for _, b := range p.backends {
d, ok := b.(Discoverer)
if !ok {
continue
}
if err := d.Refresh(); err != nil {
fmt.Printf("[discovery] backend '%s' refresh failed: %v\n", b.Name(), err)
continue
}
names = append(names, b.Name())
}
total := len(p.Models())
fmt.Printf("[discovery] refresh complete: %d backends, %d models\n", len(names), total)
}
// ---------- Model health / rate-limit tracking ----------
// markRateLimited cages a model so it disappears from /v1/models until it recovers.
+151 -10
View File
@@ -1,12 +1,32 @@
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.
type OpenCodeBackend struct{}
//
// 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 are the free models exposed by OpenCode Zen (no API key required).
// zenModels is the curated base layer: full metadata + alias anchors.
var zenModels = []ModelInfo{
{
ID: "big-pickle",
@@ -88,13 +108,20 @@ var modelAliases = map[string]string{
}
// Name returns the backend identifier.
func (OpenCodeBackend) Name() string { return "opencode" }
func (*OpenCodeBackend) Name() string { return "opencode" }
// Models returns the OpenCode Zen free models.
func (OpenCodeBackend) Models() []ModelInfo { return zenModels }
// 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 (OpenCodeBackend) Resolve(model string) (string, bool) {
func (b *OpenCodeBackend) Resolve(model string) (string, bool) {
if mapped, ok := modelAliases[model]; ok {
return mapped, true
}
@@ -103,14 +130,21 @@ func (OpenCodeBackend) Resolve(model string) (string, bool) {
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" }
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 {
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",
@@ -123,14 +157,121 @@ func (OpenCodeBackend) Headers(requestID, sessionID string) map[string]string {
}
}
// ---------- 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.
func ModelByID(id string) *ModelInfo {
// 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
}