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