Retain generation response evidence

This commit is contained in:
2026-10-13 16:47:26 +00:00
parent 6eeb0ee9c0
commit d91afc61a4
3 changed files with 296 additions and 15 deletions

View File

@@ -101,24 +101,29 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera
}
// PreparedGeneration holds one encoded request and resolved authentication for
// repeated attempts. Each Execute call creates a fresh HTTP request and context.
// repeated attempts. Each execution creates a fresh HTTP request and context.
type PreparedGeneration struct {
endpoint string
payload []byte
apiKey string
timeoutSeconds int
httpClient *http.Client
endpoint string
payload []byte
apiKey string
timeoutSeconds int
httpClient *http.Client
structuredOutputRequested bool
}
// AttemptOutcome describes one HTTP execution without exposing response bodies
// or headers to the orchestration layer.
type AttemptOutcome struct {
Response *domain.GenerateResponse
Err error
StatusCode int
Usage domain.TokenUsage
UsagePresence *domain.TokenUsagePresence
RetryNotBefore time.Time
Response *domain.GenerateResponse
Err error
StatusCode int
Usage domain.TokenUsage
UsagePresence *domain.TokenUsagePresence
RetryNotBefore time.Time
Evidence HTTPExecutionEvidence
StructuredOutputRequested *bool
ResponseDecoded bool
GenerationCompleted bool
}
// PrepareGeneration validates and freezes one built-in request before dispatch.
@@ -177,12 +182,23 @@ func (c *OpenAICompatibleClient) PrepareGeneration(req domain.GenerateRequest) (
return &PreparedGeneration{
endpoint: endpoint, payload: payload, apiKey: apiKey,
timeoutSeconds: req.Target.TimeoutSeconds, httpClient: httpClient,
structuredOutputRequested: wireReq.ResponseFormat != nil,
}, nil
}
// Execute dispatches one attempt. The returned response is non-nil only when
// the successful envelope contains a usable first choice.
func (p *PreparedGeneration) Execute(ctx context.Context) AttemptOutcome {
return p.execute(ctx, false)
}
// ExecuteWithEvidence performs one prepared attempt and retains HTTP and
// decoded-generation facts even when a later context check returns an error.
func (p *PreparedGeneration) ExecuteWithEvidence(ctx context.Context) AttemptOutcome {
return p.execute(ctx, true)
}
func (p *PreparedGeneration) execute(ctx context.Context, captureEvidence bool) AttemptOutcome {
requestContext := ctx
if p.timeoutSeconds > 0 {
var cancel context.CancelFunc
@@ -198,13 +214,29 @@ func (p *PreparedGeneration) Execute(ctx context.Context) AttemptOutcome {
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
}
httpResp, err := p.httpClient.Do(httpReq)
var (
httpResp *http.Response
outcome AttemptOutcome
)
if captureEvidence {
httpResp, err, outcome.Evidence = doWithEvidence(p.httpClient, httpReq)
if outcome.Evidence.DispatchInvoked {
requested := p.structuredOutputRequested
outcome.StructuredOutputRequested = &requested
}
} else {
httpResp, err = p.httpClient.Do(httpReq)
}
if httpResp != nil && (captureEvidence || err == nil) {
outcome.StatusCode = httpResp.StatusCode
}
if err != nil {
return AttemptOutcome{Err: &requestFailedError{cause: err}}
outcome.Err = &requestFailedError{cause: err}
return outcome
}
receivedAt := time.Now()
defer httpResp.Body.Close()
outcome := AttemptOutcome{StatusCode: httpResp.StatusCode}
outcome.StatusCode = httpResp.StatusCode
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
outcome.RetryNotBefore = retryNotBefore(httpResp.Header.Values("Retry-After"), receivedAt)
@@ -220,6 +252,11 @@ func (p *PreparedGeneration) Execute(ctx context.Context) AttemptOutcome {
}
if httpResp.ContentLength > maxOpenAIChatResponseBytes {
outcome.Err = openAIChatResponseTooLargeError()
if captureEvidence {
if contextErr := observedContextError(requestContext, ctx, outcome.Err); contextErr != nil {
outcome.Err = contextErr
}
}
return outcome
}
@@ -231,6 +268,7 @@ func (p *PreparedGeneration) Execute(ctx context.Context) AttemptOutcome {
}
return outcome
}
outcome.ResponseDecoded = true
outcome.Usage = domain.TokenUsage{
PromptTokens: tokenCount(wireResp.Usage.PromptTokens), CompletionTokens: tokenCount(wireResp.Usage.CompletionTokens),
TotalTokens: tokenCount(wireResp.Usage.TotalTokens), CachedTokens: tokenCount(wireResp.Usage.PromptTokensDetails.CachedTokens),
@@ -244,14 +282,30 @@ func (p *PreparedGeneration) Execute(ctx context.Context) AttemptOutcome {
if len(wireResp.Choices) == 0 {
outcome.Err = fmt.Errorf("%w: no choices returned", ErrMalformedResponse)
if captureEvidence {
if contextErr := observedContextError(requestContext, ctx, outcome.Err); contextErr != nil {
outcome.Err = contextErr
}
}
return outcome
}
content := wireResp.Choices[0].Message.Content
if content == nil {
outcome.Err = fmt.Errorf("%w: first choice has missing message content", ErrMalformedResponse)
if captureEvidence {
if contextErr := observedContextError(requestContext, ctx, outcome.Err); contextErr != nil {
outcome.Err = contextErr
}
}
return outcome
}
outcome.Response = &domain.GenerateResponse{Content: *content, Usage: outcome.Usage, UsagePresence: outcome.UsagePresence}
outcome.GenerationCompleted = true
if captureEvidence {
if contextErr := observedContextError(requestContext, ctx, nil); contextErr != nil {
outcome.Err = contextErr
}
}
return outcome
}