feat: multi-backend proxy with auto rate-limit jailing

Refactor from monolithic proxy to multi-backend architecture
(Backend interface: OpenCode Zen + Kilo gateway).

- Add auto rate-limit jailing: models returning HTTP 429 are
  hidden from /v1/models immediately and re-probed every 30 min
- Backend interface supports model aliases, custom headers, and
  per-backend routing
- Full Anthropic Messages API support with OpenAI format conversion
- 9 free models across two backends with Claude name aliases
This commit is contained in:
Renato
2026-06-26 19:08:01 +02:00
parent 0a4289cafc
commit 05a52d29e2
9 changed files with 652 additions and 245 deletions
+16 -9
View File
@@ -652,15 +652,19 @@ func (p *Proxy) ForwardAnthropicMessages(w http.ResponseWriter, r *http.Request,
return fmt.Errorf("invalid Anthropic request: %w", err)
}
// Resolve model
resolved, _ := p.ResolveModel(ar.Model)
// Route to the backend that owns this model (default = pass-through).
backend, resolved, known := p.resolveModel(ar.Model)
if !known {
resolved = ar.Model
}
// Keep original client-requested model name for response rewriting.
// Claude Code validates that the response model matches the request model.
clientModel := ar.Model
// Auto-route to vision model if the request has images and resolved model can't handle them
// Auto-route to a vision model only inside the opencode backend (which owns
// a vision-capable free model). Kilo models are forwarded as-is.
hasImages := requestHasImages(&ar)
if hasImages {
if hasImages && backend.Name() == "opencode" {
mi := ModelByID(resolved)
if mi == nil || !mi.SupportsVision {
fmt.Printf("[anthropic] model=%s lacks vision, auto-routing to mimo-v2.5-free\n", resolved)
@@ -668,8 +672,8 @@ func (p *Proxy) ForwardAnthropicMessages(w http.ResponseWriter, r *http.Request,
}
}
fmt.Printf("[anthropic] model=%s → %s, max_tokens=%d, stream=%v, messages=%d, tools=%d\n",
clientModel, resolved, ar.MaxTokens, ar.Stream, len(ar.Messages), len(ar.Tools))
fmt.Printf("[anthropic] model=%s → %s via %s, max_tokens=%d, stream=%v, messages=%d, tools=%d\n",
clientModel, resolved, backend.Name(), ar.MaxTokens, ar.Stream, len(ar.Messages), len(ar.Tools))
// Convert Anthropic → OpenAI
oaReq := AnthropicToOpenAI(&ar)
@@ -686,13 +690,13 @@ func (p *Proxy) ForwardAnthropicMessages(w http.ResponseWriter, r *http.Request,
requestID := RequestID()
sessionID := p.SessionID("anthropic")
// Build upstream request
upstreamReq, err := http.NewRequestWithContext(r.Context(), "POST", ZenChatURL(), bytes.NewReader(oaBody))
// Build upstream request to the chosen backend
upstreamReq, err := http.NewRequestWithContext(r.Context(), "POST", backend.ChatURL(), bytes.NewReader(oaBody))
if err != nil {
return fmt.Errorf("failed to create upstream request: %w", err)
}
for k, v := range UpstreamHeaders(requestID, sessionID) {
for k, v := range backend.Headers(requestID, sessionID) {
upstreamReq.Header.Set(k, v)
}
@@ -709,6 +713,9 @@ func (p *Proxy) ForwardAnthropicMessages(w http.ResponseWriter, r *http.Request,
if resp.StatusCode != 200 {
bodyBytes, _ := io.ReadAll(resp.Body)
fmt.Printf("[anthropic] upstream error body (%d bytes): %s\n", len(bodyBytes), string(bodyBytes))
if resp.StatusCode == http.StatusTooManyRequests {
p.markRateLimited(resolved)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
json.NewEncoder(w).Encode(AnthropicError{
+27
View File
@@ -0,0 +1,27 @@
package proxy
// Backend represents one upstream free-model provider (opencode zen, kilo, mimo, ...).
//
// Each backend knows its own model catalogue, how to resolve a requested model
// name to the upstream ID, and the URL + headers required to forward a chat
// completion request to it. The Proxy holds an ordered list of backends and
// routes each request to the first one that recognises the model.
type Backend interface {
// Name is a short stable identifier ("opencode", "kilo").
Name() string
// Models returns the free models this backend exposes, for /v1/models listing.
Models() []ModelInfo
// Resolve maps a client-requested model name to the upstream model ID.
// Returns ok=false when this backend does not recognise the model, so the
// router can try the next backend (or fall back to pass-through).
Resolve(model string) (resolved string, ok bool)
// ChatURL is the full upstream chat completions URL.
ChatURL() string
// Headers returns the headers required to authenticate/identify the
// request to the upstream (auth, client-id, request/session tracking, etc.).
Headers(requestID, sessionID string) map[string]string
}
+91
View File
@@ -0,0 +1,91 @@
package proxy
// 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 a handful of free models
// (suffixed ":free") reachable without authentication, in standard OpenAI
// Chat Completions format.
type KiloBackend struct{}
// kiloFreeModels are the free models advertised by the Kilo gateway.
// Sourced from GET /api/gateway/models filtering isFree:true.
var kiloFreeModels = []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.
func (KiloBackend) Models() []ModelInfo { return kiloFreeModels }
// Resolve maps a requested name to a Kilo free model ID.
func (KiloBackend) Resolve(model string) (string, bool) {
if mapped, ok := kiloAliases[model]; ok {
return mapped, true
}
for _, m := range kiloFreeModels {
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",
}
}
+229 -129
View File
@@ -1,98 +1,16 @@
package proxy
import (
"bytes"
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"sync"
"time"
)
// ZenBaseURL is the upstream OpenCode Zen API base URL.
const ZenBaseURL = "https://opencode.ai/zen/v1"
// Free models available via OpenCode Zen (no API key required).
// These are the 5 models currently offered for free.
var zenModels = []ModelInfo{
{
ID: "big-pickle",
Name: "Big Pickle (Free)",
OwnedBy: "opencode-zen",
ContextWindow: 200_000,
MaxOutputTokens: 32_000,
Description: "Permanentemente gratis. Código legacy, refactors, mantenimiento.",
SupportsVision: false,
},
{
ID: "deepseek-v4-flash-free",
Name: "DeepSeek V4 Flash (Free)",
OwnedBy: "deepseek",
ContextWindow: 1_000_000,
MaxOutputTokens: 384_000,
Description: "El todoterreno. 1M contexto, 284B params (13B activos), razonamiento.",
SupportsVision: false,
},
{
ID: "mimo-v2.5-free",
Name: "MiMo V2.5 (Free)",
OwnedBy: "xiaomi",
ContextWindow: 1_000_000,
MaxOutputTokens: 32_000,
Description: "Multimodal de Xiaomi. 1M contexto, imágenes → código. MIT license.",
SupportsVision: true,
},
{
ID: "north-mini-code-free",
Name: "North Mini Code (Free)",
OwnedBy: "cohere",
ContextWindow: 256_000,
MaxOutputTokens: 64_000,
Description: "Cohere. 30B total / 3B activos (MoE). Respuesta rápida, scripts.",
SupportsVision: false,
},
{
ID: "nemotron-3-ultra-free",
Name: "Nemotron 3 Ultra (Free)",
OwnedBy: "nvidia",
ContextWindow: 1_000_000,
MaxOutputTokens: 16_384,
Description: "NVIDIA. 550B params (55B activos). Mamba-2 + MoE híbrido. Razonamiento.",
SupportsVision: false,
},
}
// Model aliases: short name → full model ID.
var modelAliases = map[string]string{
// Short aliases
"pickle": "big-pickle",
"big-pickle": "big-pickle",
"deepseek": "deepseek-v4-flash-free",
"deepseek-v4": "deepseek-v4-flash-free",
"ds": "deepseek-v4-flash-free",
"mimo": "mimo-v2.5-free",
"mimo-v2.5": "mimo-v2.5-free",
"xiaomi": "mimo-v2.5-free",
"north": "north-mini-code-free",
"north-mini": "north-mini-code-free",
"cohere": "north-mini-code-free",
"nemotron": "nemotron-3-ultra-free",
"nemotron-3": "nemotron-3-ultra-free",
"nvidia": "nemotron-3-ultra-free",
// Claude model name aliases (for Claude Code compatibility)
// Claude Code validates model names client-side; set ANTHROPIC_MODEL to a Claude name.
"claude-sonnet-4-6": "deepseek-v4-flash-free",
"claude-sonnet-4-5": "deepseek-v4-flash-free",
"claude-sonnet-4": "deepseek-v4-flash-free",
"claude-opus-4-8": "deepseek-v4-flash-free",
"claude-opus-4-5": "deepseek-v4-flash-free",
"claude-opus-4": "deepseek-v4-flash-free",
"claude-haiku-4-5": "north-mini-code-free",
"claude-haiku-4": "north-mini-code-free",
"claude-3.5-sonnet": "north-mini-code-free",
"claude-3.5-haiku": "big-pickle",
}
// ModelInfo describes a single model.
// ModelInfo describes a single model exposed by a backend.
type ModelInfo struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
@@ -103,11 +21,31 @@ type ModelInfo struct {
SupportsVision bool `json:"supports_vision"`
}
// Proxy handles forwarding OpenAI-format requests to OpenCode Zen.
// healthCheckInterval is how often caged models get re-probed.
const healthCheckInterval = 30 * time.Minute
// healthPingTimeout caps each health probe. Set high enough for slow reasoning
// models (nvidia/nemotron can take ~80s to first token).
const healthPingTimeout = 90 * time.Second
// modelHealth tracks the rate-limit state of a single model.
type modelHealth struct {
status string // "active" | "rate_limited"
lastCheck time.Time
}
// Proxy routes OpenAI-format requests to the right Backend and manages session IDs.
type Proxy struct {
apiKey string
sessions map[string]*session // keyed by client-provided API key
apiKey string
backends []Backend
defaultBackend Backend
sessions map[string]*session
mu sync.Mutex
health map[string]*modelHealth
healthMu sync.RWMutex
}
type session struct {
@@ -115,63 +53,74 @@ type session struct {
expiresAt time.Time
}
// NewProxy creates a new Proxy instance.
// NewProxy creates a Proxy with the default set of free-model backends.
//
// Order matters: more specific backends come first so their model IDs win over
// the catch-all opencode aliases. The default backend is the fallback for
// unknown models (pass-through as-is).
func NewProxy(apiKey string) *Proxy {
return &Proxy{
apiKey: apiKey,
sessions: make(map[string]*session),
oc := OpenCodeBackend{}
kl := KiloBackend{}
p := &Proxy{
apiKey: apiKey,
backends: []Backend{kl, oc},
defaultBackend: oc,
sessions: make(map[string]*session),
health: make(map[string]*modelHealth),
}
go p.healthLoop()
return p
}
// APIKey returns the configured API key (empty = no auth).
// APIKey returns the configured proxy API key (empty = no auth).
func (p *Proxy) APIKey() string { return p.apiKey }
// Models returns the list of available free models.
func (p *Proxy) Models() []ModelInfo { return zenModels }
// ModelByID returns the ModelInfo entry for a given model ID, or nil if not found.
func ModelByID(id string) *ModelInfo {
for _, m := range zenModels {
if m.ID == id {
return &m
// Models returns the aggregated free-model catalogue across all backends,
// excluding any model currently caged for rate limiting (HTTP 429).
func (p *Proxy) Models() []ModelInfo {
var all []ModelInfo
for _, b := range p.backends {
for _, m := range b.Models() {
if !p.isRateLimited(m.ID) {
all = append(all, m)
}
}
}
return nil
return all
}
// ResolveModel maps a requested model name to a Zen model ID.
// Returns the resolved model ID and whether it was found.
// BackendFor returns the backend that recognises the model, or the default
// backend (pass-through) when no backend claims it.
func (p *Proxy) BackendFor(model string) Backend {
b, _, _ := p.resolveModel(model)
return b
}
// resolveModel walks the backends in order and returns the first match.
// When nothing matches it returns the default backend with the model unchanged
// and ok=false, so callers can forward it as-is (pass-through).
func (p *Proxy) resolveModel(model string) (backend Backend, resolved string, ok bool) {
for _, b := range p.backends {
if r, matched := b.Resolve(model); matched {
return b, r, true
}
}
return p.defaultBackend, model, false
}
// ResolveModel maps a requested model name to its upstream ID (backend-aware).
// Kept for backwards compatibility with existing call sites.
func (p *Proxy) ResolveModel(requested string) (string, bool) {
// Check aliases first (covers both short names and exact IDs)
if mapped, ok := modelAliases[requested]; ok {
return mapped, true
}
// Check if it's already a valid model ID (belt and suspenders)
for _, m := range zenModels {
if m.ID == requested {
return requested, true
}
}
// Pass through unknown models (might be newly added upstream)
return requested, false
_, r, ok := p.resolveModel(requested)
return r, ok
}
// ZenChatURL returns the full upstream URL for chat completions.
func ZenChatURL() string {
return ZenBaseURL + "/chat/completions"
}
// ZenModelsURL returns the full upstream URL for model listing.
func ZenModelsURL() string {
return ZenBaseURL + "/models"
}
// SessionID returns a session ID for the given user key, rotating every 30 minutes.
// SessionID returns a session ID for the given user key, rotating every 30 min.
func (p *Proxy) SessionID(userKey string) string {
p.mu.Lock()
defer p.mu.Unlock()
if s, ok := p.sessions[userKey]; ok && time.Now().Before(s.expiresAt) {
if s, exists := p.sessions[userKey]; exists && time.Now().Before(s.expiresAt) {
return s.id
}
@@ -192,3 +141,154 @@ func randomHex(n int) string {
rand.Read(b)
return hex.EncodeToString(b)
}
// ---------- Model health / rate-limit tracking ----------
// markRateLimited cages a model so it disappears from /v1/models until it recovers.
// Only logs the transition to avoid spam when many 429s arrive in a row.
func (p *Proxy) markRateLimited(modelID string) {
p.healthMu.Lock()
defer p.healthMu.Unlock()
h := p.health[modelID]
if h == nil {
h = &modelHealth{}
p.health[modelID] = h
}
if h.status != "rate_limited" {
fmt.Printf("[health] caging model '%s' (rate limited)\n", modelID)
}
h.status = "rate_limited"
h.lastCheck = time.Now()
}
// markActive restores a caged model so it reappears in /v1/models.
func (p *Proxy) markActive(modelID string) {
p.healthMu.Lock()
defer p.healthMu.Unlock()
h := p.health[modelID]
if h == nil {
h = &modelHealth{}
p.health[modelID] = h
}
h.status = "active"
h.lastCheck = time.Now()
}
// isRateLimited reports whether a model is currently caged for rate limiting.
func (p *Proxy) isRateLimited(modelID string) bool {
p.healthMu.RLock()
defer p.healthMu.RUnlock()
h, ok := p.health[modelID]
return ok && h.status == "rate_limited"
}
// healthLoop runs an initial full scan on startup, then re-probes caged models
// every healthCheckInterval (30 min). Released models reappear in /v1/models.
func (p *Proxy) healthLoop() {
p.scanAllModels()
ticker := time.NewTicker(healthCheckInterval)
defer ticker.Stop()
for range ticker.C {
p.recheckCagedModels()
}
}
// scanAllModels probes every known model once. Models returning non-200 (429)
// are caged; the rest stay active. Used for the startup scan.
func (p *Proxy) scanAllModels() {
var ids []string
for _, b := range p.backends {
for _, m := range b.Models() {
ids = append(ids, m.ID)
}
}
fmt.Printf("[health] initial scan: probing %d models\n", len(ids))
for _, id := range ids {
ok := p.pingModel(id)
p.healthMu.Lock()
h := p.health[id]
if h == nil {
h = &modelHealth{}
p.health[id] = h
}
h.lastCheck = time.Now()
if ok {
h.status = "active"
} else {
h.status = "rate_limited"
fmt.Printf("[health] caging model '%s' (rate limited on initial scan)\n", id)
}
p.healthMu.Unlock()
}
active := 0
p.healthMu.RLock()
for _, h := range p.health {
if h.status == "active" {
active++
}
}
p.healthMu.RUnlock()
fmt.Printf("[health] initial scan complete: %d active, %d caged\n", active, len(ids)-active)
}
// recheckCagedModels re-probes only the models currently caged. A 200 releases
// them; a 429 or timeout keeps them caged for another interval.
func (p *Proxy) recheckCagedModels() {
p.healthMu.RLock()
var caged []string
for id, h := range p.health {
if h.status == "rate_limited" {
caged = append(caged, id)
}
}
p.healthMu.RUnlock()
if len(caged) == 0 {
return
}
fmt.Printf("[health] re-checking %d caged models\n", len(caged))
for _, id := range caged {
ok := p.pingModel(id)
p.healthMu.Lock()
h := p.health[id]
if h == nil {
h = &modelHealth{}
p.health[id] = h
}
h.lastCheck = time.Now()
if ok {
h.status = "active"
fmt.Printf("[health] releasing model '%s' (recovered)\n", id)
} else {
fmt.Printf("[health] model '%s' still rate limited, retry in 30m\n", id)
}
p.healthMu.Unlock()
}
}
// pingModel sends a minimal chat completion to the model's backend and reports
// whether the upstream answered HTTP 200. Timeouts and non-200 count as
// unhealthy so the model stays caged (conservative).
func (p *Proxy) pingModel(modelID string) bool {
backend, resolved, known := p.resolveModel(modelID)
if !known {
resolved = modelID
}
body := fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"OK"}],"max_tokens":5,"temperature":0}`, resolved)
req, err := http.NewRequest("POST", backend.ChatURL(), bytes.NewReader([]byte(body)))
if err != nil {
return false
}
for k, v := range backend.Headers(RequestID(), "ses_health") {
req.Header.Set(k, v)
}
client := &http.Client{Timeout: healthPingTimeout}
resp, err := client.Do(req)
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == 200
}
+142
View File
@@ -0,0 +1,142 @@
package proxy
// 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{}
// zenModels are the free models exposed by OpenCode Zen (no API key required).
var zenModels = []ModelInfo{
{
ID: "big-pickle",
Name: "Big Pickle (Free)",
OwnedBy: "opencode-zen",
ContextWindow: 200_000,
MaxOutputTokens: 32_000,
Description: "Permanentemente gratis. Código legacy, refactors, mantenimiento.",
},
{
ID: "deepseek-v4-flash-free",
Name: "DeepSeek V4 Flash (Free)",
OwnedBy: "deepseek",
ContextWindow: 1_000_000,
MaxOutputTokens: 384_000,
Description: "El todoterreno. 1M contexto, 284B params (13B activos), razonamiento.",
},
{
ID: "mimo-v2.5-free",
Name: "MiMo V2.5 (Free)",
OwnedBy: "xiaomi",
ContextWindow: 1_000_000,
MaxOutputTokens: 32_000,
Description: "Multimodal de Xiaomi. 1M contexto, imágenes → código. MIT license.",
SupportsVision: true,
},
{
ID: "north-mini-code-free",
Name: "North Mini Code (Free)",
OwnedBy: "cohere",
ContextWindow: 256_000,
MaxOutputTokens: 64_000,
Description: "Cohere. 30B total / 3B activos (MoE). Respuesta rápida, scripts.",
},
{
ID: "nemotron-3-ultra-free",
Name: "Nemotron 3 Ultra (Free)",
OwnedBy: "nvidia",
ContextWindow: 1_000_000,
MaxOutputTokens: 16_384,
Description: "NVIDIA. 550B params (55B activos). Mamba-2 + MoE híbrido. Razonamiento.",
},
}
// modelAliases maps short/company/Claude names to OpenCode Zen model IDs.
var modelAliases = map[string]string{
// Short aliases
"pickle": "big-pickle",
"big-pickle": "big-pickle",
"deepseek": "deepseek-v4-flash-free",
"deepseek-v4": "deepseek-v4-flash-free",
"ds": "deepseek-v4-flash-free",
"mimo": "mimo-v2.5-free",
"mimo-v2.5": "mimo-v2.5-free",
"xiaomi": "mimo-v2.5-free",
"north": "north-mini-code-free",
"north-mini": "north-mini-code-free",
"cohere": "north-mini-code-free",
"nemotron": "nemotron-3-ultra-free",
"nemotron-3": "nemotron-3-ultra-free",
"nvidia": "nemotron-3-ultra-free",
// Exact IDs
"deepseek-v4-flash-free": "deepseek-v4-flash-free",
"mimo-v2.5-free": "mimo-v2.5-free",
"north-mini-code-free": "north-mini-code-free",
"nemotron-3-ultra-free": "nemotron-3-ultra-free",
// Claude model name aliases (Claude Code validates model names client-side).
"claude-sonnet-4-6": "deepseek-v4-flash-free",
"claude-sonnet-4-5": "deepseek-v4-flash-free",
"claude-sonnet-4": "deepseek-v4-flash-free",
"claude-opus-4-8": "deepseek-v4-flash-free",
"claude-opus-4-5": "deepseek-v4-flash-free",
"claude-opus-4": "deepseek-v4-flash-free",
"claude-haiku-4-5": "north-mini-code-free",
"claude-haiku-4": "north-mini-code-free",
"claude-3.5-sonnet": "north-mini-code-free",
"claude-3.5-haiku": "big-pickle",
}
// Name returns the backend identifier.
func (OpenCodeBackend) Name() string { return "opencode" }
// Models returns the OpenCode Zen free models.
func (OpenCodeBackend) Models() []ModelInfo { return zenModels }
// Resolve maps a requested name to an OpenCode Zen model ID.
func (OpenCodeBackend) Resolve(model string) (string, bool) {
if mapped, ok := modelAliases[model]; ok {
return mapped, true
}
for _, m := range zenModels {
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" }
// Headers builds the headers OpenCode Zen expects (x-opencode-* + public bearer).
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",
"x-opencode-client": "cli",
"x-opencode-project": "global",
"x-opencode-request": requestID,
"x-opencode-session": sessionID,
"Content-Type": "application/json",
"Accept": "text/event-stream, application/json",
}
}
// 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 {
for _, m := range zenModels {
if m.ID == id {
return &m
}
}
return nil
}
// ZenChatURL returns the OpenCode Zen chat completions URL.
// Kept for backwards compatibility with existing call sites.
func ZenChatURL() string { return ZenBaseURL + "/chat/completions" }
// ZenModelsURL returns the OpenCode Zen models listing URL.
func ZenModelsURL() string { return ZenBaseURL + "/models" }
+16 -24
View File
@@ -104,32 +104,20 @@ type OpenAIErrorDetail struct {
// ---------- Proxy forwarding logic ----------
// UpstreamHeaders builds the required headers for OpenCode Zen.
func UpstreamHeaders(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",
"x-opencode-client": "cli",
"x-opencode-project": "global",
"x-opencode-request": requestID,
"x-opencode-session": sessionID,
"Content-Type": "application/json",
"Accept": "text/event-stream, application/json",
}
}
// ForwardChatCompletion sends the OpenAI request to OpenCode Zen and streams or collects the response.
// ForwardChatCompletion routes the OpenAI request to the matching backend and
// streams or collects the response.
func (p *Proxy) ForwardChatCompletion(w http.ResponseWriter, r *http.Request, body []byte) error {
var req OpenAIChatRequest
if err := json.Unmarshal(body, &req); err != nil {
return fmt.Errorf("invalid request body: %w", err)
}
// Resolve model name
resolved, ok := p.ResolveModel(req.Model)
if !ok {
// Unknown model — log but still forward (might be a new model)
fmt.Printf("[proxy] unknown model '%s', forwarding as-is\n", req.Model)
// Route to the backend that owns this model (default backend = pass-through).
backend, resolved, known := p.resolveModel(req.Model)
if !known {
fmt.Printf("[proxy] unknown model '%s', forwarding as-is via %s\n", req.Model, backend.Name())
} else {
fmt.Printf("[proxy] model '%s' -> '%s' via %s\n", req.Model, resolved, backend.Name())
}
// Update the body with resolved model
@@ -144,7 +132,7 @@ func (p *Proxy) ForwardChatCompletion(w http.ResponseWriter, r *http.Request, bo
// Generate request/session IDs for upstream
requestID := RequestID()
// Get session ID from the client's proxy API key (or default)
// Session ID keyed by the client's proxy API key (or default)
userKey := "default"
if p.apiKey != "" {
userKey = r.Header.Get("Authorization")
@@ -154,13 +142,13 @@ func (p *Proxy) ForwardChatCompletion(w http.ResponseWriter, r *http.Request, bo
}
sessionID := p.SessionID(userKey)
// Build upstream request
upstreamReq, err := http.NewRequestWithContext(r.Context(), "POST", ZenChatURL(), bytes.NewReader(body))
// Build upstream request to the chosen backend
upstreamReq, err := http.NewRequestWithContext(r.Context(), "POST", backend.ChatURL(), bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create upstream request: %w", err)
}
for k, v := range UpstreamHeaders(requestID, sessionID) {
for k, v := range backend.Headers(requestID, sessionID) {
upstreamReq.Header.Set(k, v)
}
@@ -174,6 +162,10 @@ func (p *Proxy) ForwardChatCompletion(w http.ResponseWriter, r *http.Request, bo
// Handle non-200 responses
if resp.StatusCode != 200 {
fmt.Printf("[proxy] upstream status=%d for model='%s' via %s\n", resp.StatusCode, resolved, backend.Name())
if resp.StatusCode == http.StatusTooManyRequests {
p.markRateLimited(resolved)
}
bodyBytes, _ := io.ReadAll(resp.Body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)