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:
+151
-10
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user