Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cmd/gomodel/docs/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion docs/openapi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions internal/admin/dashboard/static/dist/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 15 additions & 2 deletions internal/auditlog/auditlog.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,13 @@ type LogData struct {
Attempts []AttemptSnapshot `json:"attempts,omitempty" bson:"attempts,omitempty"`

// RequestRevisions captures the ingress request-rewrite chain: one entry
// per registered rewriter that changed the body, in application order.
// per registered rewriter that ran, in application order. Rewriters that
// changed the body carry the rewritten body; those that left it alone are
// recorded with NoChange so the audit trail still shows the step ran.
// RequestBody always remains the original client request; the last
// revision is what was forwarded downstream.
// changed revision is what was forwarded downstream — when every rewriter
// was a no-op there is no such revision and the original body is what
// went upstream.
RequestRevisions []RequestRevisionSnapshot `json:"request_revisions,omitempty" bson:"request_revisions,omitempty"`

// Request parameters
Expand Down Expand Up @@ -206,6 +210,15 @@ type RequestRevisionSnapshot struct {
// Detail is an optional rewriter-provided structured summary of what
// changed (for example a compression block report).
Detail any `json:"detail,omitempty" bson:"detail,omitempty"`

// NoChange marks a rewriter that ran and left the body untouched. Such
// revisions record the step for operators — BytesAfter equals BytesBefore,
// Body is empty and TokensSaved is zero, though Detail may explain why
// nothing changed — but are not part of the chain that produced the
// forwarded request. Absent on entries written before no-change steps were
// tracked, which is why the flag is positive: an old revision always
// changed the body.
NoChange bool `json:"no_change,omitempty" bson:"no_change,omitempty"`
}

// AttemptSnapshot stores one external provider attempt made for a logical
Expand Down
55 changes: 42 additions & 13 deletions internal/server/request_rewrite.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,21 @@ func RequestRewriteMiddleware(rewriters []ext.RequestRewriter, auditLogger audit
if rwErr != nil {
return handleError(c, rewriterGatewayError(rw.Name(), rwErr))
}
if res == nil {
if res != nil {
applyRewriteResponseHeaders(c, res.ResponseHeader)
}
if res == nil || res.Body == nil {
// The rewriter ran and left the request alone. Record the
// step anyway so the audit trail distinguishes "compression

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No-change detail is discarded

When a rewriter returns a nil body with structured Detail, this branch records an unchanged revision without passing that detail, preventing the audit trail from explaining why the rewriter made no change.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

// found nothing" from "compression never ran".
recordUnchangedRequestRevision(c, auditLogger, rw.Name(), len(in.Body), res)
continue
}
applyRewriteResponseHeaders(c, res.ResponseHeader)
if res.Body != nil {
recordRequestRevision(c, auditLogger, rw.Name(), len(in.Body), res)
in.Body = res.Body
changed = true
if res.TokensSaved > 0 {
tokensSaved += res.TokensSaved
}
recordRequestRevision(c, auditLogger, rw.Name(), len(in.Body), res)
in.Body = res.Body
changed = true
if res.TokensSaved > 0 {
tokensSaved += res.TokensSaved
}
}

Expand Down Expand Up @@ -125,13 +129,10 @@ func applyRewrittenBody(c *echo.Context, body []byte) {
// change detail, and — only when body logging is enabled and the body is
// within the capture limit — the rewritten body itself.
func recordRequestRevision(c *echo.Context, auditLogger auditlog.LoggerInterface, name string, bytesBefore int, res *ext.Result) {
if auditLogger == nil {
if !auditCaptureEnabled(auditLogger) {
return
}
cfg := auditLogger.Config()
if !cfg.Enabled {
return
}

revision := auditlog.RequestRevisionSnapshot{
Rewriter: name,
Expand All @@ -146,6 +147,34 @@ func recordRequestRevision(c *echo.Context, auditLogger auditlog.LoggerInterface
auditlog.EnrichEntryWithRequestRevision(c, revision)
}

// recordUnchangedRequestRevision appends a no-change entry to the audit
// trail's request-revision chain: the rewriter ran, inspected the request and
// forwarded it byte-identical. The entry carries no body — there is no new one
// — and exists so operators can tell a rewriter that found nothing to do from
// one that never ran at all. res is nil when the rewriter declined outright;
// when it returned a result without a body, its Detail is kept, since that is
// where a rewriter explains why it changed nothing.
func recordUnchangedRequestRevision(c *echo.Context, auditLogger auditlog.LoggerInterface, name string, bytes int, res *ext.Result) {
if !auditCaptureEnabled(auditLogger) {
return
}
revision := auditlog.RequestRevisionSnapshot{
Rewriter: name,
BytesBefore: bytes,
BytesAfter: bytes,
NoChange: true,
}
if res != nil {
revision.Detail = res.Detail
}
auditlog.EnrichEntryWithRequestRevision(c, revision)
}

// auditCaptureEnabled reports whether an audit logger is present and enabled.
func auditCaptureEnabled(auditLogger auditlog.LoggerInterface) bool {
return auditLogger != nil && auditLogger.Config().Enabled
}

// pinOriginalAuditRequestBody captures the pre-rewrite request into the live
// audit entry so audit logs always record what the client sent. It respects
// the audit logger's header/body capture configuration and is a no-op when
Expand Down
75 changes: 75 additions & 0 deletions internal/server/request_rewrite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,81 @@ func TestRequestRewriteMiddlewareRecordsRevisions(t *testing.T) {
})
}

func TestRequestRewriteMiddlewareRecordsNoChangeRevisions(t *testing.T) {
// A rewriter that inspects the request and forwards it untouched is
// still a step operators need to see, so it gets a no-change revision.
quiet := &stubRewriter{name: "quiet"}
// Response headers and a structured detail without a body change (a
// rewriter annotating why it did nothing) must not turn the step into a
// real revision — but the detail is the explanation, so it is kept.
annotating := &stubRewriter{
name: "annotating",
rewrite: func(ext.Input) (*ext.Result, error) {
header := http.Header{}
header.Set("X-Test-Rewriter", "skipped")
return &ext.Result{
ResponseHeader: header,
Detail: map[string]any{"reason": "nothing to compress"},
}, nil
},
}

auditLogger := &capturingAuditLogger{config: auditlog.Config{Enabled: true, LogBodies: true}}
srv := New(newRewriteTestProvider(), &Config{
AuditLogger: auditLogger,
RequestRewriters: []ext.RequestRewriter{
quiet,
replaceBodyRewriter("swap", "PING", "PONG"),
annotating,
},
})
rec := postJSON(t, srv, "/v1/chat/completions",
`{"model":"gpt-4o-mini","messages":[{"role":"user","content":"PING"}]}`)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (%s)", rec.Code, rec.Body.String())
}
if rec.Header().Get("X-Test-Rewriter") != "skipped" {
t.Error("response headers from a no-change rewriter must still be applied")
}
if len(auditLogger.entries) == 0 {
t.Fatal("expected an audit entry")
}

revisions := auditLogger.entries[0].Data.RequestRevisions
if len(revisions) != 3 {
t.Fatalf("expected 3 revisions (2 no-change + 1 rewrite), got %d: %+v", len(revisions), revisions)
}
for i, want := range []struct {
rewriter string
noChange bool
}{{"quiet", true}, {"swap", false}, {"annotating", true}} {
got := revisions[i]
if got.Seq != i+1 || got.Rewriter != want.rewriter || got.NoChange != want.noChange {
t.Errorf("revision %d = %+v, want rewriter %q no_change=%v", i+1, got, want.rewriter, want.noChange)
}
}

quietRev := revisions[0]
if quietRev.BytesBefore == 0 || quietRev.BytesAfter != quietRev.BytesBefore {
t.Errorf("no-change revision must report equal sizes: %+v", quietRev)
}
if quietRev.Body != nil || quietRev.TokensSaved != 0 {
t.Errorf("no-change revision must carry no body or savings: %+v", quietRev)
}
// The trailing no-change step sees the body the previous rewriter produced.
if revisions[2].BytesBefore != revisions[1].BytesAfter {
t.Errorf("no-change revision must measure the current body: %+v", revisions[2])
}
// A rewriter that reports why it changed nothing keeps that explanation.
detail, ok := revisions[2].Detail.(map[string]any)
if !ok || detail["reason"] != "nothing to compress" {
t.Errorf("no-change revision must keep the rewriter detail, got %+v", revisions[2].Detail)
}
if quietRev.Detail != nil {
t.Errorf("a rewriter that returned no result has no detail to record: %+v", quietRev)
}
}
Comment on lines +440 to +513

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Make the new behavior coverage table-driven.

Model the nil-result, header-only/nil-body, and body-rewrite cases as table entries/subtests. As per coding guidelines, “Add or update table-driven tests for behavior changes.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/server/request_rewrite_test.go` around lines 440 - 501, The
TestRequestRewriteMiddlewareRecordsNoChangeRevisions test should use
table-driven subtests to cover nil-result, header-only/nil-body, and
body-rewrite rewriter behaviors. Refactor the existing inline rewriters and
assertions into behavior-focused table entries while preserving verification of
revision metadata, response headers, body sizes, and savings.

Source: Coding guidelines


func TestRequestRewriteMiddlewareStoresTokensSavedInContext(t *testing.T) {
compressor := &stubRewriter{
name: "compressor",
Expand Down
16 changes: 16 additions & 0 deletions web/dashboard/src/pages/audit-logs/AuditPaneTabs.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@
>{p.pane.kind}</span
>
{/if}
{#each p.pane.noChangeSteps || [] as step (step.id)}
<span class="audit-step-pill" title={step.title}>{step.label}</span>
{/each}
{#if p.pane.savingsLabel}
<span
class="audit-savings-pill mono"
Expand Down Expand Up @@ -217,4 +220,17 @@
font-weight: 700;
letter-spacing: 0.02em;
}

/* An ingress rewriter that ran and changed nothing. Deliberately quieter
than the savings pill: it reports a step that happened, not a result. */
.audit-step-pill {
display: inline-flex;
align-items: center;
padding: 1px 7px;
border: 1px dashed var(--border);
border-radius: 999px;
color: var(--text-muted);
font-size: 11px;
letter-spacing: 0.02em;
}
</style>
33 changes: 31 additions & 2 deletions web/dashboard/src/pages/audit-logs/audit-logic.js
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,32 @@ export function auditRequestRevisions(entry) {
: [];
}

// auditChangedRequestRevisions returns the revisions that actually rewrote the
// body — the ones worth their own pane. Rewriters that ran and changed nothing
// are recorded too (no_change), but they get a pill on the Request tab instead.
export function auditChangedRequestRevisions(entry) {
return auditRequestRevisions(entry).filter(
(revision) => !(revision && revision.no_change),
);
}

// auditNoChangeSteps summarizes the rewriters that inspected the request and
// left it byte-identical, so the tab strip can show the step ran without
// spending a tab on an empty pane.
export function auditNoChangeSteps(entry) {
return auditRequestRevisions(entry)
.filter((revision) => revision && revision.no_change)
.map((revision) => {
const rewriter = String(revision.rewriter || "rewriter");
return {
id: "step-" + Number(revision.seq || 0),
rewriter,
label: rewriter + ": no change",
title: rewriter + " ran and forwarded the request unchanged",
};
});
}

// auditRevisionPercentLabel renders how much of the request body this revision
// removed (e.g. "-44%"), or '' when sizes are missing or the revision didn't
// shrink the body.
Expand All @@ -565,7 +591,7 @@ export function auditRevisionPercentLabel(revision) {
export function auditRequestRevisionPane(entry, revision) {
const body = revision && revision.body;
const hasBody = body != null && body !== "";
const single = auditRequestRevisions(entry).length <= 1;
const single = auditChangedRequestRevisions(entry).length <= 1;
const summary = {
rewriter: (revision && revision.rewriter) || "",
bytes:
Expand Down Expand Up @@ -721,6 +747,9 @@ export function auditRequestPane(entry, extractSegments) {
body: data && data.request_body,
bodyCacheRatioLabel: auditCacheRatioPillLabel(entry),
promptCacheHighlight: auditPromptCacheHighlight(entry, extractSegments),
// Ingress rewriters that ran without changing anything, rendered as muted
// pills on the tab so the step is visible but costs no tab.
noChangeSteps: auditNoChangeSteps(entry),
showEmpty: empty && !pending,
emptyMessage: "Request details were not captured.",
showPending: pending,
Expand Down Expand Up @@ -767,7 +796,7 @@ export function auditResponsePane(entry) {
// either the single response or one pane per provider attempt.
export function auditPanes(entry, extractSegments) {
const panes = [{ id: "request", pane: auditRequestPane(entry, extractSegments) }];
auditRequestRevisions(entry).forEach((revision) => {
auditChangedRequestRevisions(entry).forEach((revision) => {
panes.push({
id: "revision-" + Number((revision && revision.seq) || 0),
pane: auditRequestRevisionPane(entry, revision),
Expand Down
Loading