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)
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user