Skip to content

Commit e26bc87

Browse files
authored
feat: honor Doubleword model service tiers (#4)
1 parent ede2192 commit e26bc87

10 files changed

Lines changed: 102 additions & 3 deletions

File tree

‎apps/gateway/internal/adapters/http/mapper/chat_mapper_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ func read(t *testing.T, body string) (domain.GenerateRequest, error) {
1414
}
1515

1616
func TestChatCompletionRequestToDomain_ReadsTheSummary(t *testing.T) {
17-
req, err := read(t, `{"model":"m","stream":true,"max_completion_tokens":50,"parallel_tool_calls":false,
17+
req, err := read(t, `{"model":"m","stream":true,"max_completion_tokens":50,"service_tier":"flex","parallel_tool_calls":false,
1818
"response_format":{"type":"json_object"},"temperature":0.2,"unknown_extension":{"x":1},
1919
"messages":[{"role":"developer","content":"be brief"},
2020
{"role":"user","content":[{"type":"text","text":"What is "},{"type":"image_url","image_url":{"url":"data:image/png;base64,AA"}},{"type":"text","text":"this?"}]},
@@ -24,7 +24,7 @@ func TestChatCompletionRequestToDomain_ReadsTheSummary(t *testing.T) {
2424
if err != nil {
2525
t.Fatal(err)
2626
}
27-
if req.PublicModelID != "m" || !req.Stream || *req.MaxOutputTokens != 50 || *req.ParallelToolCalls || !req.StructuredOutput {
27+
if req.PublicModelID != "m" || !req.Stream || *req.MaxOutputTokens != 50 || req.ServiceTier == nil || *req.ServiceTier != "flex" || *req.ParallelToolCalls || !req.StructuredOutput {
2828
t.Fatalf("summary %+v", req)
2929
}
3030
if len(req.Input) != 4 || *req.Input[0].Role != "developer" || *req.Input[1].Content != "What is this?" || req.Input[2].Content != nil || *req.Input[3].Content != "42" {

‎apps/gateway/internal/adapters/http/mapper/chat_to_domain.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type chatRequest struct {
1919
Stream *bool `json:"stream"`
2020
MaxTokens *int `json:"max_tokens"`
2121
MaxCompletionTokens *int `json:"max_completion_tokens"`
22+
ServiceTier *string `json:"service_tier"`
2223
N *int `json:"n"`
2324
Tools []chatTool `json:"tools"`
2425
ToolChoice json.RawMessage `json:"tool_choice"`
@@ -77,6 +78,7 @@ func ChatCompletionRequestToDomain(raw json.RawMessage) (domain.GenerateRequest,
7778
PublicModelID: req.Model,
7879
Stream: req.Stream != nil && *req.Stream,
7980
MaxOutputTokens: req.MaxTokens,
81+
ServiceTier: req.ServiceTier,
8082
ParallelToolCalls: req.ParallelToolCalls,
8183
StructuredOutput: req.ResponseFormat != nil && (req.ResponseFormat.Type == "json_object" || req.ResponseFormat.Type == "json_schema"),
8284
}

‎apps/gateway/internal/adapters/metering/runtime_client.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ func (m runtimePublicModel) toDomain() domain.PublicModel {
330330
Description: m.Description,
331331
ProviderModelID: m.ProviderModelID,
332332
UpstreamModelName: m.UpstreamModelName,
333+
ServiceTier: m.ServiceTier,
333334
ProviderConfig: m.ProviderConfig.toDomain(),
334335
SupportsChatCompletions: *m.SupportsChatCompletions,
335336
SupportsChatCompletionsStream: *m.SupportsChatCompletionsStream,
@@ -349,6 +350,7 @@ func (m runtimePublicModel) toDomain() domain.PublicModel {
349350
model.Fallback = &domain.ProviderTarget{
350351
ProviderModelID: m.Fallback.ProviderModelID,
351352
UpstreamModelName: m.Fallback.UpstreamModelName,
353+
ServiceTier: m.Fallback.ServiceTier,
352354
ProviderConfig: m.Fallback.ProviderConfig.toDomain(),
353355
}
354356
}
@@ -418,6 +420,7 @@ type runtimePublicModel struct {
418420
Description *string `json:"description,omitempty"`
419421
ProviderModelID string `json:"provider_model_id"`
420422
UpstreamModelName string `json:"upstream_model_name"`
423+
ServiceTier *string `json:"service_tier,omitempty"`
421424
ProviderConfig runtimeProviderConfig `json:"provider_config"`
422425
Fallback *runtimeProviderTarget `json:"fallback,omitempty"`
423426
SupportsChatCompletions *bool `json:"supports_chat_completions"`
@@ -440,6 +443,7 @@ type runtimePublicModel struct {
440443
type runtimeProviderTarget struct {
441444
ProviderModelID string `json:"provider_model_id"`
442445
UpstreamModelName string `json:"upstream_model_name"`
446+
ServiceTier *string `json:"service_tier,omitempty"`
443447
ProviderConfig runtimeProviderConfig `json:"provider_config"`
444448
}
445449

‎apps/gateway/internal/adapters/metering/runtime_client_test.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,8 +249,10 @@ func TestRuntimeClientRejectsInvalidModelSuccess(t *testing.T) {
249249

250250
func TestRuntimeClientMapsProviderFallback(t *testing.T) {
251251
response := validRuntimePublicModel("openai/gpt-test")
252+
response.ServiceTier = stringPointer("priority")
252253
response.Fallback = &runtimeProviderTarget{
253254
ProviderModelID: "fallback-model", UpstreamModelName: "fallback-upstream",
255+
ServiceTier: stringPointer("flex"),
254256
ProviderConfig: runtimeProviderConfig{
255257
ID: "fallback-config", ProviderName: "fallback", BaseURL: "https://fallback.test",
256258
APIKeySecretRef: "FALLBACK_API_KEY", Active: true,
@@ -267,6 +269,10 @@ func TestRuntimeClientMapsProviderFallback(t *testing.T) {
267269
model.Fallback.ProviderConfig.ProviderName != "fallback" {
268270
t.Fatalf("fallback = %#v", model.Fallback)
269271
}
272+
if model.ServiceTier == nil || *model.ServiceTier != "priority" ||
273+
model.Fallback.ServiceTier == nil || *model.Fallback.ServiceTier != "flex" {
274+
t.Fatalf("service tiers = primary %v, fallback %v", model.ServiceTier, model.Fallback.ServiceTier)
275+
}
270276
}
271277

272278
func TestRuntimeClientModelListRequiresArrayAndValidUniqueItems(t *testing.T) {
@@ -566,6 +572,10 @@ func boolPointer(value bool) *bool {
566572
return &value
567573
}
568574

575+
func stringPointer(value string) *string {
576+
return &value
577+
}
578+
569579
func int64Pointer(value int64) *int64 {
570580
return &value
571581
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"io"
88
"net/http"
9+
"strings"
910

1011
"github.com/dappnode/dappnode-nexus-gateway/apps/gateway/internal/adapters/observability/metrics"
1112
"github.com/dappnode/dappnode-nexus-gateway/apps/gateway/internal/application/ports"
@@ -22,6 +23,7 @@ var gatewayOnlyFields = []string{"provider_options"}
2223
// provider extensions). The only edits are:
2324
//
2425
// - model: the provider's name for the public model;
26+
// - service_tier: fixed by the catalog for Doubleword models;
2527
// - stream_options.include_usage: on for streams, so usage can be metered;
2628
// - the output-token limit: clamped to the model's maximum, and sent as
2729
// max_tokens to the providers that only document that name (Novita,
@@ -41,6 +43,13 @@ func PrepareProxyBody(raw []byte, model domain.PublicModel, stream bool) (map[st
4143
var transforms []string
4244

4345
body["model"] = model.UpstreamModelName
46+
// Doubleword's realtime and async catalog entries select the upstream tier.
47+
// Never let a caller change the tier behind a priced public model.
48+
if model.ServiceTier != nil && strings.TrimSpace(*model.ServiceTier) != "" {
49+
body["service_tier"] = strings.TrimSpace(*model.ServiceTier)
50+
} else if strings.EqualFold(model.ProviderConfig.ProviderName, "doubleword") {
51+
delete(body, "service_tier")
52+
}
4453
for _, field := range gatewayOnlyFields {
4554
delete(body, field)
4655
}

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,41 @@ func TestPrepareProxyBody_ForwardsTheClientRequest(t *testing.T) {
5656
}
5757
}
5858

59+
func TestPrepareProxyBody_DoublewordServiceTier(t *testing.T) {
60+
for _, stream := range []bool{false, true} {
61+
for _, tc := range []struct {
62+
name string
63+
provider string
64+
configured *string
65+
client string
66+
want string
67+
present bool
68+
}{
69+
{name: "async", provider: "doubleword", configured: stringPtr(" flex "), client: "priority", want: "flex", present: true},
70+
{name: "realtime", provider: "doubleword", client: "priority"},
71+
{name: "other provider", provider: "openai", client: "priority", want: "priority", present: true},
72+
} {
73+
t.Run(tc.name, func(t *testing.T) {
74+
raw := `{"model":"public/model","messages":[],"service_tier":"` + tc.client + `"}`
75+
body, _, err := PrepareProxyBody([]byte(raw), domain.PublicModel{
76+
UpstreamModelName: "upstream",
77+
ProviderConfig: domain.ProviderConfig{ProviderName: tc.provider},
78+
ServiceTier: tc.configured,
79+
}, stream)
80+
if err != nil {
81+
t.Fatal(err)
82+
}
83+
got, ok := body["service_tier"]
84+
if ok != tc.present || ok && got != tc.want {
85+
t.Fatalf("service_tier = %v (present %v), want %q (present %v)", got, ok, tc.want, tc.present)
86+
}
87+
})
88+
}
89+
}
90+
}
91+
92+
func stringPtr(value string) *string { return &value }
93+
5994
func TestPrepareProxyBody_Edits(t *testing.T) {
6095
t.Run("streams always report usage", func(t *testing.T) {
6196
got := prepared(t, `{"messages":[],"stream":true,"stream_options":{"include_usage":false,"continuous_usage_stats":true}}`, "novita", true)

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ func shouldTryFallback(ctx context.Context, err error, fallback *domain.Provider
8181
func withProviderTarget(model domain.PublicModel, target domain.ProviderTarget) domain.PublicModel {
8282
model.ProviderModelID = target.ProviderModelID
8383
model.UpstreamModelName = target.UpstreamModelName
84+
model.ServiceTier = target.ServiceTier
8485
model.ProviderConfig = target.ProviderConfig
8586
model.Fallback = nil
8687
return model
@@ -202,6 +203,10 @@ func (s *GenerateService) validateRequest(endpoint string, req domain.GenerateRe
202203
return domain.ErrUnsupportedFeature("structured_output")
203204
}
204205

206+
if strings.EqualFold(model.ProviderConfig.ProviderName, "doubleword") && req.ServiceTier != nil {
207+
return domain.ErrInvalidField("service_tier is fixed by the selected Doubleword model")
208+
}
209+
205210
if model.EffectiveProofMode() == domain.ProofModeTinfoilAttestedTransport &&
206211
!strings.EqualFold(model.ProviderConfig.ProviderName, "tinfoil") {
207212
return domain.ErrUnsupportedFeature("Tinfoil verified transport for non-Tinfoil provider")

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

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,9 @@ func TestProxy_UsesConfiguredFallbackOnce(t *testing.T) {
239239
if meter.lastSuccessModel.ProviderConfig.ProviderName != "fallback" {
240240
t.Fatalf("metered provider = %q, want fallback", meter.lastSuccessModel.ProviderConfig.ProviderName)
241241
}
242+
if meter.lastSuccessModel.ServiceTier == nil || *meter.lastSuccessModel.ServiceTier != "flex" {
243+
t.Fatalf("metered service tier = %v, want flex", meter.lastSuccessModel.ServiceTier)
244+
}
242245
}
243246

244247
func TestProxy_ReturnsFallbackFailure(t *testing.T) {
@@ -299,6 +302,7 @@ func newDirectModelGenerateService(meter *stubUsageMeter, provider *stubProvider
299302
}
300303

301304
func newFallbackGenerateService(meter *stubUsageMeter, primary, fallback *stubProvider) *GenerateService {
305+
serviceTier := "flex"
302306
return NewGenerateService(
303307
&stubAuthService{authCtx: domain.AuthContext{
304308
Account: domain.Account{ID: "acc1", Status: domain.AccountStatusActive},
@@ -309,7 +313,7 @@ func newFallbackGenerateService(meter *stubUsageMeter, primary, fallback *stubPr
309313
ProviderModelID: "primary-model",
310314
UpstreamModelName: "primary-upstream",
311315
ProviderConfig: domain.ProviderConfig{ProviderName: "primary"},
312-
Fallback: &domain.ProviderTarget{ProviderModelID: "fallback-model", UpstreamModelName: "fallback-upstream", ProviderConfig: domain.ProviderConfig{ProviderName: "fallback"}},
316+
Fallback: &domain.ProviderTarget{ProviderModelID: "fallback-model", UpstreamModelName: "fallback-upstream", ServiceTier: &serviceTier, ProviderConfig: domain.ProviderConfig{ProviderName: "fallback"}},
313317
SupportsChatCompletions: true,
314318
SupportsChatCompletionsStream: true,
315319
MaxContextWindow: 1000,
@@ -377,6 +381,33 @@ func TestProxy_RejectsWhenBalanceIsEmpty(t *testing.T) {
377381
}
378382
}
379383

384+
func TestValidateRequest_RejectsCallerSelectedDoublewordServiceTier(t *testing.T) {
385+
svc := &GenerateService{}
386+
serviceTier := "flex"
387+
model := domain.PublicModel{
388+
PublicModelID: "doubleword/test-realtime",
389+
SupportsChatCompletions: true,
390+
ProviderConfig: domain.ProviderConfig{ProviderName: "doubleword"},
391+
}
392+
err := svc.validateRequest(domain.EndpointChatCompletions, domain.GenerateRequest{
393+
ServiceTier: &serviceTier,
394+
}, model)
395+
if err == nil {
396+
t.Fatal("expected caller-selected Doubleword service tier to be rejected")
397+
}
398+
gwErr, ok := err.(*domain.GatewayError)
399+
if !ok || gwErr.Code != domain.ErrCodeInvalidField {
400+
t.Fatalf("error = %#v, want invalid_field", err)
401+
}
402+
403+
model.ProviderConfig.ProviderName = "openai"
404+
if err := svc.validateRequest(domain.EndpointChatCompletions, domain.GenerateRequest{
405+
ServiceTier: &serviceTier,
406+
}, model); err != nil {
407+
t.Fatalf("non-Doubleword service tier rejected: %v", err)
408+
}
409+
}
410+
380411
func TestProxy_DoesNotRouteUnknownModel(t *testing.T) {
381412
router := &stubRouterClient{decision: domain.RouteDecision{PublicModelID: "minimax/minimax-m2.7"}}
382413
svc := NewGenerateService(

‎pkg/domain/generation.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type GenerateRequest struct {
2828

2929
Stream bool
3030
MaxOutputTokens *int
31+
ServiceTier *string
3132
ParallelToolCalls *bool
3233
// StructuredOutput is set when the client asks for JSON output.
3334
StructuredOutput bool

‎pkg/domain/model.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type ProviderConfig struct {
2121
type ProviderTarget struct {
2222
ProviderModelID string
2323
UpstreamModelName string
24+
ServiceTier *string
2425
ProviderConfig ProviderConfig
2526
}
2627

@@ -32,6 +33,7 @@ type PublicModel struct {
3233
Description *string
3334
ProviderModelID string
3435
UpstreamModelName string
36+
ServiceTier *string
3537
ProviderConfig ProviderConfig
3638
Fallback *ProviderTarget
3739
SupportsChatCompletions bool

0 commit comments

Comments
 (0)