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: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,13 @@ PII masking covers conversation text, tool results, assistant reasoning, tool-ca
argument string values, and `user`. Tool definitions, names, IDs, schemas,
metadata, provider options, and image/audio content are not scanned. Keep
personal data out of those fields even when masking is enabled.

Guest chat keys (accounts flagged `is_guest` by Metering) are narrowed by
`services.GuestPolicy`: only the models in `GUEST_MODELS` (default
`deepseek/deepseek-v4.1-flash`), requests up to `GUEST_MAX_REQUEST_BYTES`
(default 16384), output capped at `GUEST_MAX_OUTPUT_TOKENS` (default 1024) and
written into the request, and only plain chat fields with text content. The
reservation for a guest request is bounded by the bytes sent upstream rather
than the model's context window, so it fits a guest's small credit. Invalid
values stop the gateway at startup. The Nexus repository's
`docs/guest-chat.md` describes the whole design.
61 changes: 61 additions & 0 deletions apps/gateway/guest_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package main

import (
"fmt"
"strconv"
"strings"

"github.com/dappnode/dappnode-nexus-gateway/apps/gateway/internal/application/services"
)

// Guest chat defaults. At DeepSeek V4.1 Flash's prices the largest guest
// request (16 KiB in, 1,024 tokens out) reserves about 0.7 cents, inside the
// 1-cent credit the control plane grants each guest; shorter messages reserve
// proportionally less. Change these together with that credit; see the Nexus
// repo's docs/guest-chat.md.
const (
defaultGuestModels = "deepseek/deepseek-v4.1-flash"
defaultGuestMaxRequestBytes = 16 << 10
defaultGuestMaxOutputTokens = 1024
)

// guestPolicyFromEnv reads the guest chat limits. A set but invalid value is
// an error, so a typo cannot loosen them.
func guestPolicyFromEnv(getenv func(string) string) (services.GuestPolicy, error) {
policy := services.GuestPolicy{Models: map[string]bool{}}
models := defaultGuestModels
if v, set := lookup(getenv, "GUEST_MODELS"); set {
models = v
}
for _, model := range strings.Split(models, ",") {
if model = strings.TrimSpace(model); model != "" {
policy.Models[model] = true
}
}

var err error
if policy.MaxRequestBytes, err = positiveIntEnv(getenv, "GUEST_MAX_REQUEST_BYTES", defaultGuestMaxRequestBytes); err != nil {
return services.GuestPolicy{}, err
}
if policy.MaxOutputTokens, err = positiveIntEnv(getenv, "GUEST_MAX_OUTPUT_TOKENS", defaultGuestMaxOutputTokens); err != nil {
return services.GuestPolicy{}, err
}
return policy, nil
}

func lookup(getenv func(string) string, name string) (string, bool) {
v := strings.TrimSpace(getenv(name))
return v, v != ""
}

func positiveIntEnv(getenv func(string) string, name string, fallback int) (int, error) {
v, set := lookup(getenv, name)
if !set {
return fallback, nil
}
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return 0, fmt.Errorf("%s must be a positive whole number", name)
}
return n, nil
}
51 changes: 51 additions & 0 deletions apps/gateway/guest_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package main

import "testing"

func envFrom(values map[string]string) func(string) string {
return func(name string) string { return values[name] }
}

func TestGuestPolicyFromEnv_Defaults(t *testing.T) {
policy, err := guestPolicyFromEnv(envFrom(nil))
if err != nil {
t.Fatalf("guestPolicyFromEnv: %v", err)
}
if len(policy.Models) != 1 || !policy.Models[defaultGuestModels] {
t.Fatalf("models = %v", policy.Models)
}
if policy.MaxRequestBytes != 16384 || policy.MaxOutputTokens != 1024 {
t.Fatalf("limits = %d bytes, %d tokens", policy.MaxRequestBytes, policy.MaxOutputTokens)
}
}

