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
4 changes: 2 additions & 2 deletions internal/core/chat_json.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import "github.com/goccy/go-json"

// chatRequestFields is derived from the struct's json tags at package init so
// the known-field list cannot drift from the type definition.
var chatRequestFields = jsonFieldNames(ChatRequest{})
var chatRequestFields = jsonFieldSetOf(ChatRequest{})

// UnmarshalJSON decodes the typed fields via an alias (so new fields are
// picked up automatically) and captures every other member in ExtraFields.
Expand All @@ -15,7 +15,7 @@ func (r *ChatRequest) UnmarshalJSON(data []byte) error {
return err
}

extraFields, err := extractUnknownJSONFields(data, chatRequestFields...)
extraFields, err := extractUnknownJSONFieldsSet(data, chatRequestFields)
if err != nil {
return err
}
Expand Down
35 changes: 34 additions & 1 deletion internal/core/json_fields.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,24 @@ func (fields UnknownJSONFields) Without(keys ...string) UnknownJSONFields {
return UnknownJSONFields{raw: buf.Bytes()}
}

// jsonFieldSet answers "is this key a known typed field?" in O(1). Use it via
// jsonFieldSetOf for the derived struct lists (dozens of fields, checked once
// per JSON key of every decoded request); the variadic
// extractUnknownJSONFields stays optimal for the hand-listed callers with a
// handful of fields, where a linear scan beats a map lookup.
type jsonFieldSet map[string]struct{}

// jsonFieldSetOf derives the known-field set from v's struct definition, like
// jsonFieldNames but as a lookup set.
func jsonFieldSetOf(v any) jsonFieldSet {
names := jsonFieldNames(v)
set := make(jsonFieldSet, len(names))
for _, name := range names {
set[name] = struct{}{}
}
return set
}

