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)