func TestGuestPolicyFromEnv_ReadsOverrides(t *testing.T) {
policy, err := guestPolicyFromEnv(envFrom(map[string]string{
"GUEST_MODELS": " a/model , b/model ,",
"GUEST_MAX_REQUEST_BYTES": "8192",
"GUEST_MAX_OUTPUT_TOKENS": "512",
}))
if err != nil {
t.Fatalf("guestPolicyFromEnv: %v", err)
}
if len(policy.Models) != 2 || !policy.Models["a/model"] || !policy.Models["b/model"] {
t.Fatalf("models = %v", policy.Models)
}
if policy.MaxRequestBytes != 8192 || policy.MaxOutputTokens != 512 {
t.Fatalf("limits = %d bytes, %d tokens", policy.MaxRequestBytes, policy.MaxOutputTokens)
}
}

// A typo must stop the gateway, not silently loosen or drop a guest limit.
func TestGuestPolicyFromEnv_RejectsInvalidLimits(t *testing.T) {
for _, env := range []map[string]string{
{"GUEST_MAX_REQUEST_BYTES": "16k"},
{"GUEST_MAX_REQUEST_BYTES": "0"},
{"GUEST_MAX_OUTPUT_TOKENS": "-1"},
{"GUEST_MAX_OUTPUT_TOKENS": "1e3"},
} {
if _, err := guestPolicyFromEnv(envFrom(env)); err == nil {
t.Errorf("accepted %v", env)
}
}
}
2 changes: 2 additions & 0 deletions apps/gateway/internal/adapters/metering/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func (c *Client) Reserve(ctx context.Context, auth domain.AuthContext, endpoint
FallbackUsed: req.FallbackUsed,
Stream: req.Stream,
MaxOutputTokens: req.MaxOutputTokens,
MaxInputTokens: req.MaxInputTokens,
}
var res reserveResponse
sent := time.Now() // A lease measured from here never outlasts Metering's.
Expand Down Expand Up @@ -420,6 +421,7 @@ type reserveRequest struct {
FallbackUsed *bool `json:"fallback_used,omitempty"`
Stream bool `json:"stream"`
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
MaxInputTokens *int `json:"max_input_tokens,omitempty"`
}

type reserveResponse struct {
Expand Down
32 changes: 32 additions & 0 deletions apps/gateway/internal/adapters/metering/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,3 +282,35 @@ func TestClientReserve_MapsDailyTokenLimitWithoutRetry(t *testing.T) {
t.Fatalf("quota error retried %d times", attempts.Load())
}
}

// The prompt bound the gateway computed for a guest reaches metering, which
// reserves for it instead of the model's context window.
func TestClientReserve_SendsTheInputTokenBound(t *testing.T) {
var got map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode: %v", err)
}
_, _ = w.Write([]byte(`{"reservation_id":"res-1"}`))
}))
defer server.Close()

bound, output := 1234, 50
req := domain.GenerateRequest{PublicModelID: "m", MaxInputTokens: &bound, MaxOutputTokens: &output}
if _, err := NewClient(server.URL, "token", time.Second).Reserve(context.Background(), domain.AuthContext{}, "chat_completions", req, domain.PublicModel{PublicModelID: "m"}, "req-1"); err != nil {
t.Fatalf("Reserve: %v", err)
}
if got["max_input_tokens"] != float64(1234) || got["max_output_tokens"] != float64(50) {
t.Fatalf("reservation body = %v", got)
}

