feat(control): expose effective group configuration

This commit is contained in:
tbphp committed 2026-07-25 01:18:15 +08:00
1 parent 4b1719c0b1
commit cb0dabfbfc
6 files changed
+229 -13

No files matched your search

+64 -11
View File
@@ -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) {
+94
View File
@@ -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")
+9 -1
View File
@@ -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
}
+36
View File
@@ -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" ||
+9 -1
View File
@@ -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")
+17
View File
@@ -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)
}