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)
309 lines
8.2 KiB
Go
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
|
|
}
|