Files
promptkit/internal/llm/http_evidence.go

192 lines
5.1 KiB
Go

package llm
import (
"context"
"io"
"net/http"
"sync"
"time"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
)
// 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) {
return c.getWithEvidence(ctx, endpoint, timeoutSeconds, "")
}
// GetWithAuthenticationEvidence performs a plain GET with credentials resolved
// from the selected target. Missing credentials are allowed for diagnostic
// authentication probes even when generation requires a key.
func (c *OpenAICompatibleClient) GetWithAuthenticationEvidence(
ctx context.Context,
endpoint string,
timeoutSeconds int,
target domain.ExecutionTarget,
) (*http.Response, HTTPExecutionEvidence, error) {
return c.getWithEvidence(ctx, endpoint, timeoutSeconds, resolveAPIKey(target))
}
func (c *OpenAICompatibleClient) getWithEvidence(
ctx context.Context,
endpoint string,
timeoutSeconds int,
apiKey string,
) (*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
}
if apiKey != "" {
request.Header.Set("Authorization", "Bearer "+apiKey)
}
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)