Files
renato97 0a4289cafc Initial commit: OpenCode Zen Proxy
Proxy translating OpenAI/Anthropic Messages API to OpenCode Zen's free models.
Pure stdlib Go 1.22, no external dependencies.

- OpenAI Chat Completions (/v1/chat/completions) with streaming
- Anthropic Messages API (/v1/messages) with streaming
- Model aliases for 5 free models
- Auth middleware support
- Session rotation (30min per user key)
2026-06-25 11:49:11 -03:00

309 lines
8.2 KiB
Go

package server
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"opencode-proxy/internal/proxy"
)
const defaultPort = "6446"
const defaultHost = "127.0.0.1"
// Server is the HTTP server that exposes OpenAI-compatible endpoints.
type Server struct {
proxy *proxy.Proxy
port string
host string
mux *http.ServeMux
}
// NewServer creates a new Server with the given proxy.
func NewServer(p *proxy.Proxy) *Server {
s := &Server{
proxy: p,
port: defaultPort,
host: defaultHost,
mux: http.NewServeMux(),
}
s.routes()
return s
}
// SetPort sets the server port.
func (s *Server) SetPort(port string) { s.port = port }
// SetHost sets the server host.
func (s *Server) SetHost(host string) { s.host = host }
// Port returns the current port.
func (s *Server) Port() string { return s.port }
// Host returns the current host.
func (s *Server) Host() string { return s.host }
func (s *Server) routes() {
// Catch-all logger first — logs every request we receive
s.mux.HandleFunc("/", s.logAll)
s.mux.HandleFunc("GET /health", s.handleHealth)
s.mux.HandleFunc("GET /v1/models", s.handleModels)
s.mux.HandleFunc("GET /v1/models/{id}", s.handleModelByID)
s.mux.HandleFunc("POST /v1/chat/completions", s.handleChatCompletions)
s.mux.HandleFunc("POST /v1/messages", s.handleMessages)
// Claude Code appends /v1/messages to ANTHROPIC_BASE_URL automatically.
// If the user sets ANTHROPIC_BASE_URL=http://...:6446/v1, we get /v1/v1/messages.
// Fix: also handle the double /v1/v1/messages path.
s.mux.HandleFunc("POST /v1/v1/messages", s.handleMessages)
}
// logAll captures unmatched requests so we can see what Claude Code is actually hitting.
func (s *Server) logAll(w http.ResponseWriter, r *http.Request) {
fmt.Printf("[CATCH-ALL] %s %s | Headers: ", r.Method, r.URL.Path)
for k, v := range r.Header {
fmt.Printf("%s=%q ", k, v[0])
}
fmt.Println()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"error":{"message":"not found: %s %s","type":"not_found"}}`, r.Method, r.URL.Path)
}
// Start begins listening on the configured host:port.
func (s *Server) Start() error {
addr := fmt.Sprintf("%s:%s", s.host, s.port)
fmt.Printf("Listening on http://%s\n", addr)
return http.ListenAndServe(addr, s.mux)
}
// ---------- Auth middleware ----------
// requireAuth returns true if the request has a valid API key.
// If no API key is configured on the proxy, all requests are allowed.
func (s *Server) requireAuth(w http.ResponseWriter, r *http.Request) bool {
key := s.proxy.APIKey()
if key == "" {
return true // no auth required
}
// Check Authorization: Bearer <key>
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
if strings.TrimPrefix(authHeader, "Bearer ") == key {
return true
}
}
// Check x-api-key header
if r.Header.Get("x-api-key") == key {
return true
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(proxy.OpenAIError{
Error: proxy.OpenAIErrorDetail{
Message: "Invalid or missing API key. Use Authorization: Bearer <key> or x-api-key header.",
Type: "authentication_error",
Code: "invalid_api_key",
},
})
return false
}
// ---------- Endpoints ----------
// GET /health
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"version": "1.0.0",
"models": len(s.proxy.Models()),
"endpoints": []string{"/v1/chat/completions", "/v1/messages", "/v1/models", "/v1/models/{id}", "/health"},
"auth_required": s.proxy.APIKey() != "",
})
}
// GET /v1/models
func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
if !s.requireAuth(w, r) {
return
}
type modelEntry struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
ContextWindow int `json:"context_window"`
MaxOutputTokens int `json:"max_output_tokens"`
Description string `json:"description"`
}
models := s.proxy.Models()
entries := make([]modelEntry, len(models))
for i, m := range models {
entries[i] = modelEntry{
ID: m.ID,
Object: "model",
Created: 1779000000,
OwnedBy: m.OwnedBy,
ContextWindow: m.ContextWindow,
MaxOutputTokens: m.MaxOutputTokens,
Description: m.Description,
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"object": "list",
"data": entries,
})
}
// GET /v1/models/{id}
func (s *Server) handleModelByID(w http.ResponseWriter, r *http.Request) {
if !s.requireAuth(w, r) {
return
}
id := r.PathValue("id")
for _, m := range s.proxy.Models() {
if m.ID == id {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"id": m.ID,
"object": "model",
"created": 1779000000,
"owned_by": m.OwnedBy,
"context_window": m.ContextWindow,
"max_output_tokens": m.MaxOutputTokens,
"description": m.Description,
})
return
}
}
writeError(w, http.StatusNotFound, fmt.Sprintf("Model '%s' not found", id), "model_not_found")
}
// POST /v1/chat/completions
func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
logRequest(r, "chat")
if !s.requireAuth(w, r) {
return
}
// Read the full body
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "Failed to read request body", "invalid_request")
return
}
defer r.Body.Close()
logBody("chat", body)
if len(body) == 0 {
writeError(w, http.StatusBadRequest, "Empty request body", "invalid_request")
return
}
// Forward to upstream
if err := s.proxy.ForwardChatCompletion(w, r, body); err != nil {
fmt.Printf("[server] error forwarding request: %v\n", err)
writeError(w, http.StatusInternalServerError, err.Error(), "proxy_error")
}
}
// POST /v1/messages — Anthropic Messages API
func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
logRequest(r, "anthropic")
if !s.requireAuth(w, r) {
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "Failed to read request body", "invalid_request")
return
}
defer r.Body.Close()
logBody("anthropic", body)
if len(body) == 0 {
writeError(w, http.StatusBadRequest, "Empty request body", "invalid_request")
return
}
// Check for Anthropic version header
av := r.Header.Get("anthropic-version")
if av != "" {
fmt.Printf("[anthropic] version: %s\n", av)
}
if err := s.proxy.ForwardAnthropicMessages(w, r, body); err != nil {
fmt.Printf("[server] error forwarding anthropic request: %v\n", err)
writeError(w, http.StatusInternalServerError, err.Error(), "proxy_error")
}
}
func logRequest(r *http.Request, kind string) {
fmt.Printf("[%s] %s %s | Auth=%s | ContentType=%s\n",
kind, r.Method, r.URL.Path,
truncate(r.Header.Get("Authorization"), 30),
r.Header.Get("Content-Type"))
}
func logBody(kind string, body []byte) {
if len(body) > 500 {
fmt.Printf("[%s] body (%d bytes): %s...\n", kind, len(body), string(body[:500]))
} else {
fmt.Printf("[%s] body: %s\n", kind, string(body))
}
}
func truncate(s string, maxLen int) string {
if len(s) > maxLen {
return s[:maxLen] + "..."
}
return s
}
// ---------- Helpers ----------
func writeError(w http.ResponseWriter, status int, message, errType string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(proxy.OpenAIError{
Error: proxy.OpenAIErrorDetail{
Message: message,
Type: errType,
},
})
}
func writeMethodNotAllowed(w http.ResponseWriter, allowed string) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Allow", allowed)
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(proxy.OpenAIError{
Error: proxy.OpenAIErrorDetail{
Message: fmt.Sprintf("Method not allowed. Use %s.", allowed),
Type: "invalid_request_error",
},
})
}
// init sets timezone for consistent timestamps
func init() {
time.Local = time.UTC
}