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
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
163 lines
4.5 KiB
Go
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() }
|