归一化视图(request_body/response_body)会丢弃工具 schema、tool_use 块, 且看不到原始 SSE 帧,排查 provider 侧问题时信息不足。新增在 HTTP transport 层捕获真实 wire body 的能力: - llmrec: 新增 Capture,Recorder 挂到 ctx,quotaAwareTransport 填充。 请求取 GetBody 副本(不消费原 body),响应用 tee 保持流式;norma 内部重试 的多次尝试逐次保留(旧代码会读掉丢弃),单次时为字节级原文。 - db: llm_records 增加 raw_request/raw_response 两列,含旧库 ALTER 迁移。 - web: 录制页详情面板增加「原文」视图切换,及请求/响应内容复制按钮 (兼容非安全上下文的 execCommand 回退)。 - 测试: 单元(capture)、端到端(逐字节比对捕获=服务器实际收到)、 迁移(真实 PG 事务回滚)、全链路(Recorder→provider→PG 落库)。
141 lines
3.9 KiB
Go
141 lines
3.9 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()
|
|
}
|
|
|
|
// 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() }
|