package provider import ( "compress/gzip" "context" "fmt" "io" "net" "net/http" "net/url" "path" "strings" "time" "github.com/seifghazi/claude-code-monitor/internal/config" ) type AnthropicProvider struct { client *http.Client config *config.AnthropicProviderConfig } func NewAnthropicProvider(cfg *config.AnthropicProviderConfig) Provider { respHeaderTimeout := cfg.ResponseHeaderTimeout if respHeaderTimeout <= 0 { respHeaderTimeout = 300 * time.Second } return &AnthropicProvider{ client: &http.Client{ // No Client.Timeout: a global timeout would cancel long streaming // responses mid-flight. Per-phase timeouts on the Transport plus the // 30-min context in handlers.Messages bound the request instead. Transport: &http.Transport{ DialContext: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, TLSHandshakeTimeout: 30 * time.Second, // Tunable via ANTHROPIC_RESPONSE_HEADER_TIMEOUT — opus + extended // thinking on large contexts can take longer than the 300s default. ResponseHeaderTimeout: respHeaderTimeout, ExpectContinueTimeout: 1 * time.Second, MaxIdleConns: 100, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, }, }, config: cfg, } } func (p *AnthropicProvider) Name() string { return "anthropic" } func (p *AnthropicProvider) ForwardRequest(ctx context.Context, originalReq *http.Request) (*http.Response, error) { // Clone the request to avoid modifying the original proxyReq := originalReq.Clone(ctx) // Parse the configured base URL baseURL, err := url.Parse(p.config.BaseURL) if err != nil { return nil, fmt.Errorf("failed to parse base URL '%s': %w", p.config.BaseURL, err) } if baseURL.Scheme == "" || baseURL.Host == "" { return nil, fmt.Errorf("invalid base URL, scheme and host are required: %s", p.config.BaseURL) } // Update the destination URL proxyReq.URL.Scheme = baseURL.Scheme proxyReq.URL.Host = baseURL.Host proxyReq.URL.Path = path.Join(baseURL.Path, originalReq.URL.Path) // Preserve query parameters proxyReq.URL.RawQuery = originalReq.URL.RawQuery // Update request headers proxyReq.RequestURI = "" proxyReq.Host = baseURL.Host // Remove hop-by-hop headers removeHopByHopHeaders(proxyReq.Header) // Add required headers if not present if proxyReq.Header.Get("anthropic-version") == "" { proxyReq.Header.Set("anthropic-version", p.config.Version) } // Handle Accept-Encoding: We accept gzip from upstream for efficiency, // but we decompress before forwarding to the client. This is transparent // to the client - they receive uncompressed data regardless of what they requested. // We preserve gzip if client already requested it, otherwise add it. clientEncoding := proxyReq.Header.Get("Accept-Encoding") if clientEncoding == "" || !strings.Contains(clientEncoding, "gzip") { proxyReq.Header.Set("Accept-Encoding", "gzip") } // Forward the request resp, err := p.client.Do(proxyReq) if err != nil { return nil, fmt.Errorf("failed to forward request: %w", err) } // Handle gzip-encoded responses if resp.Header.Get("Content-Encoding") == "gzip" { resp.Header.Del("Content-Encoding") resp.Header.Del("Content-Length") gzipReader, err := gzip.NewReader(resp.Body) if err != nil { resp.Body.Close() return nil, fmt.Errorf("failed to create gzip reader: %w", err) } resp.Body = &gzipResponseBody{ Reader: gzipReader, closer: resp.Body, } } return resp, nil } type gzipResponseBody struct { io.Reader closer io.Closer } func (g *gzipResponseBody) Close() error { if gzReader, ok := g.Reader.(*gzip.Reader); ok { gzReader.Close() } return g.closer.Close() } func removeHopByHopHeaders(header http.Header) { hopByHopHeaders := []string{ "Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", "TE", "Trailers", "Transfer-Encoding", "Upgrade", } for _, h := range hopByHopHeaders { header.Del(h) } // Remove any headers specified in the Connection header if connection := header.Get("Connection"); connection != "" { for _, h := range strings.Split(connection, ",") { header.Del(strings.TrimSpace(h)) } header.Del("Connection") } }