Skip to content
Open
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
10 changes: 9 additions & 1 deletion docs/advanced/api-endpoints.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,15 @@ For request and response details, see the dedicated guides:
[Audio API](/advanced/audio-api), [Images API](/advanced/images-api), and
[Usage API](/advanced/usage-api).

## OpenAI-Compatible API
## Request body validation

GoModel reads the top-level `model`, `provider`, and `stream` members of a JSON
body to authorize and route the request. JSON parsers disagree on which value
wins when an object repeats a member, so a body that repeats any of these three
at the top level is rejected with `400 invalid_request_error` before anything
is authorized, cached, or forwarded. This applies to the OpenAI-compatible and
Anthropic-compatible `/v1` inference routes and to provider passthrough.
Repeated members nested inside other objects are left as sent.

| Endpoint | Method | Description |
| --------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------ |
Expand Down
6 changes: 6 additions & 0 deletions docs/features/passthrough-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ GoModel strips client `Authorization` and `X-Api-Key` headers before forwarding
the request, then applies the upstream provider credential configured on the
server. For Anthropic, GoModel uses its configured `ANTHROPIC_API_KEY`.

When the body is JSON, GoModel authorizes the top-level `model` it names and
then forwards the body byte-for-byte. A body that repeats a top-level `model`,
`provider`, or `stream` member is rejected with `400` instead, because the
upstream parser could otherwise pick a different value than the one GoModel
authorized.

Because passthrough is provider-native, the response is also provider-native.
For Anthropic messages, the response uses Anthropic's message schema, not an
OpenAI chat completion schema.
Expand Down
171 changes: 171 additions & 0 deletions internal/core/selector_uniqueness.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package core

import (
"fmt"
"slices"

"github.com/tidwall/gjson"
)

// DuplicateSelectorField reports the first top-level "model", "provider", or
// "stream" member that a JSON object body repeats, or "" when each appears at
// most once.
//
// JSON parsers disagree on which duplicate wins: gjson keeps the first
// occurrence, encoding/json and most provider parsers keep the last. A repeated
// selector could therefore be authorized as one model and executed as another,
// so the gateway rejects such bodies instead of picking a side.
//
// The scan walks only the top-level members and allocates nothing for the
// common case; it never panics on malformed input but reports "" for it, so
// callers validate the body separately.
func DuplicateSelectorField(body []byte) string {
i := skipJSONSpace(body, 0)
if i >= len(body) || body[i] != '{' {
return ""
}
i++
var modelSeen, providerSeen, streamSeen bool
for {
i = skipJSONSpace(body, i)
if i >= len(body) {
return ""
}
switch body[i] {
case '}':
return ""
case ',':
i++
continue
case '"':
default:
return ""
}

keyStart := i
i = skipJSONString(body, i)
if i < 0 {
return ""
}
name := jsonMemberName(body[keyStart:i])
var seen *bool
switch name {
case "model":
seen = &modelSeen
case "provider":
seen = &providerSeen
case "stream":
seen = &streamSeen
}
if seen != nil {
if *seen {
return name
}
*seen = true
}

i = skipJSONSpace(body, i)
if i >= len(body) || body[i] != ':' {
return ""
}
i = skipJSONValue(body, i+1)
if i < 0 {
return ""
}
}
}

// jsonMemberName returns the member name for a quoted JSON key. Keys without
// escapes are viewed in place; escaped keys are decoded so "model" still
// counts as "model".
func jsonMemberName(quoted []byte) string {
raw := quoted[1 : len(quoted)-1]
if slices.Contains(raw, '\\') {
return gjson.ParseBytes(quoted).Str
}
switch string(raw) {
case "model":
return "model"
case "provider":
return "provider"
case "stream":
return "stream"
default:
return ""
}
}

func skipJSONSpace(body []byte, i int) int {
for i < len(body) {
switch body[i] {
case ' ', '\t', '\n', '\r':
i++
default:
return i
}
}
return i
}

// skipJSONString returns the index just past the string starting at body[i],
// or -1 when it is unterminated.
func skipJSONString(body []byte, i int) int {
for i++; i < len(body); i++ {
switch body[i] {
case '\\':
i++
case '"':
return i + 1
}
}
return -1
}

