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