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