Capture destination-specific HTTP evidence
This commit is contained in:
165
internal/llm/http_evidence.go
Normal file
165
internal/llm/http_evidence.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTPExecutionEvidence contains only facts observable at the configured
|
||||
// RoundTripper boundary. It intentionally excludes request headers and bodies.
|
||||
type HTTPExecutionEvidence struct {
|
||||
DispatchInvoked bool
|
||||
RequestObserved bool
|
||||
Endpoint string
|
||||
CredentialsSupplied bool
|
||||
ResponseReceived bool
|
||||
HTTPStatus *int
|
||||
}
|
||||
|
||||
type httpEvidenceCollector struct {
|
||||
mu sync.Mutex
|
||||
evidence HTTPExecutionEvidence
|
||||
}
|
||||
|
||||
func (c *httpEvidenceCollector) observeRequest(request *http.Request) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.evidence.RequestObserved = true
|
||||
c.evidence.Endpoint = endpointWithoutUserInfo(request)
|
||||
c.evidence.CredentialsSupplied = requestHasCredentials(request)
|
||||
c.evidence.ResponseReceived = false
|
||||
c.evidence.HTTPStatus = nil
|
||||
}
|
||||
|
||||
func (c *httpEvidenceCollector) observeResponse(response *http.Response) {
|
||||
if response == nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
status := response.StatusCode
|
||||
c.evidence.ResponseReceived = true
|
||||
c.evidence.HTTPStatus = &status
|
||||
}
|
||||
|
||||
func (c *httpEvidenceCollector) snapshot() HTTPExecutionEvidence {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
evidence := c.evidence
|
||||
if c.evidence.HTTPStatus != nil {
|
||||
status := *c.evidence.HTTPStatus
|
||||
evidence.HTTPStatus = &status
|
||||
}
|
||||
return evidence
|
||||
}
|
||||
|
||||
type observingRoundTripper struct {
|
||||
next http.RoundTripper
|
||||
evidence *httpEvidenceCollector
|
||||
}
|
||||
|
||||
func (t observingRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
t.evidence.observeRequest(request)
|
||||
response, err := t.next.RoundTrip(request)
|
||||
t.evidence.observeResponse(response)
|
||||
return response, err
|
||||
}
|
||||
|
||||
// doWithEvidence executes one request with a shallow copy of client. The copy
|
||||
// preserves timeout, redirect, and cookie-jar behavior without changing client.
|
||||
func doWithEvidence(client *http.Client, request *http.Request) (*http.Response, error, HTTPExecutionEvidence) {
|
||||
if client == nil {
|
||||
client = &http.Client{}
|
||||
}
|
||||
|
||||
collector := &httpEvidenceCollector{}
|
||||
clientCopy := *client
|
||||
transport := clientCopy.Transport
|
||||
if transport == nil {
|
||||
transport = http.DefaultTransport
|
||||
}
|
||||
clientCopy.Transport = observingRoundTripper{next: transport, evidence: collector}
|
||||
|
||||
response, err := clientCopy.Do(request)
|
||||
observed := collector.snapshot()
|
||||
observed.DispatchInvoked = true
|
||||
return response, err, observed
|
||||
}
|
||||
|
||||
// GetWithEvidence performs a plain GET with the configured built-in HTTP
|
||||
// client. A positive timeoutSeconds applies to the request through response
|
||||
// body closure, matching the whole-request behavior of http.Client.Timeout.
|
||||
func (c *OpenAICompatibleClient) GetWithEvidence(
|
||||
ctx context.Context,
|
||||
endpoint string,
|
||||
timeoutSeconds int,
|
||||
) (*http.Response, HTTPExecutionEvidence, error) {
|
||||
requestContext := ctx
|
||||
var cancel context.CancelFunc
|
||||
if timeoutSeconds > 0 {
|
||||
requestContext, cancel = context.WithTimeout(ctx, secondsDuration(timeoutSeconds))
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(requestContext, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
return nil, HTTPExecutionEvidence{}, err
|
||||
}
|
||||
|
||||
response, err, evidence := doWithEvidence(c.httpClient, request)
|
||||
if response == nil || response.Body == nil {
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
return response, evidence, err
|
||||
}
|
||||
if cancel != nil {
|
||||
response.Body = &cancelOnCloseReadCloser{ReadCloser: response.Body, cancel: cancel}
|
||||
}
|
||||
return response, evidence, err
|
||||
}
|
||||
|
||||
func secondsDuration(seconds int) time.Duration {
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
type cancelOnCloseReadCloser struct {
|
||||
io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (r *cancelOnCloseReadCloser) Close() error {
|
||||
r.once.Do(r.cancel)
|
||||
return r.ReadCloser.Close()
|
||||
}
|
||||
|
||||
func endpointWithoutUserInfo(request *http.Request) string {
|
||||
if request == nil || request.URL == nil {
|
||||
return ""
|
||||
}
|
||||
endpoint := *request.URL
|
||||
endpoint.User = nil
|
||||
return endpoint.String()
|
||||
}
|
||||
|
||||
func requestHasCredentials(request *http.Request) bool {
|
||||
if request == nil {
|
||||
return false
|
||||
}
|
||||
if request.Header.Get("Authorization") != "" {
|
||||
return true
|
||||
}
|
||||
return request.URL != nil && request.URL.User != nil
|
||||
}
|
||||
|
||||
var _ http.RoundTripper = observingRoundTripper{}
|
||||
var _ io.ReadCloser = (*cancelOnCloseReadCloser)(nil)
|
||||
Reference in New Issue
Block a user