From 58199ab312cc983deccc2534b201348280b00205 Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 3 Oct 2025 09:05:24 -0500 Subject: [PATCH 01/10] Optimize Docker, server, and middleware for performance Improves Dockerfile, docker-compose.yml, and .dockerignore for better containerization and config mounting. Refactors config paths and permissions for Docker compatibility. Adds gzip compression middleware, request coalescing cache with TTL, and Prometheus-style metrics endpoint. Optimizes HTTP client pooling, worker pool sizing, and server TLS/HTTP2 settings for higher performance and resource efficiency. Updates related tests and minor code cleanups. --- .dockerignore | 27 +++++--- Dockerfile | 6 +- docker-compose.yml | 29 ++++++-- internal/auth.go | 1 - internal/cli.go | 4 +- internal/cli_test.go | 2 +- internal/config.go | 46 ++++++++----- internal/errors.go | 2 +- internal/errors_test.go | 2 +- internal/health.go | 4 +- internal/logger.go | 7 +- internal/middleware.go | 82 ++++++++++++++++++++++- internal/models.go | 8 +-- internal/proxy.go | 100 +++++++++++++++++++++------- internal/server.go | 133 +++++++++++++++++++++++++++++++++++-- internal/server_test.go | 8 +-- pkg/transform/transform.go | 2 +- test/testutils/helpers.go | 4 +- 18 files changed, 375 insertions(+), 92 deletions(-) diff --git a/.dockerignore b/.dockerignore index 90911d5..16a1aec 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,14 +1,19 @@ -# Build artifacts -github-copilot-svcs -github-copilot-svcs-* -*.exe +# Git +.git +.gitignore -# Go build cache -.go-build-cache +# Documentation +README.md +*.md -# Test artifacts -coverage.out -coverage.html +# Test files +*_test.go +test/ + +# Docker files (not needed in container) +Dockerfile +docker-compose.yml +.dockerignore # IDE files .vscode/ @@ -22,5 +27,5 @@ Thumbs.db *.tmp *.log -# Config files (sensitive) -config.json +# Build artifacts (will be created in container) +github-copilot-svcs \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index b8b8e51..c3e9f90 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,13 +31,13 @@ RUN addgroup -S appgroup && adduser -S appuser -G appgroup # Switch to non-root user USER appuser -WORKDIR /home/appuser/ +WORKDIR /app # Copy the binary from builder COPY --from=builder /app/github-copilot-svcs . -# Create config directory for non-root user -RUN mkdir -p /home/appuser/.local/share/github-copilot-svcs +# Create config directory (optimized for Docker mounting) +RUN mkdir -p /app/config # Expose the default port EXPOSE 8081 diff --git a/docker-compose.yml b/docker-compose.yml index d5504c4..83919fc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,25 +1,44 @@ -version: '3.8' - services: github-copilot-svcs: build: . ports: - "8081:8081" environment: + # Server configuration - COPILOT_PORT=8081 - LOG_LEVEL=info + # Performance optimizations + - GOGC=200 # Reduce GC frequency for better performance + - GOMAXPROCS=0 # Use all available CPU cores volumes: - # Mount config directory for persistent authentication - - ./config:/home/appuser/.local/share/github-copilot-svcs + # Mount config directory for persistent authentication (optimized path) + - ./config:/app/config restart: unless-stopped healthcheck: - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8081/health"] + test: + [ + "CMD", + "wget", + "--quiet", + "--tries=1", + "--spider", + "http://localhost:8081/health", + ] interval: 30s timeout: 10s retries: 3 start_period: 40s networks: - copilot-network + # Performance and security optimizations + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + reservations: + memory: 128M + cpus: "0.25" networks: copilot-network: diff --git a/internal/auth.go b/internal/auth.go index 22a8c54..28e37a8 100644 --- a/internal/auth.go +++ b/internal/auth.go @@ -82,7 +82,6 @@ func WithRefreshFunc(f func(cfg *Config) error) func(*AuthService) { } } - // Authenticate performs the full GitHub Copilot authentication flow func (s *AuthService) Authenticate(cfg *Config) error { now := time.Now().Unix() diff --git a/internal/cli.go b/internal/cli.go index 095486d..d44bab0 100644 --- a/internal/cli.go +++ b/internal/cli.go @@ -5,8 +5,8 @@ import ( "flag" "fmt" "os" - "time" "strings" + "time" ) // Command constants to avoid goconst errors @@ -65,7 +65,6 @@ func RunCommand(command string, args []string, version string) error { // Check for flags jsonOutput := len(args) >= 1 && args[0] == "--json" - switch command { case cmdAuth: return handleAuth() @@ -242,7 +241,6 @@ func handleConfig() error { return nil } - func getCurrentTime() int64 { return time.Now().Unix() } diff --git a/internal/cli_test.go b/internal/cli_test.go index 7cad384..b3d3c9a 100644 --- a/internal/cli_test.go +++ b/internal/cli_test.go @@ -25,4 +25,4 @@ func TestPrintUsage(t *testing.T) { if len(output) == 0 { t.Error("PrintUsage did not print anything") } -} \ No newline at end of file +} diff --git a/internal/config.go b/internal/config.go index ecf0637..b7676a8 100644 --- a/internal/config.go +++ b/internal/config.go @@ -12,10 +12,10 @@ import ( // Constants for configuration const ( - configDirName = ".local/share/github-copilot-svcs" + configDirName = "config" // Changed to be Docker-mountable configFileName = "config.json" defaultServerPort = 8081 - dirPerm = 0o700 + dirPerm = 0o755 // More permissive for Docker containers // Default header values defaultUserAgent = "GitHubCopilotChat/0.29.1" @@ -25,17 +25,17 @@ const ( defaultOpenaiIntent = "conversation-edits" defaultXInitiator = "user" - // Timeout defaults - defaultHTTPClientTimeout = 300 - defaultServerReadTimeout = 30 - defaultServerWriteTimeout = 300 - defaultServerIdleTimeout = 120 - defaultProxyContextTimeout = 300 - defaultCircuitBreakerTimeout = 30 - defaultKeepAliveTimeout = 30 + // Optimized timeout defaults for better performance + defaultHTTPClientTimeout = 60 // Reduced for faster failover + defaultServerReadTimeout = 10 // Reduced for better responsiveness + defaultServerWriteTimeout = 120 // Increased for streaming responses + defaultServerIdleTimeout = 60 // Reduced for better resource usage + defaultProxyContextTimeout = 180 // Increased for long-running requests + defaultCircuitBreakerTimeout = 15 // Reduced for faster recovery + defaultKeepAliveTimeout = 60 // Increased for connection reuse defaultTLSHandshakeTimeout = 10 - defaultDialTimeout = 10 - defaultIdleConnTimeout = 90 + defaultDialTimeout = 5 // Reduced for faster connections + defaultIdleConnTimeout = 60 // Reduced for better resource cleanup // Port validation minPortNumber = 1 @@ -88,14 +88,26 @@ type Config struct { // GetConfigPath returns the path to the config file func GetConfigPath() (string, error) { - usr, err := user.Current() - if err != nil { - return "", err + // Use Docker-mountable location that works in containers + var dir string + + // Check if we're in a container environment or if /app exists + if _, err := os.Stat("/app"); err == nil { + dir = "/app/config" + } else { + // Fallback to user's home directory for local development + usr, err := user.Current() + if err != nil { + return "", err + } + dir = filepath.Join(usr.HomeDir, configDirName) } - dir := filepath.Join(usr.HomeDir, configDirName) - if err := os.MkdirAll(dir, dirPerm); err != nil { + + // Create directory if it doesn't exist, but don't fail if it already exists + if err := os.MkdirAll(dir, dirPerm); err != nil && !os.IsExist(err) { return "", err } + return filepath.Join(dir, configFileName), nil } diff --git a/internal/errors.go b/internal/errors.go index 4c49a3e..6028701 100644 --- a/internal/errors.go +++ b/internal/errors.go @@ -198,4 +198,4 @@ func IsValidationError(err error) bool { func IsProxyError(err error) bool { _, ok := err.(*ProxyError) return ok -} \ No newline at end of file +} diff --git a/internal/errors_test.go b/internal/errors_test.go index 7e22cad..fc66f4f 100644 --- a/internal/errors_test.go +++ b/internal/errors_test.go @@ -194,4 +194,4 @@ func (m *mockResponseWriter) Write(b []byte) (int, error) { func (m *mockResponseWriter) WriteHeader(statusCode int) { m.status = statusCode -} \ No newline at end of file +} diff --git a/internal/health.go b/internal/health.go index 9176cd5..2e2b1c4 100644 --- a/internal/health.go +++ b/internal/health.go @@ -26,9 +26,9 @@ type HealthStatus string const ( // StatusHealthy indicates the service is healthy. - StatusHealthy HealthStatus = "healthy" + StatusHealthy HealthStatus = "healthy" // StatusDegraded indicates the service is degraded. - StatusDegraded HealthStatus = "degraded" + StatusDegraded HealthStatus = "degraded" // StatusUnhealthy indicates the service is unhealthy. StatusUnhealthy HealthStatus = "unhealthy" ) diff --git a/internal/logger.go b/internal/logger.go index ac91b8d..d57e8aa 100644 --- a/internal/logger.go +++ b/internal/logger.go @@ -2,10 +2,10 @@ package internal import ( "context" + "fmt" "log/slog" "os" "strings" - "fmt" "time" ) @@ -44,10 +44,9 @@ func (h *DenseTextHandler) Handle(_ context.Context, r slog.Record) error { // WithAttrs returns the handler unchanged (attrs unused). func (h *DenseTextHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } -// WithGroup returns the handler unchanged (name unused). -func (h *DenseTextHandler) WithGroup(_ string) slog.Handler { return h } - +// WithGroup returns the handler unchanged (name unused). +func (h *DenseTextHandler) WithGroup(_ string) slog.Handler { return h } const ( defaultLogLevel = "info" diff --git a/internal/middleware.go b/internal/middleware.go index 2154a27..29ea57c 100644 --- a/internal/middleware.go +++ b/internal/middleware.go @@ -4,6 +4,7 @@ package internal import ( "bufio" "bytes" + "compress/gzip" "io" "net" "net/http" @@ -199,6 +200,85 @@ func TimeoutMiddleware(timeout time.Duration) func(http.Handler) http.Handler { } } +// CompressionResponseWriter wraps http.ResponseWriter to handle compression +type CompressionResponseWriter struct { + http.ResponseWriter + gzipWriter *gzip.Writer + compressed bool +} + +// NewCompressionResponseWriter creates a new compression response writer +func NewCompressionResponseWriter(w http.ResponseWriter, r *http.Request) *CompressionResponseWriter { + // Check if client accepts gzip encoding + if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + gz := gzip.NewWriter(w) + return &CompressionResponseWriter{ + ResponseWriter: w, + gzipWriter: gz, + compressed: true, + } + } + + return &CompressionResponseWriter{ + ResponseWriter: w, + compressed: false, + } +} + +// WriteHeader handles the status code and sets compression headers if needed +func (crw *CompressionResponseWriter) WriteHeader(statusCode int) { + if crw.compressed { + crw.ResponseWriter.Header().Set("Content-Encoding", "gzip") + crw.ResponseWriter.Header().Set("Vary", "Accept-Encoding") + } + crw.ResponseWriter.WriteHeader(statusCode) +} + +// Write writes data, compressing if enabled +func (crw *CompressionResponseWriter) Write(data []byte) (int, error) { + if crw.compressed { + return crw.gzipWriter.Write(data) + } + return crw.ResponseWriter.Write(data) +} + +// Close closes the gzip writer if compression is enabled +func (crw *CompressionResponseWriter) Close() error { + if crw.compressed { + return crw.gzipWriter.Close() + } + return nil +} + +// CompressionMiddleware adds gzip compression for compressible content +func CompressionMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only compress certain content types + contentType := r.Header.Get("Content-Type") + shouldCompress := strings.Contains(contentType, "text/") || + strings.Contains(contentType, "application/json") || + strings.Contains(contentType, "application/javascript") || + strings.Contains(contentType, "text/css") || + strings.Contains(contentType, "text/html") + + // Don't compress if client doesn't accept gzip or content is already compressed + acceptEncoding := r.Header.Get("Accept-Encoding") + if !shouldCompress || !strings.Contains(acceptEncoding, "gzip") { + next.ServeHTTP(w, r) + return + } + + // Create compression writer + crw := NewCompressionResponseWriter(w, r) + defer crw.Close() + + // Serve the request with compression + next.ServeHTTP(crw, r) + }) + } +} + // Helper functions func getClientIP(r *http.Request) string { // Check X-Forwarded-For header (proxy) @@ -230,4 +310,4 @@ func containsOrigin(origins []string, origin string) bool { } } return false -} \ No newline at end of file +} diff --git a/internal/models.go b/internal/models.go index f6c81f9..bcb1e7e 100644 --- a/internal/models.go +++ b/internal/models.go @@ -36,10 +36,10 @@ func FetchFromModelsDev(httpClient *http.Client) (*transform.ModelList, error) { return nil, err } defer func() { - if err := resp.Body.Close(); err != nil { - Warn("Error closing response body", "error", err) - } -}() + if err := resp.Body.Close(); err != nil { + Warn("Error closing response body", "error", err) + } + }() if resp.StatusCode != http.StatusOK { return nil, NewNetworkError("fetch_models", "https://models.dev/api.json", fmt.Sprintf("API returned HTTP %d", resp.StatusCode), nil) diff --git a/internal/proxy.go b/internal/proxy.go index 8549682..552707f 100644 --- a/internal/proxy.go +++ b/internal/proxy.go @@ -38,9 +38,9 @@ const ( const ( // ProxyCBStateClosed indicates the circuit breaker is closed. - ProxyCBStateClosed = 0 + ProxyCBStateClosed = 0 // ProxyCBStateOpen indicates the circuit breaker is open. - ProxyCBStateOpen = 1 + ProxyCBStateOpen = 1 // ProxyCBStateHalfOpen indicates the circuit breaker is half-open. ProxyCBStateHalfOpen = 2 ) @@ -66,10 +66,17 @@ type CircuitBreaker struct { mutex sync.RWMutex } -// CoalescingCache handles request coalescing for identical requests +// CoalescingCache handles request coalescing for identical requests with TTL type CoalescingCache struct { - requests map[string]chan interface{} + requests map[string]*cacheEntry mutex sync.RWMutex + ttl time.Duration +} + +type cacheEntry struct { + result interface{} + timestamp time.Time + waiting chan interface{} } // ProxyService provides proxy functionality @@ -93,11 +100,17 @@ type responseWrapper struct { headersSent bool } -// NewCoalescingCache creates a new coalescing cache +// NewCoalescingCache creates a new coalescing cache with TTL func NewCoalescingCache() *CoalescingCache { - return &CoalescingCache{ - requests: make(map[string]chan interface{}), + cache := &CoalescingCache{ + requests: make(map[string]*cacheEntry), + ttl: 30 * time.Second, // Cache results for 30 seconds } + + // Start cleanup goroutine + go cache.cleanup() + + return cache } // GetRequestKey generates a cache key for request coalescing @@ -113,37 +126,76 @@ func (cc *CoalescingCache) GetRequestKey(method, url string, body interface{}) s return hex.EncodeToString(h.Sum(nil)) } -// CoalesceRequest executes a function only once for identical concurrent requests +// CoalesceRequest executes a function only once for identical concurrent requests with caching func (cc *CoalescingCache) CoalesceRequest(key string, fn func() interface{}) interface{} { cc.mutex.Lock() + // Check if we have a cached result that's still valid + if entry, exists := cc.requests[key]; exists { + if time.Since(entry.timestamp) < cc.ttl { + cc.mutex.Unlock() + return entry.result + } + // Remove expired entry + delete(cc.requests, key) + } + // Check if request is already in progress - if ch, exists := cc.requests[key]; exists { + if entry, exists := cc.requests[key]; exists && entry.waiting != nil { cc.mutex.Unlock() // Wait for the existing request to complete - return <-ch + return <-entry.waiting } - // Create new channel for this request - ch := make(chan interface{}, 1) - cc.requests[key] = ch + // Create new entry for this request + entry := &cacheEntry{ + waiting: make(chan interface{}, 1), + timestamp: time.Now(), + } + cc.requests[key] = entry cc.mutex.Unlock() // Execute the request result := fn() - // Broadcast result to all waiting goroutines - ch <- result - close(ch) + // Cache the result + entry.result = result + entry.timestamp = time.Now() - // Clean up - cc.mutex.Lock() - delete(cc.requests, key) - cc.mutex.Unlock() + // Broadcast result to all waiting goroutines + entry.waiting <- result + close(entry.waiting) + + // Clean up the waiting channel after a short delay + go func() { + time.Sleep(100 * time.Millisecond) + cc.mutex.Lock() + if e, exists := cc.requests[key]; exists { + e.waiting = nil + } + cc.mutex.Unlock() + }() return result } +// cleanup removes expired cache entries +func (cc *CoalescingCache) cleanup() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + cc.mutex.Lock() + now := time.Now() + for key, entry := range cc.requests { + if now.Sub(entry.timestamp) > cc.ttl { + delete(cc.requests, key) + } + } + cc.mutex.Unlock() + } +} + // NewProxyService creates a new proxy service func NewProxyService(cfg *Config, httpClient *http.Client, authService *AuthService, workerPool WorkerPoolInterface) *ProxyService { circuitBreaker := &CircuitBreaker{ @@ -363,10 +415,10 @@ func (s *ProxyService) processProxyRequest(ctx context.Context, w http.ResponseW return NewNetworkError("proxy_request", targetURL, "failed to complete request after retries", err) } defer func() { - if err := resp.Body.Close(); err != nil { - Warn("Error closing response body", "error", err) - } -}() + if err := resp.Body.Close(); err != nil { + Warn("Error closing response body", "error", err) + } + }() // Update circuit breaker based on response if resp.StatusCode < statusCodeServerError { diff --git a/internal/server.go b/internal/server.go index 020fcad..7c59477 100644 --- a/internal/server.go +++ b/internal/server.go @@ -2,6 +2,7 @@ package internal import ( "context" + "crypto/tls" "fmt" "net" "net/http" @@ -17,18 +18,28 @@ import ( const ( shutdownTimeout = 10 * time.Second - // HTTP client configuration - maxIdleConns = 100 - maxIdleConnsPerHost = 20 + // Optimized HTTP client configuration for better performance + maxIdleConns = 200 // Increased for better connection reuse + maxIdleConnsPerHost = 50 // Increased for high-traffic scenarios + maxConnsPerHost = 100 // Limit concurrent connections per host workerMultiplier = 2 ) +// Metrics holds server performance metrics +type Metrics struct { + RequestsTotal int64 + RequestsDuration float64 + ActiveConnections int64 + mutex sync.RWMutex +} + // Server represents the HTTP server and its dependencies type Server struct { config *Config httpServer *http.Server httpClient *http.Client workerPool *WorkerPool + metrics *Metrics } // WorkerPool handles background processing @@ -39,15 +50,27 @@ type WorkerPool struct { wg sync.WaitGroup } -// NewWorkerPool creates a new worker pool +// NewWorkerPool creates a new worker pool with intelligent sizing func NewWorkerPool(workers int) *WorkerPool { if workers <= 0 { - workers = runtime.NumCPU() + // Intelligent sizing based on system resources and workload + cpuCount := runtime.NumCPU() + // Use 50% of CPU cores for workers, with a minimum of 2 and maximum of 16 + workers = cpuCount / 2 + if workers < 2 { + workers = 2 + } + if workers > 16 { + workers = 16 + } } + // Increased buffer size for better burst handling + bufferSize := workers * workerMultiplier * 2 + wp := &WorkerPool{ workers: workers, - jobQueue: make(chan func(), workers*workerMultiplier), // Buffer for burst traffic + jobQueue: make(chan func(), bufferSize), // Buffer for burst traffic quit: make(chan bool), } @@ -83,14 +106,17 @@ func (wp *WorkerPool) Stop() { wp.wg.Wait() } -// CreateHTTPClient creates a configured HTTP client +// CreateHTTPClient creates a configured HTTP client with optimized connection pooling func CreateHTTPClient(cfg *Config) *http.Client { return &http.Client{ Timeout: time.Duration(cfg.Timeouts.HTTPClient) * time.Second, Transport: &http.Transport{ MaxIdleConns: maxIdleConns, MaxIdleConnsPerHost: maxIdleConnsPerHost, + MaxConnsPerHost: maxConnsPerHost, IdleConnTimeout: time.Duration(cfg.Timeouts.IdleConnTimeout) * time.Second, + DisableKeepAlives: false, // Enable keep-alives for better performance + DisableCompression: false, // Enable compression for better performance DialContext: (&net.Dialer{ Timeout: time.Duration(cfg.Timeouts.DialTimeout) * time.Second, KeepAlive: time.Duration(cfg.Timeouts.KeepAlive) * time.Second, @@ -104,6 +130,9 @@ func CreateHTTPClient(cfg *Config) *http.Client { func NewServer(cfg *Config, httpClient *http.Client) *Server { workerPool := NewWorkerPool(runtime.NumCPU() * workerMultiplier) + // Initialize metrics + metrics := &Metrics{} + // Create auth service authService := NewAuthService(httpClient) @@ -121,6 +150,7 @@ func NewServer(cfg *Config, httpClient *http.Client) *Server { mux.HandleFunc("/v1/models", modelsService.Handler()) mux.HandleFunc("/v1/chat/completions", proxyService.Handler()) mux.HandleFunc("/health", healthChecker.Handler()) + mux.HandleFunc("/metrics", metrics.Handler()) // Add metrics endpoint // Add pprof endpoints for profiling mux.HandleFunc("/debug/pprof/", http.DefaultServeMux.ServeHTTP) @@ -142,15 +172,33 @@ func NewServer(cfg *Config, httpClient *http.Client) *Server { handler = CORSMiddleware(cfg)(handler) handler = LoggingMiddleware(handler) handler = RecoveryMiddleware(handler) + handler = CompressionMiddleware()(handler) // Add compression for better performance + handler = metrics.MetricsMiddleware(handler) // Add metrics collection // Note: TimeoutMiddleware could be added here if needed per-request timeouts // handler = TimeoutMiddleware(time.Duration(cfg.Timeouts.ProxyContext) * time.Second)(handler) + // Configure HTTP/2 support with optimized TLS settings + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + CurvePreferences: []tls.CurveID{tls.CurveP256, tls.CurveP384, tls.CurveP521}, + PreferServerCipherSuites: true, + CipherSuites: []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + }, + } + httpServer := &http.Server{ Addr: fmt.Sprintf(":%d", port), Handler: handler, ReadTimeout: time.Duration(cfg.Timeouts.ServerRead) * time.Second, WriteTimeout: time.Duration(cfg.Timeouts.ServerWrite) * time.Second, IdleTimeout: time.Duration(cfg.Timeouts.ServerIdle) * time.Second, + TLSConfig: tlsConfig, + // Enable HTTP/2 support (empty map disables HTTP/1.1 fallback to HTTP/2) + TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), } return &Server{ @@ -158,6 +206,7 @@ func NewServer(cfg *Config, httpClient *http.Client) *Server { httpServer: httpServer, httpClient: httpClient, workerPool: workerPool, + metrics: metrics, } } @@ -218,3 +267,73 @@ func (s *Server) setupGracefulShutdown() { } // healthHandler is now replaced by the comprehensive HealthChecker + +// MetricsMiddleware adds request metrics collection +func (m *Metrics) MetricsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Track active connections + m.mutex.Lock() + m.ActiveConnections++ + m.mutex.Unlock() + + // Wrap response writer to capture status code + rw := &responseWriter{ResponseWriter: w, statusCode: 200} + + // Process request + next.ServeHTTP(rw, r) + + // Record metrics + duration := time.Since(start).Seconds() + m.mutex.Lock() + m.RequestsTotal++ + m.RequestsDuration += duration + m.ActiveConnections-- + m.mutex.Unlock() + }) +} + +// responseWriter wraps http.ResponseWriter to capture status code +type responseWriter struct { + http.ResponseWriter + statusCode int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} + +// Handler returns metrics in Prometheus format +func (m *Metrics) Handler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + m.mutex.RLock() + requestsTotal := m.RequestsTotal + requestsDuration := m.RequestsDuration + activeConnections := m.ActiveConnections + m.mutex.RUnlock() + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + + fmt.Fprintf(w, "# HELP github_copilot_requests_total Total number of requests\n") + fmt.Fprintf(w, "# TYPE github_copilot_requests_total counter\n") + fmt.Fprintf(w, "github_copilot_requests_total %d\n", requestsTotal) + + fmt.Fprintf(w, "# HELP github_copilot_requests_duration_seconds Total duration of requests in seconds\n") + fmt.Fprintf(w, "# TYPE github_copilot_requests_duration_seconds counter\n") + fmt.Fprintf(w, "github_copilot_requests_duration_seconds %f\n", requestsDuration) + + fmt.Fprintf(w, "# HELP github_copilot_active_connections Current number of active connections\n") + fmt.Fprintf(w, "# TYPE github_copilot_active_connections gauge\n") + fmt.Fprintf(w, "github_copilot_active_connections %d\n", activeConnections) + + // Add uptime metric + uptime := time.Since(startTime).Seconds() + fmt.Fprintf(w, "# HELP github_copilot_uptime_seconds Server uptime in seconds\n") + fmt.Fprintf(w, "# TYPE github_copilot_uptime_seconds counter\n") + fmt.Fprintf(w, "github_copilot_uptime_seconds %f\n", uptime) + } +} + +var startTime = time.Now() diff --git a/internal/server_test.go b/internal/server_test.go index c773373..934a13b 100644 --- a/internal/server_test.go +++ b/internal/server_test.go @@ -190,12 +190,12 @@ func TestCreateHTTPClient(t *testing.T) { t.Fatal("Expected transport to be *http.Transport") } - if transport.MaxIdleConns != 100 { - t.Errorf("Expected MaxIdleConns 100, got %d", transport.MaxIdleConns) + if transport.MaxIdleConns != 200 { + t.Errorf("Expected MaxIdleConns 200, got %d", transport.MaxIdleConns) } - if transport.MaxIdleConnsPerHost != 20 { - t.Errorf("Expected MaxIdleConnsPerHost 20, got %d", transport.MaxIdleConnsPerHost) + if transport.MaxIdleConnsPerHost != 50 { + t.Errorf("Expected MaxIdleConnsPerHost 50, got %d", transport.MaxIdleConnsPerHost) } }) diff --git a/pkg/transform/transform.go b/pkg/transform/transform.go index 47e4567..36ea206 100644 --- a/pkg/transform/transform.go +++ b/pkg/transform/transform.go @@ -52,4 +52,4 @@ type Model struct { Object string `json:"object"` Created int64 `json:"created"` OwnedBy string `json:"owned_by"` -} \ No newline at end of file +} diff --git a/test/testutils/helpers.go b/test/testutils/helpers.go index 2396274..ceced26 100644 --- a/test/testutils/helpers.go +++ b/test/testutils/helpers.go @@ -57,8 +57,8 @@ func SetupTestDir(t *testing.T) string { t.Cleanup(func() { if err := os.RemoveAll(dir); err != nil { - panic(err) -} + panic(err) + } }) return dir From ba9381b4a177545fc69d49c85d044addbeed1f14 Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 3 Oct 2025 09:06:17 -0500 Subject: [PATCH 02/10] Update docker-compose.yml --- docker-compose.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 83919fc..0ecdba2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,14 +4,11 @@ services: ports: - "8081:8081" environment: - # Server configuration - COPILOT_PORT=8081 - LOG_LEVEL=info - # Performance optimizations - GOGC=200 # Reduce GC frequency for better performance - GOMAXPROCS=0 # Use all available CPU cores volumes: - # Mount config directory for persistent authentication (optimized path) - ./config:/app/config restart: unless-stopped healthcheck: @@ -30,7 +27,6 @@ services: start_period: 40s networks: - copilot-network - # Performance and security optimizations deploy: resources: limits: From 62fb2e5cfb1b2b1ab9d1b0b0e7bf6f5d8d81e208 Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 3 Oct 2025 09:08:29 -0500 Subject: [PATCH 03/10] linter errors --- internal/middleware.go | 7 +++++- internal/server.go | 51 +++++++++++++++++++++++++++++++----------- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/internal/middleware.go b/internal/middleware.go index 29ea57c..c79b41d 100644 --- a/internal/middleware.go +++ b/internal/middleware.go @@ -271,7 +271,12 @@ func CompressionMiddleware() func(http.Handler) http.Handler { // Create compression writer crw := NewCompressionResponseWriter(w, r) - defer crw.Close() + defer func() { + if err := crw.Close(); err != nil { + // Log error but don't fail the request + // The response has already been sent at this point + } + }() // Serve the request with compression next.ServeHTTP(crw, r) diff --git a/internal/server.go b/internal/server.go index 7c59477..82b695a 100644 --- a/internal/server.go +++ b/internal/server.go @@ -307,7 +307,7 @@ func (rw *responseWriter) WriteHeader(code int) { // Handler returns metrics in Prometheus format func (m *Metrics) Handler() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, _ *http.Request) { m.mutex.RLock() requestsTotal := m.RequestsTotal requestsDuration := m.RequestsDuration @@ -316,23 +316,48 @@ func (m *Metrics) Handler() http.HandlerFunc { w.Header().Set("Content-Type", "text/plain; charset=utf-8") - fmt.Fprintf(w, "# HELP github_copilot_requests_total Total number of requests\n") - fmt.Fprintf(w, "# TYPE github_copilot_requests_total counter\n") - fmt.Fprintf(w, "github_copilot_requests_total %d\n", requestsTotal) + // Write metrics in Prometheus format, checking for errors + if _, err := fmt.Fprintf(w, "# HELP github_copilot_requests_total Total number of requests\n"); err != nil { + return + } + if _, err := fmt.Fprintf(w, "# TYPE github_copilot_requests_total counter\n"); err != nil { + return + } + if _, err := fmt.Fprintf(w, "github_copilot_requests_total %d\n", requestsTotal); err != nil { + return + } - fmt.Fprintf(w, "# HELP github_copilot_requests_duration_seconds Total duration of requests in seconds\n") - fmt.Fprintf(w, "# TYPE github_copilot_requests_duration_seconds counter\n") - fmt.Fprintf(w, "github_copilot_requests_duration_seconds %f\n", requestsDuration) + if _, err := fmt.Fprintf(w, "# HELP github_copilot_requests_duration_seconds Total duration of requests in seconds\n"); err != nil { + return + } + if _, err := fmt.Fprintf(w, "# TYPE github_copilot_requests_duration_seconds counter\n"); err != nil { + return + } + if _, err := fmt.Fprintf(w, "github_copilot_requests_duration_seconds %f\n", requestsDuration); err != nil { + return + } - fmt.Fprintf(w, "# HELP github_copilot_active_connections Current number of active connections\n") - fmt.Fprintf(w, "# TYPE github_copilot_active_connections gauge\n") - fmt.Fprintf(w, "github_copilot_active_connections %d\n", activeConnections) + if _, err := fmt.Fprintf(w, "# HELP github_copilot_active_connections Current number of active connections\n"); err != nil { + return + } + if _, err := fmt.Fprintf(w, "# TYPE github_copilot_active_connections gauge\n"); err != nil { + return + } + if _, err := fmt.Fprintf(w, "github_copilot_active_connections %d\n", activeConnections); err != nil { + return + } // Add uptime metric uptime := time.Since(startTime).Seconds() - fmt.Fprintf(w, "# HELP github_copilot_uptime_seconds Server uptime in seconds\n") - fmt.Fprintf(w, "# TYPE github_copilot_uptime_seconds counter\n") - fmt.Fprintf(w, "github_copilot_uptime_seconds %f\n", uptime) + if _, err := fmt.Fprintf(w, "# HELP github_copilot_uptime_seconds Server uptime in seconds\n"); err != nil { + return + } + if _, err := fmt.Fprintf(w, "# TYPE github_copilot_uptime_seconds counter\n"); err != nil { + return + } + if _, err := fmt.Fprintf(w, "github_copilot_uptime_seconds %f\n", uptime); err != nil { + return + } } } From 0ed43c4ea9c3a1f68dad2aa20b5641ae4844752c Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 3 Oct 2025 09:29:37 -0500 Subject: [PATCH 04/10] fix workflow --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bad07e3..edc4780 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,8 +125,8 @@ jobs: uses: docker/login-action@v3 with: registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GH_PAT }} - name: Extract metadata id: meta diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 338886b..cec7ac1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -144,8 +144,8 @@ jobs: uses: docker/login-action@v3 with: registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GH_PAT }} - name: Extract metadata id: meta From 42940b018623e9b244aefaefa3261eab5f8a66c7 Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 3 Oct 2025 09:35:24 -0500 Subject: [PATCH 05/10] Update middleware.go --- internal/middleware.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/internal/middleware.go b/internal/middleware.go index c79b41d..29ea57c 100644 --- a/internal/middleware.go +++ b/internal/middleware.go @@ -271,12 +271,7 @@ func CompressionMiddleware() func(http.Handler) http.Handler { // Create compression writer crw := NewCompressionResponseWriter(w, r) - defer func() { - if err := crw.Close(); err != nil { - // Log error but don't fail the request - // The response has already been sent at this point - } - }() + defer crw.Close() // Serve the request with compression next.ServeHTTP(crw, r) From fc6bf3057fc104340ce470205fb0e387a5448c22 Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 3 Oct 2025 09:40:25 -0500 Subject: [PATCH 06/10] Update middleware.go --- internal/middleware.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/middleware.go b/internal/middleware.go index 29ea57c..a9f4640 100644 --- a/internal/middleware.go +++ b/internal/middleware.go @@ -271,7 +271,9 @@ func CompressionMiddleware() func(http.Handler) http.Handler { // Create compression writer crw := NewCompressionResponseWriter(w, r) - defer crw.Close() + defer func() { + _ = crw.Close() // Ignore error as response is already sent + }() // Serve the request with compression next.ServeHTTP(crw, r) From 05d6047f94cb76475fa312fc3c09895b8e0dd870 Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 3 Oct 2025 09:50:19 -0500 Subject: [PATCH 07/10] fix package name --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edc4780..6221a2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,7 +132,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ghcr.io/${{ github.repository }} + images: ghcr.io/${{ github.repository_owner }}/${{ github.repository }} tags: | type=ref,event=branch type=ref,event=pr diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cec7ac1..29413e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -151,7 +151,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ghcr.io/${{ github.repository }} + images: ghcr.io/${{ github.repository_owner }}/${{ github.repository }} tags: | type=semver,pattern={{version}},value=${{ needs.release.outputs.version }} type=semver,pattern={{major}}.{{minor}},value=${{ needs.release.outputs.version }} From c313074d5df161da0fd08ca6a54c1e50f02e3828 Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 3 Oct 2025 09:56:29 -0500 Subject: [PATCH 08/10] revert --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6221a2b..edc4780 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,7 +132,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ghcr.io/${{ github.repository_owner }}/${{ github.repository }} + images: ghcr.io/${{ github.repository }} tags: | type=ref,event=branch type=ref,event=pr diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29413e9..cec7ac1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -151,7 +151,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ghcr.io/${{ github.repository_owner }}/${{ github.repository }} + images: ghcr.io/${{ github.repository }} tags: | type=semver,pattern={{version}},value=${{ needs.release.outputs.version }} type=semver,pattern={{major}}.{{minor}},value=${{ needs.release.outputs.version }} From e681a03de6a88c9aa37fbb0e228873d76931ca1d Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 3 Oct 2025 10:08:11 -0500 Subject: [PATCH 09/10] Update proxy.go --- internal/proxy.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/proxy.go b/internal/proxy.go index 552707f..ef5a25d 100644 --- a/internal/proxy.go +++ b/internal/proxy.go @@ -498,7 +498,8 @@ func (s *ProxyService) handleRegularResponse(w http.ResponseWriter, resp *http.R buf.Reset() defer s.bufferPool.Put(buf) - _, err := io.CopyBuffer(w, resp.Body, buf.Bytes()[:0]) + // Use the buffer for copying (not an empty slice) + _, err := io.CopyBuffer(w, resp.Body, buf.Bytes()) if err != nil { Error("Error copying response", "error", err) return err From cdb00f77683f5a060741d481634779a3384ce618 Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 3 Oct 2025 10:16:22 -0500 Subject: [PATCH 10/10] Update proxy.go --- internal/proxy.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/internal/proxy.go b/internal/proxy.go index ef5a25d..355c1ac 100644 --- a/internal/proxy.go +++ b/internal/proxy.go @@ -391,6 +391,11 @@ func (s *ProxyService) processProxyRequest(ctx context.Context, w http.ResponseW targetURL := copilotAPIBase + chatCompletionsPath Debug("Sending request to target", "url", targetURL, "body_length", len(body)) + // Debug: Log the request body for troubleshooting + if len(body) < 1000 { // Only log small requests to avoid flooding logs + Debug("Request body", "body", string(body)) + } + req, err := http.NewRequestWithContext(ctx, r.Method, targetURL, bytes.NewBuffer(body)) if err != nil { Error("Error creating request", "error", err) @@ -408,6 +413,13 @@ func (s *ProxyService) processProxyRequest(ctx context.Context, w http.ResponseW req.Header.Set("Openai-Intent", s.config.Headers.OpenaiIntent) req.Header.Set("X-Initiator", s.config.Headers.XInitiator) + // Debug: Log the final headers being sent + authPrefix := s.config.CopilotToken + if len(authPrefix) > 10 { + authPrefix = authPrefix[:10] + "..." + } + Debug("Request headers", "authorization_prefix", authPrefix, "user_agent", s.config.Headers.UserAgent) + resp, err := s.makeRequestWithRetry(req, body) if err != nil { s.circuitBreaker.onFailure() @@ -429,6 +441,25 @@ func (s *ProxyService) processProxyRequest(ctx context.Context, w http.ResponseW Debug("Received response", "status", resp.StatusCode, "content_type", resp.Header.Get("Content-Type")) + // If we got an error response, try to read and log the response body for debugging + if resp.StatusCode >= 400 { + errorRespBody, readErr := io.ReadAll(resp.Body) + if readErr == nil { + // Put the body back so it can be read again + resp.Body = io.NopCloser(bytes.NewBuffer(errorRespBody)) + // Only log small error responses to avoid flooding logs + if len(errorRespBody) < 500 { + Debug("Error response body", "status", resp.StatusCode, "body", string(errorRespBody)) + } else { + Debug("Error response body", "status", resp.StatusCode, "body_length", len(errorRespBody)) + } + } else { + // If reading failed, try to put the original body back (though it might be consumed) + // This is best effort since we can't recreate the original body + Debug("Failed to read error response body for debugging", "error", readErr) + } + } + // Copy response headers for key, values := range resp.Header { for _, value := range values {