feat(control): expose effective group configuration
This commit is contained in:
6 files changed
+229
-13
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user