// skipJSONValue returns the index just past the value starting at body[i]
// (after optional whitespace), or -1 when the value is unterminated.
func skipJSONValue(body []byte, i int) int {
i = skipJSONSpace(body, i)
if i >= len(body) {
return -1
}
switch body[i] {
case '"':
return skipJSONString(body, i)
case '{', '[':
depth := 0
for i < len(body) {
switch body[i] {
case '"':
i = skipJSONString(body, i)
if i < 0 {
return -1
}
continue
case '{', '[':
depth++
case '}', ']':
depth--
if depth == 0 {
return i + 1
}
}
i++
}
return -1
default:
for i < len(body) {
switch body[i] {
case ',', '}', ']', ' ', '\t', '\n', '\r':
return i
}
i++
}
return i
}
}

// NewDuplicateSelectorFieldError is the invalid-request error returned for a
// body that repeats a top-level selector field.
func NewDuplicateSelectorFieldError(field string) *GatewayError {
return NewInvalidRequestError(fmt.Sprintf("duplicate top-level %q field in request body", field), nil)
}
50 changes: 50 additions & 0 deletions internal/core/selector_uniqueness_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package core

import "testing"

func TestDuplicateSelectorField(t *testing.T) {
tests := []struct {
name string
body string
want string
}{
{name: "unique selectors", body: `{"model":"gpt-5-mini","provider":"openai","stream":true}`, want: ""},
{name: "no selectors", body: `{"messages":[]}`, want: ""},
{name: "empty object", body: `{}`, want: ""},
{name: "duplicate model", body: `{"model":"allowed","model":"blocked"}`, want: "model"},
{name: "duplicate provider", body: `{"provider":"a","model":"m","provider":"b"}`, want: "provider"},
{name: "duplicate stream", body: `{"stream":true,"stream":false}`, want: "stream"},
{name: "first repeated field wins the report", body: `{"stream":true,"model":"a","stream":false,"model":"b"}`, want: "stream"},
{name: "duplicate with escaped key", body: `{"model":"allowed","mod\u0065l":"blocked"}`, want: "model"},
{name: "nested duplicates ignored", body: `{"model":"m","messages":[{"model":"a","model":"b"}],"x":{"stream":true,"stream":false}}`, want: ""},
{name: "duplicate other fields ignored", body: `{"model":"m","n":1,"n":2}`, want: ""},
{name: "pretty printed duplicate", body: "{\n \"model\" : \"a\" ,\n \"messages\": [ {\"x\": [1, 2]} ],\n \"model\" : \"b\"\n}", want: "model"},
{name: "values containing braces and escaped quotes", body: `{"model":"a{\"}","messages":[{"content":"[{]\\"}],"model":"b"}`, want: "model"},
{name: "scalar values of every kind", body: `{"n":1.5e3,"b":true,"z":null,"stream":false,"stream":true}`, want: "stream"},
{name: "array root", body: `[{"model":"a","model":"b"}]`, want: ""},
{name: "not json", body: `nope`, want: ""},
{name: "unterminated object", body: `{"model":"a","model":"b"`, want: "model"},
{name: "unterminated string", body: `{"model":"a`, want: ""},
{name: "unterminated nested value", body: `{"model":"a","x":[1,2`, want: ""},
{name: "missing colon", body: `{"model" "a"}`, want: ""},
{name: "empty body", body: ``, want: ""},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := DuplicateSelectorField([]byte(tt.body)); got != tt.want {
t.Fatalf("DuplicateSelectorField(%s) = %q, want %q", tt.body, got, tt.want)
}
})
}
}

func TestNewDuplicateSelectorFieldError(t *testing.T) {
err := NewDuplicateSelectorFieldError("model")
if err.StatusCode != 400 || err.Type != ErrorTypeInvalidRequest {
t.Fatalf("error = %+v, want 400 invalid_request", err)
}
if want := `duplicate top-level "model" field in request body`; err.Message != want {
t.Fatalf("message = %q, want %q", err.Message, want)
}
}
67 changes: 50 additions & 17 deletions internal/core/semantic.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,24 @@ type WhiteBoxPrompt struct {
// JSONBodyParsed reports that the captured request body was parsed as JSON
// (for selector peeking and/or canonical request decode).
JSONBodyParsed bool
// DuplicateSelectorField names the top-level selector member ("model",
// "provider", or "stream") that the captured body repeats. Such a body
// yields no selector hints; DuplicateSelectorError rejects the request.
DuplicateSelectorField string

cache map[semanticCacheKey]any
}

