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{