// Without a bound the field is left out, so metering uses the context window.
got = nil
req.MaxInputTokens = nil
if _, err := NewClient(server.URL, "token", time.Second).Reserve(context.Background(), domain.AuthContext{}, "chat_completions", req, domain.PublicModel{PublicModelID: "m"}, "req-2"); err != nil {
t.Fatalf("Reserve: %v", err)
}
if _, present := got["max_input_tokens"]; present {
t.Fatalf("unbounded request sent max_input_tokens: %v", got)
}
}
2 changes: 2 additions & 0 deletions apps/gateway/internal/adapters/metering/runtime_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ func (c *Client) AuthenticateAPIKey(ctx context.Context, rawKey string) (domain.
ID: response.Account.ID,
ExternalCustomerID: response.Account.ExternalCustomerID,
Status: response.Account.Status,
IsGuest: response.Account.IsGuest,
},
APIKey: domain.APIKey{
ID: response.APIKey.ID,
Expand Down Expand Up @@ -413,6 +414,7 @@ type runtimeAccount struct {
ID string `json:"id"`
ExternalCustomerID *string `json:"external_customer_id,omitempty"`
Status string `json:"status"`
IsGuest bool `json:"is_guest"`
}

type runtimeAPIKey struct {
Expand Down
25 changes: 25 additions & 0 deletions apps/gateway/internal/adapters/metering/runtime_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,31 @@ func TestRuntimeClientAuthenticateHashesAPIKeyLocally(t *testing.T) {
}
}

// The guest flag decides whether the guest policy applies, so it must survive
// the trip from metering; an older metering that omits it means "not a guest".
func TestRuntimeClientAuthenticateReadsTheGuestFlag(t *testing.T) {
for _, tc := range []struct {
account string
want bool
}{
{`{"id":"acc-1","status":"active","is_guest":true}`, true},
{`{"id":"acc-1","status":"active","is_guest":false}`, false},
{`{"id":"acc-1","status":"active"}`, false},
} {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"account":` + tc.account + `,"api_key":{"id":"key-1","account_id":"acc-1","key_prefix":"nxs_1234","active":true,"pii_mode":"off"}}`))
}))
auth, err := NewClient(server.URL, "token", time.Second).AuthenticateAPIKey(context.Background(), "sk-x")
server.Close()
if err != nil {
t.Fatalf("AuthenticateAPIKey: %v", err)
}
if auth.Account.IsGuest != tc.want {
t.Fatalf("account %s: IsGuest = %v, want %v", tc.account, auth.Account.IsGuest, tc.want)
}
}
}

func TestRuntimeClientPreservesUserAuthenticationErrors(t *testing.T) {
tests := []struct {
name string
Expand Down
15 changes: 15 additions & 0 deletions apps/gateway/internal/application/services/generate_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type GenerateService struct {
piiLang string
piiFailOpen bool
tinfoilProofs ports.TinfoilTransportProofRepository
guest *GuestPolicy
logger ports.Logger
}

Expand Down Expand Up @@ -58,6 +59,20 @@ func (s *GenerateService) SetPIIOptions(language string, failOpen bool) {
s.piiFailOpen = failOpen
}

// SetGuestPolicy sets the limits for guest chat keys. Without a policy,
// guest keys are refused.
func (s *GenerateService) SetGuestPolicy(policy GuestPolicy) {
s.guest = &policy
}

// applyGuestPolicy narrows a guest's request; see GuestPolicy.
func (s *GenerateService) applyGuestPolicy(endpoint string, raw []byte, req domain.GenerateRequest) ([]byte, domain.GenerateRequest, error) {
if s.guest == nil {
return nil, req, domain.ErrGuestNotAllowed("this gateway")
}
return s.guest.Apply(endpoint, raw, req)
}

func (s *GenerateService) SetTinfoilProofRepository(proofs ports.TinfoilTransportProofRepository) {
s.tinfoilProofs = proofs
}
Expand Down
142 changes: 142 additions & 0 deletions apps/gateway/internal/application/services/guest_policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package services

import (
"bytes"
"encoding/json"
"fmt"

"github.com/dappnode/dappnode-nexus-gateway/pkg/domain"
)

// GuestPolicy narrows what a guest chat key can do. A guest is a landing-page
// visitor without a login, holding a key funded with a fraction of a cent
// (see the Nexus repo's docs/guest-chat.md).
//
// Metering refuses any request whose worst-case cost the guest's credit
// cannot cover. This policy keeps that worst case small and honest:
//
// - the model is one of Models, never a router or a pricier model;
// - the request is at most MaxRequestBytes, so the prompt is at most that
// many tokens: tokenizers emit at most one token per byte of text, and the
// JSON around the text is longer than the chat template that replaces it;
// - the output is at most MaxOutputTokens, written into the request so the
// provider enforces it too;
// - only plain chat fields and text content are accepted, so no provider
// option (reasoning budgets, tools, images) can raise the cost.
type GuestPolicy struct {
Models map[string]bool
MaxRequestBytes int
MaxOutputTokens int
}

// guestPromptOverheadTokens covers what reaches the model beyond the bytes
// the gateway reserved for: the chat template's role markers and the fields
// the provider adapter adds (model name, stream options).
const guestPromptOverheadTokens = 256

// guestRequestFields are the top-level request fields a guest may send.
var guestRequestFields = map[string]bool{
"model": true, "messages": true, "stream": true, "stream_options": true,
"max_tokens": true, "max_completion_tokens": true,
"temperature": true, "top_p": true, "presence_penalty": true, "frequency_penalty": true,
"stop": true, "seed": true, "user": true,
}

// guestMessageFields are the fields a guest message may carry.
var guestMessageFields = map[string]bool{"role": true, "content": true, "name": true}

// guestRoles are the message roles a guest may send.
var guestRoles = map[string]bool{"system": true, "user": true, "assistant": true}

// Apply checks a guest's request and returns the body to forward, with its
// output limit set, and the request summary updated to match.
func (p GuestPolicy) Apply(endpoint string, raw []byte, req domain.GenerateRequest) ([]byte, domain.GenerateRequest, error) {
if endpoint != domain.EndpointChatCompletions {
return nil, req, domain.ErrGuestNotAllowed("this endpoint")
}
if len(raw) > p.MaxRequestBytes {
return nil, req, domain.ErrGuestRequestTooLarge(p.MaxRequestBytes)
}
if !p.Models[req.PublicModelID] {
return nil, req, domain.ErrGuestNotAllowed(fmt.Sprintf("the model '%s'", req.PublicModelID))
}

dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber() // Forward numbers exactly as sent.
var body map[string]any
if err := dec.Decode(&body); err != nil || body == nil {
return nil, req, domain.ErrInvalidField("invalid JSON body")
}
for field := range body {
if !guestRequestFields[field] {
return nil, req, domain.ErrGuestNotAllowed(fmt.Sprintf("the field '%s'", field))
}
}
messages, ok := body["messages"].([]any)
if !ok {
return nil, req, domain.ErrInvalidField("messages must be an array")
}
for i, message := range messages {
if err := checkGuestMessage(i, message); err != nil {
return nil, req, err
}
}

limit := p.MaxOutputTokens
if req.MaxOutputTokens != nil && *req.MaxOutputTokens > 0 && *req.MaxOutputTokens < limit {
limit = *req.MaxOutputTokens
}
// Keep the client's field name; providers that need max_tokens get it
// from the provider adapter.
if _, ok := body["max_completion_tokens"]; ok {
body["max_completion_tokens"] = limit
} else {
body["max_tokens"] = limit
}
rewritten, err := json.Marshal(body)
if err != nil {
return nil, req, domain.ErrInternal("an internal error occurred").WithMeta("reason", "re-encode guest request", "error", err.Error())
}
req.MaxOutputTokens = &limit
return rewritten, req, nil
}

// PromptTokenBound is the most prompt tokens a guest request can reach the
// model with, given the exact body sent upstream (after PII masking).
func (p GuestPolicy) PromptTokenBound(upstreamBody []byte) int {
return len(upstreamBody) + guestPromptOverheadTokens
}

// checkGuestMessage accepts a system, user or assistant message whose content
// is text: a string, or text parts.
func checkGuestMessage(i int, message any) error {
fields, ok := message.(map[string]any)
if !ok {
return domain.ErrInvalidField(fmt.Sprintf("message[%d] must be an object", i))
}
for field := range fields {
if !guestMessageFields[field] {
return domain.ErrGuestNotAllowed(fmt.Sprintf("the message field '%s'", field))
}
}
if role, _ := fields["role"].(string); !guestRoles[role] {
return domain.ErrGuestNotAllowed(fmt.Sprintf("the message role '%v'", fields["role"]))
}
switch content := fields["content"].(type) {
case string:
return nil
case []any:
for _, part := range content {
p, ok := part.(map[string]any)
if !ok || p["type"] != "text" {
return domain.ErrGuestNotAllowed("images, audio or files")
}
if _, ok := p["text"].(string); !ok || len(p) != 2 {
return domain.ErrGuestNotAllowed("this message content")
}
}
return nil
default:
return domain.ErrInvalidField(fmt.Sprintf("message[%d] content must be text", i))
}
}
Loading
Loading