Files
artex/llmrec/capture.go
T
dela 0335d572de
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
First Commit
2026-10-09 08:38:16 +08:00

163 lines
4.5 KiB
Go

package llmrec
import (
"context"
"fmt"
"io"
"strings"
"sync"
)
type captureContextKey struct{}
// Capture collects the untouched wire bodies of one logical LLM call. The
// Recorder creates it and puts it on the context; the HTTP transport that norma
// dials through (agent.quotaAwareTransport) finds it there and fills it in.
//
// This exists because everything the Recorder itself sees is already normalized:
// llm.CompletionRequest is re-serialized rather than the body buildBody() sent,
// and the response arrives as decoded StreamEvents, not the SSE frames. For
// debugging a live provider, the bytes on the wire are the only ground truth.
//
// norma's doStream retries the request-establishment phase, so one Stream can
// issue several HTTP attempts. Each attempt is kept: the discarded ones (see
// norma/llm/retry.go, which closes non-final bodies unread) are exactly what
// makes rate-limit and gateway failures diagnosable.
//
// The transport writes from norma's stream-reading goroutine while the Recorder
// snapshots at stream end, so all state is mutex-guarded.
type Capture struct {
mu sync.Mutex
request string
attempts []*attempt
}
type attempt struct {
status int
body strings.Builder
}
// NewCapture returns a context carrying a fresh Capture, plus the Capture itself.
func NewCapture(ctx context.Context) (context.Context, *Capture) {
c := &Capture{}
return context.WithValue(ctx, captureContextKey{}, c), c
}
// CaptureFrom returns the Capture attached to ctx, or nil when raw capture is
// off. Callers must tolerate nil — recording is a toggle, and non-recorded
// providers dial through the same transport.
func CaptureFrom(ctx context.Context) *Capture {
if ctx == nil {
return nil
}
c, _ := ctx.Value(captureContextKey{}).(*Capture)
return c
}
// SetRequest stores the outgoing request body. Retries re-send identical bytes,
// so only the first attempt's body is kept.
func (c *Capture) SetRequest(body string) {
if c == nil || body == "" {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if c.request == "" {
c.request = body
}
}
// TeeResponse opens a new attempt and wraps rc so everything read from it is
// mirrored into that attempt. It tees rather than reads because a successful
// response is an SSE stream that must keep streaming to the caller.
func (c *Capture) TeeResponse(status int, rc io.ReadCloser) io.ReadCloser {
if c == nil || rc == nil {
return rc
}
a := &attempt{status: status}
c.mu.Lock()
c.attempts = append(c.attempts, a)
c.mu.Unlock()
return &teeBody{rc: rc, c: c, a: a}
}
// RawRequest returns the request body as sent, or "" if nothing was captured.
func (c *Capture) RawRequest() string {
if c == nil {
return ""
}
c.mu.Lock()
defer c.mu.Unlock()
return c.request
}
// RawResponse returns the response bytes as received. A single attempt yields
// the untouched original (copy-pasteable straight into a replay); multiple
// attempts are concatenated behind per-attempt header lines so a retry sequence
// stays readable.
func (c *Capture) RawResponse() string {
if c == nil {
return ""
}
c.mu.Lock()
defer c.mu.Unlock()
switch len(c.attempts) {
case 0:
return ""
case 1:
return c.attempts[0].body.String()
}
var b strings.Builder
for i, a := range c.attempts {
fmt.Fprintf(&b, "===== attempt %d/%d — HTTP %d =====\n", i+1, len(c.attempts), a.status)
body := a.body.String()
b.WriteString(body)
if !strings.HasSuffix(body, "\n") {
b.WriteByte('\n')
}
}
return b.String()
}
// Attempt is one HTTP round trip's status code and response bytes.
type Attempt struct {
Status int
Body string
}
// Attempts returns every round trip in order. Unlike RawResponse — which drops
// the header line for a lone attempt so the bytes stay replayable — this always
// carries the status code, for callers that must report "HTTP 401 + body".
func (c *Capture) Attempts() []Attempt {
if c == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
out := make([]Attempt, 0, len(c.attempts))
for _, a := range c.attempts {
out = append(out, Attempt{Status: a.status, Body: a.body.String()})
}
return out
}
// teeBody mirrors reads into a Capture attempt, guarded by the Capture's mutex
// so a snapshot taken mid-stream never races the writer.
type teeBody struct {
rc io.ReadCloser
c *Capture
a *attempt
}
func (t *teeBody) Read(p []byte) (int, error) {
n, err := t.rc.Read(p)
if n > 0 {
t.c.mu.Lock()
t.a.body.Write(p[:n])
t.c.mu.Unlock()
}
return n, err
}
func (t *teeBody) Close() error { return t.rc.Close() }