// DuplicateSelectorError returns the invalid-request error for a captured body
// that repeats a top-level selector field, or nil when the body was unique or
// not inspected.
func (env *WhiteBoxPrompt) DuplicateSelectorError() error {
if env == nil || env.DuplicateSelectorField == "" {
return nil
}
return NewDuplicateSelectorFieldError(env.DuplicateSelectorField)
}

// CachedChatRequest returns the cached canonical chat request, if present.
func (env *WhiteBoxPrompt) CachedChatRequest() *ChatRequest {
req, _ := cachedSemanticValue[*ChatRequest](env, semanticChatRequestKey)
Expand Down Expand Up @@ -253,11 +267,15 @@ func DeriveWhiteBoxPrompt(snapshot *RequestSnapshot) *WhiteBoxPrompt {
return env
}

model, provider, stream, parsed := deriveSnapshotSelectorHintsGJSON(trimmed)
if !parsed {
hints := deriveSnapshotSelectorHintsGJSON(trimmed)
if hints.duplicate != "" {
env.DuplicateSelectorField = hints.duplicate
return env
}
if !hints.parsed {
return env
}
ApplyBodySelectorHints(env, model, provider, stream)
ApplyBodySelectorHints(env, hints.model, hints.provider, hints.stream)

return env
}
Expand Down Expand Up @@ -389,48 +407,63 @@ func derivePassthroughRouteInfoFromTransport(snapshot *RequestSnapshot) *Passthr
return info
}

func deriveSnapshotSelectorHintsGJSON(body []byte) (model, provider string, stream, parsed bool) {
// snapshotSelectorHints is the sparse selector state peeked from a captured
// JSON body. duplicate names a repeated top-level selector field; when set the
// remaining fields are meaningless because the body is rejected.
type snapshotSelectorHints struct {
model string
provider string
stream bool
parsed bool
duplicate string
}

func deriveSnapshotSelectorHintsGJSON(body []byte) snapshotSelectorHints {
if !gjson.ValidBytes(body) {
return "", "", false, false
return snapshotSelectorHints{}
}

// GetBytes peeks without gjson.ParseBytes's full copy of the body; the
// leading byte is the object check the parse used to make.
if trimmed := bytes.TrimSpace(body); len(trimmed) == 0 || trimmed[0] != '{' {
return "", "", false, false
return snapshotSelectorHints{}
}

// gjson returns the first matching top-level field while encoding/json
// keeps the last. Bodies that repeat a selector field are rejected, so for
// every body that reaches the lookups below both parsers agree.
if field := DuplicateSelectorField(body); field != "" {
return snapshotSelectorHints{duplicate: field}
}

// gjson returns the first matching top-level field. That differs from
// encoding/json on duplicate keys, but the hot-path speedup is worth it here:
// duplicate selector keys are not expected from real clients, and we accept
// the first-match behavior to keep ingress peeking cheap.
modelResult := gjson.GetBytes(body, "model")
if !snapshotSelectorStringAllowed(modelResult) {
return "", "", false, false
return snapshotSelectorHints{}
}
providerResult := gjson.GetBytes(body, "provider")
if !snapshotSelectorStringAllowed(providerResult) {
return "", "", false, false
return snapshotSelectorHints{}
}
streamResult := gjson.GetBytes(body, "stream")
if !snapshotSelectorBoolAllowed(streamResult) {
return "", "", false, false
return snapshotSelectorHints{}
}

// GetBytes results own their strings (unlike Parse results, which alias
// the body), so these values can land in RouteHints — which lives on the
// request context for the whole, possibly streaming, request — without
// pinning a request-sized backing string.
hints := snapshotSelectorHints{parsed: true}
if modelResult.Type == gjson.String {
model = modelResult.String()
hints.model = modelResult.String()
}
if providerResult.Type == gjson.String {
provider = providerResult.String()
hints.provider = providerResult.String()
}
if streamResult.Type == gjson.True || streamResult.Type == gjson.False {
stream = streamResult.Bool()
hints.stream = streamResult.Bool()
}
return model, provider, stream, true
return hints
}

func snapshotSelectorStringAllowed(result gjson.Result) bool {
Expand Down
Loading