Skip to content
Closed
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
Loading
Loading