Files
promptkit/internal/usecase/smoke_tester.go

265 lines
8.5 KiB
Go

package usecase
import (
"context"
"fmt"
"time"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
"gitea.maximumdirect.net/eric/promptkit/internal/generation"
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
"gitea.maximumdirect.net/eric/promptkit/internal/observation"
)
// SmokeTester coordinates internal HTTP smoke-test operations over captured
// selection collaborators. Inference operations are added separately.
type SmokeTester struct {
runner *Runner
httpClient smokeHTTPGetter
now func() time.Time
}
// NewSmokeTester creates an internal diagnostic coordinator. A nil HTTP client
// leaves HTTP operations unsupported rather than falling back to another client.
func NewSmokeTester(runner *Runner, httpClient *llm.OpenAICompatibleClient) *SmokeTester {
var getter smokeHTTPGetter
if httpClient != nil {
getter = httpClient
}
return &SmokeTester{runner: runner, httpClient: getter, now: time.Now}
}
func newSmokeTesterWithClock(runner *Runner, httpClient *llm.OpenAICompatibleClient, now func() time.Time) *SmokeTester {
var getter smokeHTTPGetter
if httpClient != nil {
getter = httpClient
}
return &SmokeTester{runner: runner, httpClient: getter, now: now}
}
// Run selects and executes one HTTP diagnostic, retaining only factual results.
func (t *SmokeTester) Run(ctx context.Context, request domain.SmokeTestRequest) (*domain.SmokeTestReport, error) {
if t == nil || t.runner == nil {
return nil, fmt.Errorf("%w: smoke tester is not configured", ErrInvalidRequest)
}
ctx = observation.WithoutExecutionState(ctx)
now := t.now
if now == nil {
now = time.Now
}
startedAt := now().UTC()
report := &domain.SmokeTestReport{
Kind: request.Kind,
Timing: domain.SmokeTestTiming{
StartedAt: startedAt,
},
}
finish := func(err error) (*domain.SmokeTestReport, error) {
report.Timing.Duration = nonnegativeDuration(now().UTC().Sub(startedAt))
return report, err
}
selection, err := t.runner.resolveSmokeTestTarget(ctx, request)
if err != nil {
if contextErr := ctx.Err(); contextErr != nil {
report.Failure = generation.ClassifyFailure(contextErr)
return finish(contextErr)
}
return nil, err
}
report.Target, err = smokeReportTarget(selection.target)
if err != nil {
return nil, err
}
if err := t.setSupport(report, selection); err != nil {
return nil, err
}
if contextErr := ctx.Err(); contextErr != nil {
report.Failure = generation.ClassifyFailure(contextErr)
return finish(contextErr)
}
if !report.Supported || (report.Applicable != nil && !*report.Applicable) {
report.Completed = true
return finish(nil)
}
requestStartedAt := now().UTC()
switch request.Kind {
case domain.SmokeTestReachability:
outcome, err := (reachabilityProbe{client: t.httpClient}).run(
ctx,
selection.target.Endpoint,
selection.target.EffectiveSettings.TimeoutSeconds,
)
requestDuration := nonnegativeDuration(now().UTC().Sub(requestStartedAt))
if err != nil {
if contextErr := ctx.Err(); contextErr != nil {
report.Failure = generation.ClassifyFailure(contextErr)
return finish(contextErr)
}
return nil, err
}
report.Supported = outcome.supported
report.Reason = outcome.reason
report.Failure = outcome.failure
report.Facts = httpExecutionFacts(outcome.evidence)
if outcome.reachable != nil {
reachable := *outcome.reachable
report.Facts.EndpointReachable = &reachable
}
t.recordHTTPAttempt(report, report.Target.Endpoint, outcome.evidence, requestStartedAt, requestDuration, outcome.failure, nil)
return t.finishHTTP(ctx, report, finish)
case domain.SmokeTestAuthentication:
outcome, err := (authenticationProbe{client: t.httpClient}).run(ctx, selection.backend, selection.target)
requestDuration := nonnegativeDuration(now().UTC().Sub(requestStartedAt))
if err != nil {
if contextErr := ctx.Err(); contextErr != nil {
report.Failure = generation.ClassifyFailure(contextErr)
return finish(contextErr)
}
return nil, err
}
report.Supported = outcome.supported
report.Applicable = cloneOptionalBool(outcome.applicable)
report.Reason = outcome.reason
report.Failure = outcome.failure
report.Facts = httpExecutionFacts(outcome.evidence)
report.Facts.CredentialsAccepted = cloneOptionalBool(outcome.credentialsAccepted)
t.recordHTTPAttempt(report, report.Target.Endpoint, outcome.evidence, requestStartedAt, requestDuration, outcome.failure, outcome.credentialsAccepted)
return t.finishHTTP(ctx, report, finish)
default:
return nil, fmt.Errorf("%w: HTTP smoke tester cannot run kind %q", ErrInvalidRequest, request.Kind)
}
}
func (t *SmokeTester) setSupport(report *domain.SmokeTestReport, selection *smokeTestSelection) error {
switch report.Kind {
case domain.SmokeTestReachability:
if t.httpClient == nil {
report.Reason = domain.SmokeTestReasonHTTPDiagnosticsUnavailable
return nil
}
report.Supported = true
report.Applicable = boolPointer(true)
return nil
case domain.SmokeTestAuthentication:
outcome, err := (authenticationProbe{client: t.httpClient}).support(selection.backend)
if err != nil {
return err
}
report.Supported = outcome.supported
report.Applicable = cloneOptionalBool(outcome.applicable)
report.Reason = outcome.reason
return nil
default:
return fmt.Errorf("%w: HTTP smoke tester cannot run kind %q", ErrInvalidRequest, report.Kind)
}
}
func (t *SmokeTester) finishHTTP(
ctx context.Context,
report *domain.SmokeTestReport,
finish func(error) (*domain.SmokeTestReport, error),
) (*domain.SmokeTestReport, error) {
if contextErr := ctx.Err(); contextErr != nil && isContextFailure(report.Failure) {
report.Completed = false
report.Failure = generation.ClassifyFailure(contextErr)
return finish(contextErr)
}
report.Completed = true
return finish(nil)
}
func (t *SmokeTester) recordHTTPAttempt(
report *domain.SmokeTestReport,
initialEndpoint string,
evidence llm.HTTPExecutionEvidence,
startedAt time.Time,
duration time.Duration,
failure domain.ExecutionFailureKind,
credentialsAccepted *bool,
) {
if !evidence.DispatchInvoked {
return
}
reportDuration := duration
if report.Timing.RequestDuration == nil {
report.Timing.RequestDuration = &reportDuration
} else {
*report.Timing.RequestDuration += reportDuration
}
attempt := domain.SmokeTestAttempt{
Dispatched: true,
InitialEndpoint: initialEndpoint,
Endpoint: evidence.Endpoint,
Failure: failure,
Usage: domain.ObservedTokenUsage{},
Facts: httpExecutionFacts(evidence),
Timing: domain.SmokeTestTiming{
StartedAt: startedAt,
Duration: duration,
RequestDuration: &duration,
},
}
if evidence.HTTPStatus != nil {
status := *evidence.HTTPStatus
attempt.HTTPStatus = &status
}
attempt.Facts.CredentialsAccepted = cloneOptionalBool(credentialsAccepted)
report.Attempts = append(report.Attempts, attempt)
}
func httpExecutionFacts(evidence llm.HTTPExecutionEvidence) domain.SmokeTestFacts {
var facts domain.SmokeTestFacts
if evidence.ResponseReceived || evidence.DispatchInvoked {
responseReceived := evidence.ResponseReceived
facts.ResponseReceived = &responseReceived
}
if evidence.DispatchInvoked {
reachable := evidence.ResponseReceived
facts.EndpointReachable = &reachable
}
if evidence.RequestObserved {
credentialsSupplied := evidence.CredentialsSupplied
facts.CredentialsSupplied = &credentialsSupplied
}
return facts
}
func smokeReportTarget(target domain.SmokeTestTarget) (domain.SmokeTestTarget, error) {
cloned, err := domain.CloneSmokeTestReport(domain.SmokeTestReport{Target: target})
if err != nil {
return domain.SmokeTestTarget{}, fmt.Errorf("clone smoke-test target settings: %w", err)
}
target = cloned.Target
target.EffectiveSettings.APIKey = ""
target.Endpoint, err = endpointWithoutUserInfo(target.Endpoint)
if err != nil {
return domain.SmokeTestTarget{}, fmt.Errorf("%w: invalid resolved diagnostic endpoint", ErrInvalidRequest)
}
target.EffectiveSettings.Endpoint, err = endpointWithoutUserInfo(target.EffectiveSettings.Endpoint)
if err != nil {
return domain.SmokeTestTarget{}, fmt.Errorf("%w: invalid resolved execution endpoint", ErrInvalidRequest)
}
return target, nil
}
func cloneOptionalBool(value *bool) *bool {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
func isContextFailure(failure domain.ExecutionFailureKind) bool {
return failure == domain.ExecutionFailureCanceled || failure == domain.ExecutionFailureDeadline
}
func nonnegativeDuration(duration time.Duration) time.Duration {
if duration < 0 {
return 0
}
return duration
}