// extractUnknownJSONFields captures the object's keys that are not in
// knownFields, preserving their raw bytes for passthrough (Postel's Law).
//
Expand All @@ -301,6 +319,21 @@ func (fields UnknownJSONFields) Without(keys ...string) UnknownJSONFields {
// benefit. The cheap first-byte and IsObject checks remain to reject non-object
// JSON explicitly.
func extractUnknownJSONFields(data []byte, knownFields ...string) (UnknownJSONFields, error) {
return extractUnknownJSONFieldsWith(data, func(key string) bool {
return slices.Contains(knownFields, key)
})
}

// extractUnknownJSONFieldsSet is extractUnknownJSONFields with a precomputed
// known-field set, for the struct-derived lists too large to scan per key.
func extractUnknownJSONFieldsSet(data []byte, known jsonFieldSet) (UnknownJSONFields, error) {
return extractUnknownJSONFieldsWith(data, func(key string) bool {
_, ok := known[key]
return ok
})
}

func extractUnknownJSONFieldsWith(data []byte, isKnown func(string) bool) (UnknownJSONFields, error) {
data = bytes.TrimSpace(data)
if len(data) == 0 || data[0] != '{' {
return UnknownJSONFields{}, fmt.Errorf("expected JSON object")
Expand All @@ -320,7 +353,7 @@ func extractUnknownJSONFields(data []byte, knownFields ...string) (UnknownJSONFi
buf.WriteByte('{')
wrote := false
root.ForEach(func(key, value gjson.Result) bool {
if slices.Contains(knownFields, key.String()) {
if isKnown(key.String()) {
return true
}
if wrote {
Expand Down
12 changes: 6 additions & 6 deletions internal/core/responses_json.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ import (
// captured as an unknown extra field. ContentSchema swagger phantoms share
// tags with real fields, which is harmless here (duplicates in the list).
var (
responsesRequestFields = jsonFieldNames(ResponsesRequest{})
responsesUtilityRequestFields = jsonFieldNames(ResponseInputTokensRequest{})
responsesOutputItemFields = jsonFieldNames(ResponsesOutputItem{})
responsesRequestFields = jsonFieldSetOf(ResponsesRequest{})
responsesUtilityRequestFields = jsonFieldSetOf(ResponseInputTokensRequest{})
responsesOutputItemFields = jsonFieldSetOf(ResponsesOutputItem{})
)

// responsesExtrasAndInput finishes a responses-shaped decode: it captures
// unknown members and decodes the raw input union.
func responsesExtrasAndInput(data []byte, rawInput json.RawMessage, knownFields []string) (any, UnknownJSONFields, error) {
extraFields, err := extractUnknownJSONFields(data, knownFields...)
func responsesExtrasAndInput(data []byte, rawInput json.RawMessage, knownFields jsonFieldSet) (any, UnknownJSONFields, error) {
extraFields, err := extractUnknownJSONFieldsSet(data, knownFields)
if err != nil {
return nil, UnknownJSONFields{}, err
}
Expand Down Expand Up @@ -328,7 +328,7 @@ func (i *ResponsesOutputItem) UnmarshalJSON(data []byte) error {
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
extraFields, err := extractUnknownJSONFields(data, responsesOutputItemFields...)
extraFields, err := extractUnknownJSONFieldsSet(data, responsesOutputItemFields)
if err != nil {
return err
}
Expand Down
17 changes: 6 additions & 11 deletions internal/server/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ func New(provider core.RoutableProvider, cfg *Config) *Server {
AllowOverwritingRoute: true,
NotFoundHandler: handleRouteNotFound,
}),
JSONSerializer: goJSONSerializer{},
})
e.Logger = slog.Default()
basePath := configuredBasePath(cfg)
Expand Down Expand Up @@ -311,19 +312,13 @@ func New(provider core.RoutableProvider, cfg *Config) *Server {
}
e.Use(middleware.BodyLimit(parseBodySizeLimitBytes(bodySizeLimit)))

// Request ID middleware (always active — ensures every request has a unique ID
// for usage tracking, audit logging, and response correlation)
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
req, id := ensureRequestID(c.Request())
c.SetRequest(req)
c.Response().Header().Set("X-Request-ID", id)
return next(c)
}
})
e.Use(modelInteractionWriteDeadlineMiddleware())

// Ingress capture (before auth/audit/model validation so they can consume shared raw request state)
// Ingress capture (before auth/audit/model validation so they can consume
// shared raw request state). Also assigns the per-request ID: the snapshot
// middleware runs unconditionally and calls ensureRequestID first thing, so
// a separate request-ID middleware would just repeat that work (a second
// context wrap + request copy) on every request.
userPathHeaderName := configuredUserPathHeader(cfg)
handler.userPathHeaderName = userPathHeaderName
e.Use(RequestSnapshotCapture(userPathHeaderName))
Expand Down
28 changes: 28 additions & 0 deletions internal/server/json_serializer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package server

import (
"github.com/goccy/go-json"
"github.com/labstack/echo/v5"
)

// goJSONSerializer serializes responses with goccy/go-json instead of echo's
// reflection-based encoding/json default. Response types already marshal with
// goccy elsewhere (internal/core), so this removes the second, slower encoder
// from the hot path. Deserialization keeps echo's default: request bodies are
// decoded through the core types, not c.Bind, so there is nothing to win there
// and the default's error mapping stays intact for the few admin binds.
type goJSONSerializer struct {
fallback echo.DefaultJSONSerializer
}

func (s goJSONSerializer) Serialize(c *echo.Context, target any, indent string) error {
enc := json.NewEncoder(c.Response())
if indent != "" {
enc.SetIndent("", indent)
}
return enc.Encode(target)
}

func (s goJSONSerializer) Deserialize(c *echo.Context, target any) error {
return s.fallback.Deserialize(c, target)
}
60 changes: 60 additions & 0 deletions internal/session/canonical_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package session

import (
"bytes"
encjson "encoding/json"
"testing"

"github.com/tidwall/gjson"
)

// TestCanonicalSegmentMatchesStdlib pins that goccy-based canonicalization
// produces byte-identical output to the original encoding/json implementation.
// Auto-detected session ids hash these bytes, so any divergence would silently
// re-anchor every in-flight conversation on upgrade (breaking virtual-model
// affinity pins and Pro compression epochs mid-session).
func TestCanonicalSegmentMatchesStdlib(t *testing.T) {
segments := []string{
`"gpt-4o-mini"`,
`"with \"escapes\" and é unicode 😀"`,
`"html <b>&amp;</b> specials"`,
`123`,
`1e2`,
`1.50`,
`-0.0031415926535897932384626433e4`,
`9007199254740993`,
`true`,
`null`,
`[]`,
`{}`,
`[1, "two", {"three": 3}, [4]]`,
`{"b":2,"a":1,"nested":{"z":null,"y":[1.0,"x"]}}`,
`{"role":"user","content":[{"type":"text","text":"line one\nline two\ttabbed"}]}`,
`{ "spaced" : { "keys" : [ 1 , 2 ] } }`,
}

for _, segment := range segments {
parsed := gjson.Parse(segment)
got := canonicalSegment(parsed)
want := stdlibCanonical(t, parsed.Raw)
if !bytes.Equal(got, want) {
t.Errorf("canonicalSegment(%s) = %s, stdlib canonical = %s", segment, got, want)
}
}
}

// stdlibCanonical is the pre-switch implementation, kept verbatim as the oracle.
func stdlibCanonical(t *testing.T, raw string) []byte {
t.Helper()
decoder := encjson.NewDecoder(bytes.NewReader([]byte(raw)))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return []byte(raw)
}
canonical, err := encjson.Marshal(value)
if err != nil {
return []byte(raw)
}
return canonical
}
25 changes: 11 additions & 14 deletions internal/session/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"strings"

"github.com/goccy/go-json"
"github.com/tidwall/gjson"

"github.com/enterpilot/gomodel/internal/core"
Expand Down Expand Up @@ -196,6 +196,14 @@ func rawSegment(result gjson.Result) json.RawMessage {
// equivalent string escapes must not split one conversation into multiple
// auto-detected sessions. UseNumber preserves number spelling/precision while
// arrays retain their original order.
//
// The goccy canonical bytes are byte-identical to encoding/json's (pinned by
// TestCanonicalSegmentMatchesStdlib), so auto-detected ids are stable across
// the library switch. The trailing-data guard must decode to io.EOF rather
// than check Decoder.More: More treats a stray closing bracket ("1]", "1}")
// as end of input, which would canonicalize malformed raw segments instead of
// falling back to their exact bytes. For valid input the extra decode reads
// only the empty remainder, so it costs nothing on the hot path.
func canonicalSegment(result gjson.Result) json.RawMessage {
raw := rawSegment(result)
if len(raw) == 0 {
Expand All @@ -207,7 +215,8 @@ func canonicalSegment(result gjson.Result) json.RawMessage {
if err := decoder.Decode(&value); err != nil {
return raw
}
if err := ensureJSONEOF(decoder); err != nil {
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return raw
}
canonical, err := json.Marshal(value)
Expand All @@ -216,15 +225,3 @@ func canonicalSegment(result gjson.Result) json.RawMessage {
}
return canonical
}

func ensureJSONEOF(decoder *json.Decoder) error {
var trailing any
err := decoder.Decode(&trailing)
if errors.Is(err, io.EOF) {
return nil
}
if err == nil {
return errors.New("multiple JSON values")
}
return err
}
4 changes: 4 additions & 0 deletions internal/session/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ func TestCanonicalSegmentFallsBackToExactRawJSON(t *testing.T) {
for _, raw := range []string{
`{"unterminated":`,
`{"first":1}{"second":2}`,
// Stray closing brackets: Decoder.More treats these as end of input,
// so the trailing-data guard must decode to io.EOF to catch them.
`1]`,
`1}`,
} {
result := gjson.Result{Type: gjson.JSON, Raw: raw}
if got := string(canonicalSegment(result)); got != raw {
Expand Down
18 changes: 18 additions & 0 deletions tests/perf/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,21 @@ bare one and is independent of catalog size.
`BenchmarkSharedStreamingObserversDefaultConfig` covers streaming observation
with audit body capture disabled (the default), where the observed stream
skips JSON decoding for chunks no observer wants.

## Production shape and ablation

`BenchmarkGatewayHotPathProductionShape` runs the routed path with the
default-deployment middleware chain fully wired: master-key auth, audit
logging (bodies + headers), usage tracking, session keeping, and a configured
rate limit. The guard enforces allocation ceilings on this case too, so
regressions in any of those subsystems fail CI even though the bare cases
cannot see them.

The `BenchmarkAblation*` family turns exactly one subsystem off relative to
that full shape; the delta against `BenchmarkAblationFull` attributes
per-request cost to that subsystem. These are diagnostic benchmarks (run via
`make perf-bench`), not guarded.

`TestSessionIDVisibilityByBodySize` pins that content-based session
auto-detection is independent of request body size — there is no size above
which a request quietly stops carrying a session id to downstream consumers.
85 changes: 85 additions & 0 deletions tests/perf/ablation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package perf

import (
"bytes"
"net/http"
"net/http/httptest"
"testing"

"github.com/enterpilot/gomodel/internal/auditlog"
"github.com/enterpilot/gomodel/internal/server"
"github.com/enterpilot/gomodel/internal/session"
"github.com/enterpilot/gomodel/internal/usage"
)

// ablation isolates the per-subsystem cost of the default-on middleware stack.
// Each variant turns exactly one subsystem off relative to the full
// production shape, so the delta attributes cost to that subsystem.
func benchAblation(b *testing.B, mutate func(*server.Config)) {
cfg := &server.Config{
LogOnlyModelInteractions: true,
MasterKey: "bench-master-key",
AuditLogger: benchAuditLogger{cfg: auditlog.Config{Enabled: true, LogBodies: true, LogHeaders: true}},
UsageLogger: benchUsageLogger{cfg: usage.Config{Enabled: true}},
SessionDetector: session.NewDetector(session.BuiltinRules(), true),
RateLimiter: newBenchRateLimiter(b),
}
mutate(cfg)

srv := server.New(newBenchRouter(b, routedCatalogSize), cfg)
body := []byte(sampleChatRequest)

b.ReportAllocs()
b.ResetTimer()

for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer bench-master-key")

rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
b.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
}
}

func BenchmarkAblationFull(b *testing.B) {
benchAblation(b, func(*server.Config) {})
}

// No content auto-detection: header/body rules still run, the sha256 conversation
// anchor does not.
func BenchmarkAblationNoAutoDetect(b *testing.B) {
benchAblation(b, func(c *server.Config) {
c.SessionDetector = session.NewDetector(session.BuiltinRules(), false)
})
}

func BenchmarkAblationNoSessionKeeping(b *testing.B) {
benchAblation(b, func(c *server.Config) { c.SessionDetector = nil })
}

func BenchmarkAblationNoAuditBodies(b *testing.B) {
benchAblation(b, func(c *server.Config) {
c.AuditLogger = benchAuditLogger{cfg: auditlog.Config{Enabled: true, LogHeaders: true}}
})
}

func BenchmarkAblationNoAudit(b *testing.B) {
benchAblation(b, func(c *server.Config) { c.AuditLogger = nil })
}

func BenchmarkAblationNoAuth(b *testing.B) {
benchAblation(b, func(c *server.Config) { c.MasterKey = "" })
}

func BenchmarkAblationNoUsage(b *testing.B) {
benchAblation(b, func(c *server.Config) { c.UsageLogger = nil })
}

func BenchmarkAblationNoRateLimit(b *testing.B) {
benchAblation(b, func(c *server.Config) { c.RateLimiter = nil })
}
Loading