diff --git a/internal/control/group_settings.go b/internal/control/group_settings.go index 3efddccb..81b55293 100644 --- a/internal/control/group_settings.go +++ b/internal/control/group_settings.go @@ -246,13 +246,12 @@ func (s *Service) UpdateGroupSettings( updates["weight_manual"] = normalized.weightManual } if normalized.overridesSet { - if _, injectUsageOptionsSet := normalized.overrides[state.SettingInjectUsageOptions]; injectUsageOptionsSet && - !groupSupportsOpenAICompletions(group.Protocols) { - return app_errors.ErrValidation - } group.Config = normalized.encodedOverrides updates["config"] = group.Config } + if err := validateGroupInjectUsageOptionsConstraint(group); err != nil { + return err + } if err := validateGroupRowCandidate(ctx, tx, group); err != nil { return app_errors.ErrValidation } @@ -280,6 +279,20 @@ func (s *Service) UpdateGroupSettings( return result, nil } +func validateGroupInjectUsageOptionsConstraint(group models.Group) error { + settings := make(config.Settings) + if len(group.Config) > 0 { + if err := decodeGroupDiscoveryJSON(group.Config, &settings); err != nil { + return app_errors.ErrValidation + } + } + if _, injectUsageOptionsSet := settings[state.SettingInjectUsageOptions]; injectUsageOptionsSet && + !groupSupportsOpenAICompletions(group.Protocols) { + return app_errors.ErrValidation + } + return nil +} + func groupSupportsOpenAICompletions(raw models.JSON) bool { var protocols []protocol.Protocol if err := decodeGroupDiscoveryJSON(raw, &protocols); err != nil { diff --git a/internal/control/group_settings_test.go b/internal/control/group_settings_test.go index b6885287..8f5e3cb3 100644 --- a/internal/control/group_settings_test.go +++ b/internal/control/group_settings_test.go @@ -160,6 +160,45 @@ func TestUpdateGroupSettingsValidatesWeightInjectUsageAndUpstreamConfirmation(t } } +func TestUpdateGroupSettingsRejectsProtocolChangeThatLeavesInjectUsageOptionsOnNonOpenAIGroup(t *testing.T) { + fixture := newServiceFixture(t) + created, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{ + UpstreamURL: "https://settings-protocol-constraint.example.com/v1", + Protocols: []protocol.Protocol{protocol.OpenAICompletions}, + Config: config.Settings{ + state.SettingInjectUsageOptions: true, + }, + Keys: "sk-settings-protocol-constraint", + }) + if err != nil { + t.Fatalf("CreateGroup() error = %v", err) + } + var before models.Group + if err := fixture.db.First(&before, created.GroupID).Error; err != nil { + t.Fatal(err) + } + beforeRevision := fixture.manager.Current().Revision + + _, err = fixture.service.UpdateGroupSettings(t.Context(), created.GroupID, GroupSettingsUpdateRequest{ + Protocols: optionalField[[]protocol.Protocol]{ + Set: true, Value: []protocol.Protocol{protocol.Anthropic}, + }, + }) + if !errors.Is(err, app_errors.ErrValidation) { + t.Fatalf("UpdateGroupSettings() error = %v, want validation", err) + } + var after models.Group + if err := fixture.db.First(&after, created.GroupID).Error; err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(after, before) { + t.Fatalf("protocol constraint changed persisted group: got=%#v want=%#v", after, before) + } + if got := fixture.manager.Current().Revision; got != beforeRevision { + t.Fatalf("protocol constraint published revision %d, want %d", got, beforeRevision) + } +} + func TestGroupSettingsHTTPRejectsStrictJSONAndUnauthorizedWithoutMutation(t *testing.T) { initControlI18n(t) fixture := newServiceFixture(t)