192 lines
5.1 KiB
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)
|