From cb0dabfbfcfd2786fc0244eb7f0666aa6dbde0cd Mon Sep 17 00:00:00 2001 From: tbphp Date: Sat, 25 Jul 2026 01:18:15 +0800 Subject: [PATCH] feat(control): expose effective group configuration --- internal/control/group_detail.go | 75 +++++++++++++++++---- internal/control/group_detail_test.go | 94 +++++++++++++++++++++++++++ internal/control/group_models.go | 10 ++- internal/control/group_models_test.go | 36 ++++++++++ internal/control/group_update.go | 10 ++- internal/control/group_update_test.go | 17 +++++ 6 files changed, 229 insertions(+), 13 deletions(-) diff --git a/internal/control/group_detail.go b/internal/control/group_detail.go index 8bfbcefb..0aff5a51 100644 --- a/internal/control/group_detail.go +++ b/internal/control/group_detail.go @@ -9,28 +9,81 @@ import ( "gpt-load/internal/platform/config" app_errors "gpt-load/internal/platform/errors" "gpt-load/internal/protocol" + "gpt-load/internal/state" "gpt-load/internal/storage/models" ) +type GroupEffectiveConfigResponse struct { + ConnectTimeout int64 `json:"connect_timeout"` + FirstByteTimeout int64 `json:"first_byte_timeout"` + RequestTimeout int64 `json:"request_timeout"` + StreamIdleTimeout int64 `json:"stream_idle_timeout"` + HeaderRules HeaderRulesResponse `json:"header_rules"` +} + type GroupDetailResponse struct { - ID uint `json:"id"` - Name string `json:"name"` - UpstreamURL string `json:"upstream_url"` - Protocols []protocol.Protocol `json:"protocols"` - Models []GroupModel `json:"models"` - Enabled bool `json:"enabled"` - ValidationModel *string `json:"validation_model"` - WeightManual *int `json:"weight_manual"` - Config config.Settings `json:"config"` - KeyCount int64 `json:"key_count"` + ID uint `json:"id"` + Name string `json:"name"` + UpstreamURL string `json:"upstream_url"` + Protocols []protocol.Protocol `json:"protocols"` + Models []GroupModel `json:"models"` + Enabled bool `json:"enabled"` + ValidationModel *string `json:"validation_model"` + WeightManual *int `json:"weight_manual"` + Config config.Settings `json:"config"` + EffectiveConfig GroupEffectiveConfigResponse `json:"effective_config"` + KeyCount int64 `json:"key_count"` } func (s *Service) GetGroup(ctx context.Context, groupID uint) (GroupDetailResponse, error) { if groupID == 0 { return GroupDetailResponse{}, app_errors.ErrBadRequest } + + s.writeMu.RLock() + defer s.writeMu.RUnlock() + result, _, err := loadGroupDetail(s.db.WithContext(ctx), groupID) - return result, err + if err != nil { + return GroupDetailResponse{}, err + } + snapshot := s.manager.Current() + if snapshot == nil { + return GroupDetailResponse{}, fmt.Errorf("runtime snapshot unavailable: %w", app_errors.ErrInternalServer) + } + result.EffectiveConfig, err = effectiveGroupConfig(snapshot.Settings, result.Config) + if err != nil { + return GroupDetailResponse{}, fmt.Errorf( + "resolve group %d effective config: %w", + groupID, + app_errors.ErrInternalServer, + ) + } + return result, nil +} + +func effectiveGroupConfig( + system state.RuntimeSettings, + overrides config.Settings, +) (GroupEffectiveConfigResponse, error) { + timeouts, rules, err := state.ResolveGroupRuntimeSettings(system, overrides) + if err != nil { + return GroupEffectiveConfigResponse{}, err + } + set := make(map[string]string, len(rules.Set)) + for name, value := range rules.Set { + set[name] = value + } + return GroupEffectiveConfigResponse{ + ConnectTimeout: durationSeconds(timeouts.Connect), + FirstByteTimeout: durationSeconds(timeouts.FirstByte), + RequestTimeout: durationSeconds(timeouts.Request), + StreamIdleTimeout: durationSeconds(timeouts.StreamIdle), + HeaderRules: HeaderRulesResponse{ + Set: set, + Remove: append([]string{}, rules.Remove...), + }, + }, nil } func loadGroupDetail(db *gorm.DB, groupID uint) (GroupDetailResponse, models.Group, error) { diff --git a/internal/control/group_detail_test.go b/internal/control/group_detail_test.go index 00cf0cfe..e8bdf1dc 100644 --- a/internal/control/group_detail_test.go +++ b/internal/control/group_detail_test.go @@ -10,12 +10,14 @@ import ( "net/http/httptest" "reflect" "testing" + "time" "github.com/gin-gonic/gin" "gpt-load/internal/platform/config" app_errors "gpt-load/internal/platform/errors" "gpt-load/internal/protocol" + "gpt-load/internal/state" "gpt-load/internal/storage/models" ) @@ -73,6 +75,98 @@ func TestGetGroupReturnsCompletePersistedConfiguration(t *testing.T) { } } +func TestGetGroupReturnsSparseAndEffectiveConfig(t *testing.T) { + fixture := newServiceFixture(t) + if err := fixture.db.Create(&models.SystemSetting{ + Key: state.SettingRequestTimeout, Value: "700", + }).Error; err != nil { + t.Fatal(err) + } + group := validControlGroup("effective") + group.Enabled = false + group.Config = models.JSON(`{"first_byte_timeout":180}`) + if err := fixture.db.Create(group).Error; err != nil { + t.Fatal(err) + } + if _, err := fixture.manager.Publish(mustBuildCompileInput(t, fixture.db)); err != nil { + t.Fatal(err) + } + + got, err := fixture.service.GetGroup(t.Context(), group.ID) + if err != nil { + t.Fatal(err) + } + firstByte, ok := got.Config[state.SettingFirstByteTimeout].(json.Number) + if len(got.Config) != 1 || !ok || firstByte.String() != "180" { + t.Fatalf("sparse config = %#v", got.Config) + } + if got.EffectiveConfig.ConnectTimeout != 15 || + got.EffectiveConfig.FirstByteTimeout != 180 || + got.EffectiveConfig.RequestTimeout != 700 || + got.EffectiveConfig.StreamIdleTimeout != 300 { + t.Fatalf("effective config = %#v", got.EffectiveConfig) + } + if got.EffectiveConfig.HeaderRules.Set == nil || got.EffectiveConfig.HeaderRules.Remove == nil { + t.Fatalf("effective header collections = %#v", got.EffectiveConfig.HeaderRules) + } + + encoded, err := json.Marshal(got.EffectiveConfig) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(encoded, &fields); err != nil { + t.Fatal(err) + } + if len(fields) != 5 { + t.Fatalf("effective config fields = %#v", fields) + } + for _, forbidden := range []string{ + "request_log_retention_days", "key_value", "candidates", "compiled", "timeouts", + } { + if containsJSONToken(encoded, forbidden) { + t.Fatalf("effective config exposed %q: %s", forbidden, encoded) + } + } +} + +func TestGetGroupEffectiveConfigMatchesEnabledSnapshot(t *testing.T) { + fixture := newServiceFixture(t) + group := validControlGroup("effective-enabled") + group.Config = models.JSON(`{ + "connect_timeout":20, + "header_rules":{"set":{"X-Test":"value"},"remove":["X-Debug"]} + }`) + if err := fixture.db.Create(group).Error; err != nil { + t.Fatal(err) + } + published, err := fixture.manager.Publish(mustBuildCompileInput(t, fixture.db)) + if err != nil { + t.Fatal(err) + } + + got, err := fixture.service.GetGroup(t.Context(), group.ID) + if err != nil { + t.Fatal(err) + } + view := published.Groups[group.ID] + if got.EffectiveConfig.ConnectTimeout != int64(view.Timeouts.Connect/time.Second) || + got.EffectiveConfig.FirstByteTimeout != int64(view.Timeouts.FirstByte/time.Second) || + got.EffectiveConfig.RequestTimeout != int64(view.Timeouts.Request/time.Second) || + got.EffectiveConfig.StreamIdleTimeout != int64(view.Timeouts.StreamIdle/time.Second) || + !reflect.DeepEqual(got.EffectiveConfig.HeaderRules.Set, view.HeaderRules.Set) || + !reflect.DeepEqual(got.EffectiveConfig.HeaderRules.Remove, view.HeaderRules.Remove) { + t.Fatalf("effective/snapshot = %#v/%#v", got.EffectiveConfig, view) + } + + got.EffectiveConfig.HeaderRules.Set["X-Test"] = "changed" + got.EffectiveConfig.HeaderRules.Remove[0] = "X-Changed" + if view.HeaderRules.Set["X-Test"] != "value" || + !reflect.DeepEqual(view.HeaderRules.Remove, []string{"X-Debug"}) { + t.Fatalf("response aliases snapshot header rules: %#v", view.HeaderRules) + } +} + func TestGetGroupUsesNonNilEmptyCollectionsAndNullPointers(t *testing.T) { fixture := newServiceFixture(t) group := validControlGroup("empty-detail") diff --git a/internal/control/group_models.go b/internal/control/group_models.go index 0cebc401..965a4560 100644 --- a/internal/control/group_models.go +++ b/internal/control/group_models.go @@ -36,7 +36,7 @@ func (s *Service) UpdateGroupModels( } var result GroupDetailResponse - _, err = s.writeConfig(ctx, func(tx *gorm.DB) error { + snapshot, err := s.writeConfig(ctx, func(tx *gorm.DB) error { _, group, err := loadGroupDetail(tx, groupID) if err != nil { return err @@ -60,5 +60,13 @@ func (s *Service) UpdateGroupModels( if err != nil { return GroupDetailResponse{}, withControlOperationContext(err, groupID, 0) } + result.EffectiveConfig, err = effectiveGroupConfig(snapshot.Settings, result.Config) + if err != nil { + return GroupDetailResponse{}, fmt.Errorf( + "resolve group %d effective config after model update: %w", + groupID, + app_errors.ErrInternalServer, + ) + } return result, nil } diff --git a/internal/control/group_models_test.go b/internal/control/group_models_test.go index db152178..cbfb983c 100644 --- a/internal/control/group_models_test.go +++ b/internal/control/group_models_test.go @@ -6,6 +6,7 @@ import ( "errors" "reflect" "testing" + "time" "gpt-load/internal/dialect" app_errors "gpt-load/internal/platform/errors" @@ -76,6 +77,19 @@ func TestUpdateGroupModelsReplacesAuthoritativeListAndPublishesOnce(t *testing.T }); err != nil { t.Fatal(err) } + if err := fixture.db.Model(&models.Group{}). + Where("id = ?", created.GroupID). + Update("config", models.JSON(`{ + "stream_idle_timeout":45, + "header_rules":{"remove":["X-Trace"]} + }`)).Error; err != nil { + t.Fatal(err) + } + if err := fixture.db.Create(&models.SystemSetting{ + Key: state.SettingRequestTimeout, Value: "701", + }).Error; err != nil { + t.Fatal(err) + } beforeRevision := fixture.manager.Current().Revision beforeRegistry := fixture.registry.Snapshot() var beforeKeys []models.UpstreamKey @@ -105,6 +119,22 @@ func TestUpdateGroupModelsReplacesAuthoritativeListAndPublishesOnce(t *testing.T got.KeyCount != 2 { t.Fatalf("detail = %#v", got) } + streamIdle, ok := got.Config[state.SettingStreamIdleTimeout].(json.Number) + if len(got.Config) != 2 || !ok || streamIdle.String() != "45" || + got.Config[state.SettingHeaderRules] == nil { + t.Fatalf("preserved sparse config = %#v", got.Config) + } + if got.EffectiveConfig.ConnectTimeout != 15 || + got.EffectiveConfig.FirstByteTimeout != 120 || + got.EffectiveConfig.RequestTimeout != 701 || + got.EffectiveConfig.StreamIdleTimeout != 45 || + len(got.EffectiveConfig.HeaderRules.Set) != 0 || + !reflect.DeepEqual(got.EffectiveConfig.HeaderRules.Remove, []string{"X-Trace"}) { + t.Fatalf("post-write effective config = %#v", got.EffectiveConfig) + } + if got.EffectiveConfig.HeaderRules.Set == nil || got.EffectiveConfig.HeaderRules.Remove == nil { + t.Fatalf("effective header collections = %#v", got.EffectiveConfig.HeaderRules) + } if stored := loadCreatedGroupModels(t, fixture, created.GroupID); !reflect.DeepEqual(stored, wantModels) { t.Fatalf("stored models = %#v, want %#v", stored, wantModels) } @@ -122,6 +152,12 @@ func TestUpdateGroupModelsReplacesAuthoritativeListAndPublishesOnce(t *testing.T t.Fatalf("upstream keys changed: got=%#v want=%#v", afterKeys, beforeKeys) } snapshot := fixture.manager.Current() + view := snapshot.Groups[created.GroupID] + if got.EffectiveConfig.RequestTimeout != int64(view.Timeouts.Request/time.Second) || + got.EffectiveConfig.StreamIdleTimeout != int64(view.Timeouts.StreamIdle/time.Second) || + !reflect.DeepEqual(got.EffectiveConfig.HeaderRules.Remove, view.HeaderRules.Remove) { + t.Fatalf("effective/snapshot = %#v/%#v", got.EffectiveConfig, view) + } targets := snapshot.Candidates[protocol.OpenAI] if len(targets) != 2 || targets["public-a"][0].UpstreamModelID != "provider-a" || diff --git a/internal/control/group_update.go b/internal/control/group_update.go index 283e7c15..3dc5aa47 100644 --- a/internal/control/group_update.go +++ b/internal/control/group_update.go @@ -159,7 +159,7 @@ func (s *Service) UpdateGroup( var result GroupUpdateResult var changedHostname string - _, err = s.writeConfig(ctx, func(tx *gorm.DB) error { + snapshot, err := s.writeConfig(ctx, func(tx *gorm.DB) error { _, group, err := loadGroupDetail(tx, groupID) if err != nil { return err @@ -236,6 +236,14 @@ func (s *Service) UpdateGroup( if err != nil { return GroupUpdateResult{}, withControlOperationContext(err, groupID, 0) } + result.Group.EffectiveConfig, err = effectiveGroupConfig(snapshot.Settings, result.Group.Config) + if err != nil { + return GroupUpdateResult{}, fmt.Errorf( + "resolve updated group %d effective config: %w", + groupID, + app_errors.ErrInternalServer, + ) + } if changedHostname != "" && isLiteralPrivateHost(changedHostname) { logrus.WithField("host", changedHostname). Warn("Updating upstream group to a private or local host") diff --git a/internal/control/group_update_test.go b/internal/control/group_update_test.go index 26feee2e..c2c8230b 100644 --- a/internal/control/group_update_test.go +++ b/internal/control/group_update_test.go @@ -116,6 +116,11 @@ func TestUpdateGroupReplacesOnlySuppliedFieldsAndPublishesOnce(t *testing.T) { fixture := newServiceFixture(t) groupID := createGroupForKeyImport(t, fixture, "sk-update-preserved") beforeRevision := fixture.manager.Current().Revision + if err := fixture.db.Create(&models.SystemSetting{ + Key: state.SettingRequestTimeout, Value: "700", + }).Error; err != nil { + t.Fatal(err) + } name := optionalField[string]{Set: true, Value: " renamed "} enabled := optionalField[bool]{Set: true, Value: false} validation := optionalField[string]{Set: true, Null: true} @@ -138,6 +143,18 @@ func TestUpdateGroupReplacesOnlySuppliedFieldsAndPublishesOnce(t *testing.T) { result.ModelRediscoveryRecommended { t.Fatalf("result = %#v", result) } + firstByte, ok := result.Group.Config[state.SettingFirstByteTimeout].(json.Number) + if len(result.Group.Config) != 1 || !ok || firstByte.String() != "180" { + t.Fatalf("sparse config = %#v", result.Group.Config) + } + if result.Group.EffectiveConfig.ConnectTimeout != 15 || + result.Group.EffectiveConfig.FirstByteTimeout != 180 || + result.Group.EffectiveConfig.RequestTimeout != 700 || + result.Group.EffectiveConfig.StreamIdleTimeout != 300 || + result.Group.EffectiveConfig.HeaderRules.Set == nil || + result.Group.EffectiveConfig.HeaderRules.Remove == nil { + t.Fatalf("post-write effective config = %#v", result.Group.EffectiveConfig) + } if got := fixture.manager.Current().Revision; got != beforeRevision+1 { t.Fatalf("revision = %d, want %d", got, beforeRevision+1) }