Skip to content

Commit 42bcd5f

Browse files
authored
feat: implement SystemOne endpoint and related functionality (#7)
1 parent e26bc87 commit 42bcd5f

16 files changed

Lines changed: 463 additions & 10 deletions

File tree

‎apps/gateway/internal/adapters/http/handlers/models_handler.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ func toRouterData(r domain.RouterEntry) dto.ModelData {
9595
}
9696

9797
func modelFeatures(m domain.PublicModel) []string {
98+
if m.SupportsEndpoint(domain.EndpointSystemOne) {
99+
return []string{"structured-outputs"}
100+
}
98101
features := make([]string, 0, 5)
99102
if m.SupportsChatCompletionsStream {
100103
features = append(features, "streaming")
@@ -120,6 +123,9 @@ func modelFeatures(m domain.PublicModel) []string {
120123

121124
func modelEndpoints(m domain.PublicModel) []string {
122125
endpoints := make([]string, 0, 1)
126+
if m.SupportsEndpoint(domain.EndpointSystemOne) {
127+
return []string{"systemone"}
128+
}
123129
if m.SupportsChatCompletions {
124130
endpoints = append(endpoints, "chat/completions")
125131
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package handlers
2+
3+
import (
4+
"io"
5+
"net/http"
6+
7+
"github.com/dappnode/dappnode-nexus-gateway/apps/gateway/internal/adapters/http/mapper"
8+
"github.com/dappnode/dappnode-nexus-gateway/apps/gateway/internal/application/ports"
9+
"github.com/dappnode/dappnode-nexus-gateway/apps/gateway/internal/application/services"
10+
"github.com/dappnode/dappnode-nexus-gateway/pkg/domain"
11+
)
12+
13+
type SystemOneHandler struct {
14+
service *services.GenerateService
15+
logger ports.Logger
16+
}
17+
18+
func NewSystemOneHandler(service *services.GenerateService, logger ports.Logger) *SystemOneHandler {
19+
return &SystemOneHandler{service: service, logger: logger}
20+
}
21+
22+
func (h *SystemOneHandler) Handle(w http.ResponseWriter, r *http.Request) {
23+
token, err := ExtractBearerToken(r)
24+
if err != nil {
25+
WriteErrorWithLog(w, r, h.logger, err)
26+
return
27+
}
28+
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxRequestBodySize))
29+
if err != nil {
30+
WriteErrorWithLog(w, r, h.logger, domain.ErrInvalidField("request body unreadable or too large"))
31+
return
32+
}
33+
req, err := mapper.SystemOneRequestToDomain(body)
34+
if err != nil {
35+
WriteErrorWithLog(w, r, h.logger, err)
36+
return
37+
}
38+
call, err := h.service.Proxy(r.Context(), domain.EndpointSystemOne, body, req, token)
39+
if err != nil {
40+
WriteErrorWithLog(w, r, h.logger, err)
41+
return
42+
}
43+
w.Header().Set("Content-Type", "application/json")
44+
_, _ = w.Write(call.Body)
45+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package mapper
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"strings"
7+
8+
"github.com/dappnode/dappnode-nexus-gateway/pkg/domain"
9+
)
10+
11+
// SystemOneRequestToDomain reads routing fields while preserving the native body.
12+
func SystemOneRequestToDomain(raw []byte) (domain.GenerateRequest, error) {
13+
var body struct {
14+
Model string `json:"model"`
15+
State json.RawMessage `json:"state"`
16+
Questions map[string]struct {
17+
Type string `json:"type"`
18+
Instructions json.RawMessage `json:"instructions"`
19+
Criteria json.RawMessage `json:"criteria"`
20+
} `json:"questions"`
21+
Stream bool `json:"stream"`
22+
}
23+
invalid := func(message string) (domain.GenerateRequest, error) {
24+
err := domain.ErrInvalidField(message)
25+
err.HTTPStatus = 422
26+
return domain.GenerateRequest{}, err
27+
}
28+
if json.Unmarshal(raw, &body) != nil {
29+
return invalid("invalid systemone request")
30+
}
31+
if strings.TrimSpace(body.Model) == "" {
32+
return invalid("model is required")
33+
}
34+
if body.Stream {
35+
return invalid("systemone does not support streaming")
36+
}
37+
if !structuredValue(body.State) {
38+
return invalid("state must be a string, object, or array")
39+
}
40+
if len(body.Questions) == 0 {
41+
return invalid("questions must be a non-empty object")
42+
}
43+
for _, q := range body.Questions {
44+
if !structuredValue(q.Instructions) {
45+
return invalid("question instructions must be a string, object, or array")
46+
}
47+
switch q.Type {
48+
case "noul":
49+
if len(q.Criteria) == 0 {
50+
continue
51+
}
52+
var criteria map[string]json.RawMessage
53+
if json.Unmarshal(q.Criteria, &criteria) != nil || criteria == nil {
54+
return invalid("noul criteria must be an object")
55+
}
56+
for key, value := range criteria {
57+
if (key != "true" && key != "false") || !structuredValue(value) {
58+
return invalid("noul criteria must describe true or false")
59+
}
60+
}
61+
case "choice":
62+
var criteria map[string]json.RawMessage
63+
if json.Unmarshal(q.Criteria, &criteria) != nil || len(criteria) < 1 || len(criteria) > 255 {
64+
return invalid("choice criteria must contain 1 to 255 options")
65+
}
66+
for _, value := range criteria {
67+
if !bytes.Equal(bytes.TrimSpace(value), []byte("null")) && !structuredValue(value) {
68+
return invalid("invalid choice criterion")
69+
}
70+
}
71+
case "score":
72+
var criteria []json.RawMessage
73+
if json.Unmarshal(q.Criteria, &criteria) != nil || len(criteria) < 2 || len(criteria) > 10 {
74+
return invalid("score criteria must contain 2 to 10 levels")
75+
}
76+
for _, value := range criteria {
77+
if !structuredValue(value) {
78+
return invalid("invalid score criterion")
79+
}
80+
}
81+
default:
82+
return invalid("question type must be noul, choice, or score")
83+
}
84+
}
85+
return domain.GenerateRequest{PublicModelID: body.Model, RequestedModelID: body.Model}, nil
86+
}
87+
88+
func structuredValue(raw json.RawMessage) bool {
89+
raw = bytes.TrimSpace(raw)
90+
return len(raw) > 0 && (raw[0] == '"' || raw[0] == '{' || raw[0] == '[')
91+
}

‎apps/gateway/internal/adapters/http/router.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ func NewRouter(
1313
health *handlers.HealthHandler,
1414
models *handlers.ModelsHandler,
1515
chatCompletions *handlers.ChatCompletionsHandler,
16+
systemOne *handlers.SystemOneHandler,
1617
confidentialChat *handlers.ConfidentialChatCompletionsHandler,
1718
tinfoil *handlers.TinfoilHandler,
1819
attestation *handlers.AttestationHandler,
@@ -23,6 +24,9 @@ func NewRouter(
2324
mux.HandleFunc("GET /healthz", health.Handle)
2425
mux.HandleFunc("GET /v1/models", models.Handle)
2526
mux.HandleFunc("POST /v1/chat/completions", chatCompletions.Handle)
27+
if systemOne != nil {
28+
mux.HandleFunc("POST /v1/systemone", systemOne.Handle)
29+
}
2630
if confidentialChat != nil {
2731
mux.HandleFunc("POST /v1/confidential/chat/completions", confidentialChat.Handle)
2832
}

‎apps/gateway/internal/adapters/http/router_test.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ func TestNewRouter_PreservesLegacyChatRouteWithoutConfidentialHandler(t *testing
2222
handlers.NewHealthHandler(),
2323
handlers.NewModelsHandler(nil),
2424
handlers.NewChatCompletionsHandler(nil, logger),
25+
handlers.NewSystemOneHandler(nil, logger),
2526
nil,
2627
nil,
2728
nil,
@@ -35,6 +36,12 @@ func TestNewRouter_PreservesLegacyChatRouteWithoutConfidentialHandler(t *testing
3536
t.Fatalf("legacy status = %d, want existing 401 auth behavior", legacyResponse.Code)
3637
}
3738

39+
evaluationResponse := httptest.NewRecorder()
40+
router.ServeHTTP(evaluationResponse, httptest.NewRequest(http.MethodPost, "/v1/systemone", strings.NewReader(`{}`)))
41+
if evaluationResponse.Code != http.StatusUnauthorized {
42+
t.Fatalf("systemone status = %d", evaluationResponse.Code)
43+
}
44+
3845
confidentialRequest := httptest.NewRequest(http.MethodPost, "/v1/confidential/chat/completions", strings.NewReader(`{"model":"test"}`))
3946
confidentialResponse := httptest.NewRecorder()
4047
router.ServeHTTP(confidentialResponse, confidentialRequest)

‎apps/gateway/internal/adapters/providers/openai/client.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ func NewClient(timeout time.Duration) *Client {
4545
}
4646

4747
func (c *Client) Do(ctx context.Context, baseURL, apiKey string, body map[string]any) ([]byte, error) {
48+
return c.DoEndpoint(ctx, baseURL, "/chat/completions", apiKey, body)
49+
}
50+
51+
// DoEndpoint uses the same bounded, enclave-aware transport for JSON APIs.
52+
func (c *Client) DoEndpoint(ctx context.Context, baseURL, endpoint, apiKey string, body map[string]any) ([]byte, error) {
4853
ctx, cancel := context.WithTimeout(ctx, c.responseTimeout)
4954
defer cancel()
5055

@@ -53,7 +58,7 @@ func (c *Client) Do(ctx context.Context, baseURL, apiKey string, body map[string
5358
return nil, fmt.Errorf("failed to marshal request: %w", err)
5459
}
5560

56-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(jsonBody))
61+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(baseURL, "/")+endpoint, bytes.NewReader(jsonBody))
5762
if err != nil {
5863
return nil, fmt.Errorf("failed to create request: %w", err)
5964
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package typesafe
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"os"
9+
"time"
10+
11+
"github.com/dappnode/dappnode-nexus-gateway/apps/gateway/internal/adapters/providers/openai"
12+
"github.com/dappnode/dappnode-nexus-gateway/apps/gateway/internal/application/ports"
13+
"github.com/dappnode/dappnode-nexus-gateway/pkg/domain"
14+
)
15+
16+
type Adapter struct{ client *openai.Client }
17+
18+
func NewAdapter(timeout time.Duration) *Adapter { return &Adapter{client: openai.NewClient(timeout)} }
19+
20+
func (a *Adapter) Complete(ctx context.Context, raw []byte, model domain.PublicModel) ([]byte, *domain.TinfoilTransportProof, error) {
21+
apiKey := os.Getenv(model.ProviderConfig.APIKeySecretRef)
22+
if apiKey == "" {
23+
return nil, nil, domain.ErrInternal("provider API key is not configured")
24+
}
25+
var body map[string]any
26+
dec := json.NewDecoder(bytes.NewReader(raw))
27+
dec.UseNumber()
28+
if err := dec.Decode(&body); err != nil || body == nil {
29+
return nil, nil, domain.ErrInvalidField("invalid JSON body")
30+
}
31+
body["model"] = model.UpstreamModelName
32+
delete(body, "provider_options")
33+
out, err := a.client.DoEndpoint(ctx, model.ProviderConfig.BaseURL, "/systemone", apiKey, body)
34+
var upstream *openai.ProviderHTTPError
35+
if errors.As(err, &upstream) && (upstream.StatusCode == 422 || upstream.StatusCode == 429 || upstream.StatusCode == 529) {
36+
return nil, nil, domain.ErrProviderError(upstream.StatusCode, "TypeSafe rejected the request").WithMeta("upstream_status", upstream.StatusCode)
37+
}
38+
return out, nil, openai.MapProviderErrorWithCompatibilityContext(err, model, body)
39+
}
40+
41+
func (a *Adapter) Stream(context.Context, []byte, domain.PublicModel) (ports.ProviderStream, error) {
42+
return ports.ProviderStream{}, domain.ErrUnsupportedFeature("streaming for systemone")
43+
}

‎apps/gateway/internal/application/services/pii.go‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import (
2525
// other static fields are not scanned. It returns the body unchanged and no
2626
// mapping when the key doesn't mask, the filter is off, or nothing was found.
2727
// On filter failure it fails closed, unless configured to fail open.
28-
func (s *GenerateService) maskBody(ctx context.Context, raw []byte, piiMode string) ([]byte, *domain.PIIMapping, error) {
28+
func (s *GenerateService) maskBody(ctx context.Context, raw []byte, piiMode, endpoint string) ([]byte, *domain.PIIMapping, error) {
2929
mode, ok := domain.NormalizeAPIKeyPIIMode(piiMode)
3030
if !ok || mode == domain.APIKeyPIIModeOff || s.pii == nil || !s.pii.Enabled() {
3131
return raw, nil, nil
@@ -43,7 +43,7 @@ func (s *GenerateService) maskBody(ctx context.Context, raw []byte, piiMode stri
4343
entityCounts: make(map[string]int),
4444
surfaceCounts: make(map[string]int),
4545
}
46-
if err := masker.maskBody(body); err != nil {
46+
if err := masker.maskBody(body, endpoint); err != nil {
4747
s.logger.Warn("pii filter error", "error", err, "fail_open", s.piiFailOpen)
4848
if s.piiFailOpen {
4949
return raw, nil, nil
@@ -66,7 +66,30 @@ func (s *GenerateService) maskBody(ctx context.Context, raw []byte, piiMode stri
6666
return masked, masker.mapping, nil
6767
}
6868

69-
func (m *requestPIIMasker) maskBody(body map[string]any) error {
69+
func (m *requestPIIMasker) maskBody(body map[string]any, endpoint string) error {
70+
if endpoint == domain.EndpointSystemOne {
71+
masked, _, err := m.maskAnyWithChanged("state", body["state"])
72+
if err != nil {
73+
return err
74+
}
75+
body["state"] = masked
76+
questions, _ := body["questions"].(map[string]any)
77+
for _, item := range questions {
78+
question, _ := item.(map[string]any)
79+
for _, field := range []string{"instructions", "criteria"} {
80+
value, exists := question[field]
81+
if !exists {
82+
continue
83+
}
84+
masked, _, err := m.maskAnyWithChanged(field, value)
85+
if err != nil {
86+
return err
87+
}
88+
question[field] = masked
89+
}
90+
}
91+
return nil
92+
}
7093
if user, ok := body["user"].(string); ok {
7194
masked, err := m.maskText("user", user)
7295
if err != nil {
@@ -346,6 +369,18 @@ func (u *unmasker) body(data []byte) []byte {
346369
if err != nil {
347370
return data
348371
}
372+
if answers, ok := obj["answers"].(map[string]any); ok {
373+
for _, item := range answers {
374+
answer, _ := item.(map[string]any)
375+
legend, _ := answer["legend"].(map[string]any)
376+
for key, value := range legend {
377+
if text, ok := value.(string); ok {
378+
legend[key] = domain.Unmask(text, u.mapping)
379+
u.track("legend", legend[key].(string))
380+
}
381+
}
382+
}
383+
}
349384
choices, _ := obj["choices"].([]any)
350385
for _, item := range choices {
351386
choice, _ := item.(map[string]any)

‎apps/gateway/internal/application/services/proxy.go‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ type ProxyCall struct {
3232
Body []byte
3333
}
3434

35-
// Proxy runs a chat-completions request. The gateway authenticates, routes,
35+
// Proxy runs an inference request. The gateway authenticates, routes,
3636
// validates, masks PII for keys that ask for it, reserves credit, and meters
3737
// the usage it reads from the response; it never rebuilds the request or the
3838
// response.
@@ -45,7 +45,13 @@ func (s *GenerateService) Proxy(ctx context.Context, endpoint string, raw []byte
4545
s.recordTerminalOutcome(metrics.OutcomeError, endpoint, req, nil, time.Since(start).Milliseconds())
4646
return nil, err
4747
}
48-
model, execReq, err := s.resolveModel(ctx, req)
48+
var model domain.PublicModel
49+
execReq := req
50+
if endpoint == domain.EndpointSystemOne {
51+
model, err = s.catalog.GetPublicModel(ctx, req.PublicModelID)
52+
} else {
53+
model, execReq, err = s.resolveModel(ctx, req)
54+
}
4955
if err != nil {
5056
s.recordTerminalOutcome(metrics.OutcomeError, endpoint, req, nil, time.Since(start).Milliseconds())
5157
return nil, err
@@ -55,7 +61,7 @@ func (s *GenerateService) Proxy(ctx context.Context, endpoint string, raw []byte
5561
s.recordFailure(ctx, nil, &authCtx, endpoint, &execReq, &model, err, nil, time.Since(start).Milliseconds())
5662
return nil, err
5763
}
58-
masked, mapping, err := s.maskBody(ctx, raw, authCtx.APIKey.PIIMode)
64+
masked, mapping, err := s.maskBody(ctx, raw, authCtx.APIKey.PIIMode, endpoint)
5965
if err != nil {
6066
s.recordTerminalOutcome(metrics.OutcomeError, endpoint, execReq, &model, time.Since(start).Milliseconds())
6167
s.recordFailure(ctx, nil, &authCtx, endpoint, &execReq, &model, err, nil, time.Since(start).Milliseconds())
@@ -76,6 +82,9 @@ func (s *GenerateService) Proxy(ctx context.Context, endpoint string, raw []byte
7682
}
7783

7884
attempt := func(target domain.PublicModel) (*ProxyCall, error) {
85+
if err := s.validateRequest(endpoint, execReq, target); err != nil {
86+
return nil, err
87+
}
7988
provider, err := s.registry.GetProvider(target.ProviderConfig.ProviderName)
8089
if err != nil {
8190
return nil, domain.ErrProviderUnavailable(target.ProviderConfig.ProviderName)
@@ -87,7 +96,13 @@ func (s *GenerateService) Proxy(ctx context.Context, endpoint string, raw []byte
8796
if err != nil {
8897
return nil, err
8998
}
90-
rewritten, o, err := observeJSON(body, target.PublicModelID, unmask(target))
99+
var rewritten []byte
100+
var o outcome
101+
if endpoint == domain.EndpointSystemOne {
102+
rewritten, o, err = observeSystemOne(body, target.PublicModelID, unmask(target))
103+
} else {
104+
rewritten, o, err = observeJSON(body, target.PublicModelID, unmask(target))
105+
}
91106
if err != nil {
92107
return nil, err
93108
}
@@ -116,7 +131,8 @@ func (s *GenerateService) Proxy(ctx context.Context, endpoint string, raw []byte
116131

117132
call, err := attempt(model)
118133
executed := model
119-
if err != nil && shouldTryFallback(ctx, err, model.Fallback) {
134+
if err != nil && shouldTryFallback(ctx, err, model.Fallback) &&
135+
withProviderTarget(model, *model.Fallback).SupportsEndpoint(endpoint) {
120136
executed = withProviderTarget(model, *model.Fallback)
121137
s.logFallback(requestID, model, executed, sanitizeErrorWithPIIMapping(err, mapping))
122138
call, err = attempt(executed)

0 commit comments

Comments
 (0)