- 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
258 lines
7.4 KiB
Go
258 lines
7.4 KiB
Go
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).
|
|
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 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
|
|
}
|
|
|
|
// 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)",
|
|
OwnedBy: "stepfun",
|
|
ContextWindow: 256_000,
|
|
MaxOutputTokens: 32_000,
|
|
Description: "StepFun. Flash rápido, tareas generales.",
|
|
},
|
|
{
|
|
ID: "poolside/laguna-m.1:free",
|
|
Name: "Poolside Laguna M.1 (Free)",
|
|
OwnedBy: "poolside",
|
|
ContextWindow: 256_000,
|
|
MaxOutputTokens: 32_000,
|
|
Description: "Poolside. Modelo orientado a código.",
|
|
},
|
|
{
|
|
ID: "nvidia/nemotron-3-ultra-550b-a55b:free",
|
|
Name: "Nemotron 3 Ultra 550B (Free)",
|
|
OwnedBy: "nvidia",
|
|
ContextWindow: 1_000_000,
|
|
MaxOutputTokens: 16_384,
|
|
Description: "NVIDIA Nemotron 3 Ultra (550B/55B MoE). 1M contexto, razonamiento.",
|
|
},
|
|
{
|
|
ID: "openrouter/free",
|
|
Name: "OpenRouter Best Free (Free)",
|
|
OwnedBy: "openrouter",
|
|
ContextWindow: 256_000,
|
|
MaxOutputTokens: 32_000,
|
|
Description: "OpenRouter selecciona automáticamente el mejor modelo free disponible.",
|
|
},
|
|
}
|
|
|
|
// kiloAliases lets clients use shorter names for Kilo free models.
|
|
var kiloAliases = map[string]string{
|
|
"stepfun": "stepfun/step-3.7-flash:free",
|
|
"stepfun-free": "stepfun/step-3.7-flash:free",
|
|
"poolside": "poolside/laguna-m.1:free",
|
|
"poolside-free": "poolside/laguna-m.1:free",
|
|
"laguna": "poolside/laguna-m.1:free",
|
|
"openrouter": "openrouter/free",
|
|
}
|
|
|
|
// Name returns the backend identifier.
|
|
func (*KiloBackend) Name() string { return "kilo" }
|
|
|
|
// 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 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 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
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// ChatURL is the Kilo gateway chat completions endpoint.
|
|
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 {
|
|
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"
|
|
}
|