diff --git a/go.mod b/go.mod index 6973ea2a..a4440ea0 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.27.0 require ( github.com/andybalholm/brotli v1.2.3 + github.com/buger/jsonparser v1.2.0 github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 github.com/joho/godotenv v1.5.1 @@ -50,7 +51,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect github.com/aws/smithy-go v1.27.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect - github.com/buger/jsonparser v1.2.0 // indirect github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.3-0.20260730064818-2a36d6da63e2 // indirect github.com/bytedance/sonic/loader v0.5.2 // indirect diff --git a/internal/control/group_create.go b/internal/control/group_create.go index a2d3e13a..22cc98dc 100644 --- a/internal/control/group_create.go +++ b/internal/control/group_create.go @@ -307,6 +307,10 @@ func normalizeGroupSettings(settings config.Settings) (config.Settings, models.J return nil, nil, app_errors.ErrValidation } for key, value := range normalized { + if key == state.SettingParameterOverrides { + // 参数覆盖校验依赖原始 JSON 数字字面量。 + continue + } normalized[key] = canonicalizeGroupSettingNumbers(value) } encoded, err = json.Marshal(normalized) diff --git a/internal/control/group_settings_test.go b/internal/control/group_settings_test.go index 865cab6f..bedd1dcd 100644 --- a/internal/control/group_settings_test.go +++ b/internal/control/group_settings_test.go @@ -15,9 +15,11 @@ import ( "github.com/gin-gonic/gin" "gpt-load/internal/channel" + "gpt-load/internal/execution" "gpt-load/internal/health" "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" ) @@ -71,6 +73,54 @@ func TestGetGroupSettingsReturnsPersistedDraftOverridesAndEffectiveConfig(t *tes } } +func TestUpdateGroupSettingsPublishesParameterOverrides(t *testing.T) { + t.Parallel() + fixture := newServiceFixture(t) + groupID := createGroupWithCredentials(t, fixture, "sk-parameter-overrides") + rawRules := []any{ + map[string]any{ + "match": map[string]any{"protocol": string(protocol.OpenAICompletions), "model": "public-*"}, + "set": map[string]any{"temperature": json.Number("0.4")}, + }, + } + + result, err := fixture.service.UpdateGroupSettings(t.Context(), groupID, GroupSettingsUpdateRequest{ + Overrides: optionalField[config.Settings]{Set: true, Value: config.Settings{ + state.SettingParameterOverrides: rawRules, + }}, + }) + if err != nil { + t.Fatal(err) + } + if result.Overrides[state.SettingParameterOverrides] == nil { + t.Fatalf("overrides = %#v", result.Overrides) + } + body, applied, err := fixture.manager.Current().Groups[groupID].ParameterOverrides.Apply( + protocol.OpenAICompletions, + execution.OperationChatCompletion, + "public-model", + []byte(`{"model":"public-model"}`), + ) + if err != nil || !applied || string(body) != `{"model":"public-model","temperature":0.4}` { + t.Fatalf("Apply() = %s, %t, %v", body, applied, err) + } + + before := fixture.manager.Current() + _, err = fixture.service.UpdateGroupSettings(t.Context(), groupID, GroupSettingsUpdateRequest{ + Overrides: optionalField[config.Settings]{Set: true, Value: config.Settings{ + state.SettingParameterOverrides: []any{map[string]any{ + "set": map[string]any{"stream": true}, + }}, + }}, + }) + if !errors.Is(err, app_errors.ErrValidation) { + t.Fatalf("invalid rules error = %v, want validation", err) + } + if fixture.manager.Current() != before { + t.Fatal("invalid parameter overrides published a snapshot") + } +} + func TestUpdateGroupSettingsPublishesOnceAndReturnsNewSettings(t *testing.T) { t.Parallel() fixture := newServiceFixture(t) diff --git a/internal/control/server_test.go b/internal/control/server_test.go index bf44f952..aa422e33 100644 --- a/internal/control/server_test.go +++ b/internal/control/server_test.go @@ -1213,6 +1213,9 @@ func TestUpdateGroupSettingsEndpointRejectsStrictInvalidBodies(t *testing.T) { {name: "negative weight", body: `{"weight_manual":-1}`, code: app_errors.ErrValidation.Code}, {name: "retired protocols", body: `{"protocols":[]}`, code: app_errors.ErrInvalidJSON.Code}, {name: "invalid overrides", body: `{"overrides":{"first_byte_timeout":-1}}`, code: app_errors.ErrValidation.Code}, + {name: "parameter override negative zero", body: `{"overrides":{"parameter_overrides":[{"set":{"value":-0}}]}}`, code: app_errors.ErrValidation.Code}, + {name: "parameter override negative decimal zero", body: `{"overrides":{"parameter_overrides":[{"set":{"value":-0.0}}]}}`, code: app_errors.ErrValidation.Code}, + {name: "parameter override negative exponent zero", body: `{"overrides":{"parameter_overrides":[{"set":{"value":-0e3}}]}}`, code: app_errors.ErrValidation.Code}, } { t.Run(test.name, func(t *testing.T) { before := fixture.manager.Current().Revision diff --git a/internal/gateway/dialects_integration_test.go b/internal/gateway/dialects_integration_test.go index 0ca430de..18ad02b5 100644 --- a/internal/gateway/dialects_integration_test.go +++ b/internal/gateway/dialects_integration_test.go @@ -22,6 +22,7 @@ import ( "gpt-load/internal/dialect" "gpt-load/internal/execution" "gpt-load/internal/health" + "gpt-load/internal/platform/config" "gpt-load/internal/platform/contentcoding" "gpt-load/internal/protocol" "gpt-load/internal/state" @@ -37,6 +38,7 @@ type dialectGatewayGroup struct { params json.RawMessage apiKeys []string models []state.ModelConfig + settings config.Settings headerRules state.HeaderRules firstByte time.Duration } @@ -105,7 +107,7 @@ func newDialectGatewayEngineWithForwarder( channelID, params = testChannelConfig(t, selectedProtocol, baseURL) } configs = append(configs, state.GroupConfig{ConnectionType: "api_key", ID: group.id, Name: group.name, ChannelID: channelID, Params: params, - Models: models, Enabled: true, + Models: models, Settings: group.settings, Enabled: true, }) for _, apiKey := range group.apiKeys { entries = append(entries, testCredentialEntry(t, keyService, credentialID, group.id, apiKey)) @@ -601,6 +603,72 @@ func TestGatewayRewritesEachAttemptFromOriginal(t *testing.T) { } } +func TestGatewayAppliesEachGroupsParameterOverridesFromOriginal(t *testing.T) { + first := fakeupstream.New(fakeupstream.Step{Status: http.StatusUnauthorized, Fixture: "401.json"}) + defer first.Close() + second := fakeupstream.New(fakeupstream.Step{Status: http.StatusOK, Fixture: "success.json"}) + defer second.Close() + + rules := func(marker string) config.Settings { + return config.Settings{state.SettingParameterOverrides: []any{ + map[string]any{ + "match": map[string]any{ + "protocol": string(protocol.OpenAICompletions), + "model": "public*", + }, + "remove": []any{"/nested/remove"}, + "set": map[string]any{ + "marker": marker, + "nested": map[string]any{"group": marker}, + }, + }, + }} + } + engine, _ := newDialectGatewayEngine(t, protocol.OpenAICompletions, "public", + dialect.NewSet(dialect.NewOpenAI()), + dialectGatewayGroup{ + id: 1, name: "first", upstreamURL: first.URL, apiKeys: []string{"sk-first"}, + models: []state.ModelConfig{{ID: "provider-one", Alias: "public"}}, + settings: rules("first"), + }, + dialectGatewayGroup{ + id: 2, name: "second", upstreamURL: second.URL, apiKeys: []string{"sk-second"}, + models: []state.ModelConfig{{ID: "provider-two", Alias: "public"}}, + settings: rules("second"), + }, + ) + request := httptest.NewRequest( + http.MethodPost, + "/v1/chat/completions", + strings.NewReader(`{"model":"public","nested":{"remove":true,"original":true}}`), + ) + request.Header.Set("Authorization", "Bearer gl-client") + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("response = %d body=%s", recorder.Code, recorder.Body.String()) + } + requests := append(first.Requests(), second.Requests()...) + if len(requests) != 2 { + t.Fatalf("upstream requests = %d, want 2", len(requests)) + } + for index, received := range requests { + var body map[string]any + if err := json.Unmarshal(received.Body, &body); err != nil { + t.Fatal(err) + } + wantMarker := []string{"first", "second"}[index] + if body["marker"] != wantMarker { + t.Fatalf("request %d marker = %#v, want %q", index+1, body["marker"], wantMarker) + } + nested, ok := body["nested"].(map[string]any) + if !ok || nested["remove"] != nil || nested["original"] != true || nested["group"] != wantMarker { + t.Fatalf("request %d nested = %#v", index+1, body["nested"]) + } + } +} + func TestHandlerHostFailureDoesNotReplayGenerationAcrossGroups(t *testing.T) { primary := fakeupstream.New( fakeupstream.Step{Status: http.StatusInternalServerError, Fixture: "openai/500.json"}, diff --git a/internal/gateway/handler.go b/internal/gateway/handler.go index 8e98e931..3f8b450e 100644 --- a/internal/gateway/handler.go +++ b/internal/gateway/handler.go @@ -114,15 +114,20 @@ type Handler struct { func (handler *Handler) freezeAttemptPricing( selection scheduler.Selection, - applicable bool, + observations dialect.RequestMetadata, + observationsAvailable bool, ) frozenAttemptPricing { frozen := frozenAttemptPricing{ - channelID: string(selection.ChannelID), - groupID: selection.GroupID, - upstreamModel: optionalModelValue(selection.UpstreamModelID), - applicable: applicable, + channelID: string(selection.ChannelID), + groupID: selection.GroupID, + upstreamModel: optionalModelValue(selection.UpstreamModelID), + applicable: observations.ObserveUsage, + metadataSet: true, + pricingMode: observations.PricingMode, + usageDiagnostics: observations.UsageDiagnostics, + reasoning: observations.Reasoning.Clone(), } - if handler != nil && handler.priceTables != nil { + if observationsAvailable && handler != nil && handler.priceTables != nil { frozen.table = handler.priceTables.Load() } return frozen @@ -581,10 +586,7 @@ func (handler *Handler) Handle(ginContext *gin.Context) { selectedDialect, parsed, model, - metadata.Stream, - metadata.ObserveUsage, - metadata.Operation, - metadata.RouteRequirement, + metadata, requestAffinity, recorder, "aAdmission, @@ -755,14 +757,13 @@ func (handler *Handler) executeAttempts( selectedDialect dialect.Dialect, parsed *dialect.ParsedRequest, externalModel string, - stream bool, - observeUsage bool, - operation execution.Operation, - routeRequirement execution.RouteRequirement, + originalMetadata dialect.RequestMetadata, requestAffinity requestAffinity, recorder *requestRecorder, quotaAdmission *requestAccessQuotaAdmission, ) { + stream := originalMetadata.Stream + operation := originalMetadata.Operation type deferredAttempt struct { result UpstreamResult decision health.Decision @@ -784,6 +785,68 @@ func (handler *Handler) executeAttempts( } var refreshRetry *credentialRefreshRetry authRefreshReplayUsed := false + type preparedRequest struct { + request *dialect.ParsedRequest + observations dialect.RequestMetadata + observationsAvailable bool + err error + } + // 缓存仅属于本次请求;切换分组时释放旧结果,避免重试累积完整请求体。 + var preparedGroupID uint + var cachedPrepared *preparedRequest + loggedOverrideFailures := make(map[uint]struct{}) + var parameterOverrideFailure *reason + prepareRequest := func(selection scheduler.Selection) preparedRequest { + if cachedPrepared != nil && preparedGroupID == selection.GroupID { + return *cachedPrepared + } + cachedPrepared = nil + preparedGroupID = selection.GroupID + prepared := preparedRequest{ + request: parsed, observations: originalMetadata, observationsAvailable: true, + } + body, applied, err := selection.Group.ParameterOverrides.Apply( + selectedDialect.Protocol(), + originalMetadata.Operation, + externalModel, + parsed.Body, + ) + if err != nil { + prepared.err = err + cachedPrepared = &prepared + return prepared + } + if !applied { + cachedPrepared = &prepared + return prepared + } + if int64(len(body)) > maxRequestBodyBytes { + prepared.err = errRequestTooLarge + cachedPrepared = &prepared + return prepared + } + request := &dialect.ParsedRequest{ + Method: parsed.Method, Path: parsed.Path, RawQuery: parsed.RawQuery, + Header: parsed.Header.Clone(), Body: body, + } + // Routing metadata is frozen from the original client request. Refresh only + // attempt-local observations, and never block dispatch when observation fails. + // An unavailable observation stays unknown instead of reusing client values. + prepared.observations = dialect.RequestMetadata{ObserveUsage: originalMetadata.ObserveUsage} + prepared.observationsAvailable = false + if metadata, inspectErr := selectedDialect.InspectRequest(request); inspectErr == nil { + prepared.observations = dialect.RequestMetadata{ + ObserveUsage: originalMetadata.ObserveUsage, + PricingMode: metadata.PricingMode, + UsageDiagnostics: metadata.UsageDiagnostics, + Reasoning: metadata.Reasoning, + } + prepared.observationsAvailable = true + } + prepared.request = request + cachedPrepared = &prepared + return prepared + } decisionContextForSelection := func(selection scheduler.Selection) health.DecisionContext { defaultRateLimitCooldown := fixedCooldown credentialRefreshable := false @@ -804,6 +867,8 @@ func (handler *Handler) executeAttempts( } recordCandidatePreparationFailure := func( selection scheduler.Selection, + attemptObservations dialect.RequestMetadata, + attemptObservationsAvailable bool, code string, summary string, scope execution.ErrorScope, @@ -816,7 +881,11 @@ func (handler *Handler) executeAttempts( updateDebugHeaders(ginContext.Writer.Header(), selection.Group.Name, attemptSequence) if recorder != nil { recorder.freezeNextAttemptPricing( - handler.freezeAttemptPricing(selection, observeUsage), + handler.freezeAttemptPricing( + selection, + attemptObservations, + attemptObservationsAvailable, + ), ) } attemptStarted := recorder.beforeForward() @@ -898,6 +967,30 @@ func (handler *Handler) executeAttempts( if !active { continue } + prepared := prepareRequest(selection) + if prepared.err != nil { + if errors.Is(prepared.err, errRequestTooLarge) { + if parameterOverrideFailure == nil { + parameterOverrideFailure = &reasonRequestTooLarge + } + } else { + parameterOverrideFailure = &reasonParameterOverrideUnavailable + } + if _, logged := loggedOverrideFailures[selection.GroupID]; !logged { + utils.LogPlaneBestEffort( + handler.logger, + logrus.WarnLevel, + utils.LogPlaneData, + logrus.Fields{"group_id": selection.GroupID, "reason": "apply_failed"}, + "Parameter override failed for an upstream Group", + ) + loggedOverrideFailures[selection.GroupID] = struct{}{} + } + iterator.SkipGroup(selection.GroupID) + continue + } + attemptObservations := prepared.observations + attemptObservationsAvailable := prepared.observationsAvailable if !retryPolicyResolved { // A request can fail over across Groups. Freeze the first active // candidate's effective Group policy for the whole retry chain. @@ -908,6 +1001,8 @@ func (handler *Handler) executeAttempts( if err != nil { if !recordCandidatePreparationFailure( selection, + attemptObservations, + attemptObservationsAvailable, "credential_decrypt_failed", "Stored credential could not be decrypted.", execution.ErrorScopeCredential, @@ -926,6 +1021,8 @@ func (handler *Handler) executeAttempts( if err != nil { if !recordCandidatePreparationFailure( selection, + attemptObservations, + attemptObservationsAvailable, "credential_normalization_failed", "Stored credential could not be prepared.", execution.ErrorScopeCredential, @@ -948,7 +1045,14 @@ func (handler *Handler) executeAttempts( summary = "Credential proxy configuration could not be prepared." scope = execution.ErrorScopeCredential } - if !recordCandidatePreparationFailure(selection, code, summary, scope) { + if !recordCandidatePreparationFailure( + selection, + attemptObservations, + attemptObservationsAvailable, + code, + summary, + scope, + ) { break } continue @@ -993,17 +1097,17 @@ func (handler *Handler) executeAttempts( executionRequestID = recorder.requestID } input := ForwardInput{ - Dialect: selectedDialect, ObserveUsage: observeUsage, + Dialect: selectedDialect, ObserveUsage: attemptObservations.ObserveUsage, Group: selection.Group, APIKey: normalizedCredential.apiKey, - CredentialSecrets: normalizedCredential.secrets, Request: parsed, + CredentialSecrets: normalizedCredential.secrets, Request: prepared.request, ExternalModel: externalModel, UpstreamModelID: optionalModelValue(selection.UpstreamModelID), RequestID: executionRequestID, AttemptID: executionRequestID + ":" + strconv.Itoa(attemptSequence), AttemptSequence: uint32(attemptSequence), ClientProtocol: selectedDialect.Protocol(), - Operation: operation, - RouteRequirement: routeRequirement, + Operation: originalMetadata.Operation, + RouteRequirement: originalMetadata.RouteRequirement, ResponsesStoreDowngraded: selection.ResponsesStoreDowngraded, ChannelID: string(selection.ChannelID), RouteMode: execution.RouteMode(selection.RouteMode), @@ -1024,7 +1128,11 @@ func (handler *Handler) executeAttempts( } if recorder != nil { recorder.freezeNextAttemptPricing( - handler.freezeAttemptPricing(selection, observeUsage), + handler.freezeAttemptPricing( + selection, + attemptObservations, + attemptObservationsAvailable, + ), ) } attemptStarted := recorder.beforeForward() @@ -1240,6 +1348,10 @@ func (handler *Handler) executeAttempts( handler.completeReason(ginContext, recorder, reasonModelRequiredByFilter) return } + if parameterOverrideFailure != nil && lastAttemptIndex < 0 { + handler.completeReason(ginContext, recorder, *parameterOverrideFailure) + return + } handler.completeReason(ginContext, recorder, reasonNoCandidate) } diff --git a/internal/gateway/handler_test.go b/internal/gateway/handler_test.go index cb8436e6..b0990ea5 100644 --- a/internal/gateway/handler_test.go +++ b/internal/gateway/handler_test.go @@ -3448,6 +3448,118 @@ func TestHandlerRetriesAnotherGroupAfterLocalConversionFailure(t *testing.T) { }) } +func TestHandlerKeepsOriginalRouteAfterParameterOverride(t *testing.T) { + t.Parallel() + forwarder := &scriptedForwarder{results: []UpstreamResult{{ + StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true, + }}} + settings := config.Settings{state.SettingParameterOverrides: []any{ + map[string]any{ + "match": map[string]any{ + "protocol": string(protocol.Anthropic), + "model": "claude-*", + }, + "set": map[string]any{"container": "container_123"}, + }, + }} + engine, registry := newConvertedFallbackHandlerTestRuntime(t, forwarder, settings) + before := registry.Snapshot() + request := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewBufferString( + `{"model":"claude-client","max_tokens":64,"messages":[{"role":"user","content":"hello"}]}`, + )) + request.Header.Set("Authorization", "Bearer gl-client") + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK || len(forwarder.inputs) != 1 { + t.Fatalf("response/attempts = %d %s / %d", recorder.Code, recorder.Body.String(), len(forwarder.inputs)) + } + input := forwarder.inputs[0] + if input.RouteMode != execution.RouteConverted || + input.RouteRequirement.Normalize() != execution.RouteRequirementAny { + t.Fatalf("route = %q/%q, want converted/any", input.RouteMode, input.RouteRequirement) + } + var body map[string]any + if err := json.Unmarshal(input.Request.Body, &body); err != nil { + t.Fatal(err) + } + if body["container"] != "container_123" { + t.Fatalf("overridden request = %s, want container", input.Request.Body) + } + if after := registry.Snapshot(); !reflect.DeepEqual(after, before) { + t.Fatalf("credential health changed: before=%#v after=%#v", before, after) + } +} + +func TestHandlerDoesNotExpandCandidatesAfterParameterOverride(t *testing.T) { + t.Parallel() + forwarder := &scriptedForwarder{results: []UpstreamResult{{ + StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true, + }}} + settings := config.Settings{state.SettingParameterOverrides: []any{ + map[string]any{ + "match": map[string]any{ + "protocol": string(protocol.Anthropic), + "model": "claude-*", + }, + "remove": []any{"/container"}, + }, + }} + engine, _ := newConvertedFallbackHandlerTestRuntime(t, forwarder, settings) + request := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewBufferString( + `{"model":"claude-client","max_tokens":64,"container":{"id":"container_123"},"messages":[{"role":"user","content":"hello"}]}`, + )) + request.Header.Set("Authorization", "Bearer gl-client") + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, request) + + var body struct { + Code string `json:"code"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if recorder.Code != http.StatusServiceUnavailable || + body.Code != reasonNoCandidate.Code || len(forwarder.inputs) != 0 { + t.Fatalf("response/attempts = %d %s / %d", recorder.Code, recorder.Body.String(), len(forwarder.inputs)) + } +} + +func TestHandlerForwardsOverrideWithoutProviderSemanticValidation(t *testing.T) { + t.Parallel() + forwarder := &scriptedForwarder{results: []UpstreamResult{{ + StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true, + }}} + handler, manager, _ := newHandlerForTest(t, forwarder, "sk-one") + publishHandlerPolicySettings( + t, + handler, + manager, + 1, + nil, + config.Settings{state.SettingParameterOverrides: []any{ + map[string]any{"set": map[string]any{"service_tier": "ultrafast"}}, + }}, + ) + engine := gin.New() + bindGatewayRoutesForTest(t, engine, handler) + request := httptest.NewRequest( + http.MethodPost, + "/v1/chat/completions", + bytes.NewBufferString(`{"model":"gpt-4o"}`), + ) + request.Header.Set("Authorization", "Bearer gl-client") + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK || len(forwarder.inputs) != 1 { + t.Fatalf("response/attempts = %d %s / %d", recorder.Code, recorder.Body.String(), len(forwarder.inputs)) + } + if !bytes.Contains(forwarder.inputs[0].Request.Body, []byte(`"service_tier":"ultrafast"`)) { + t.Fatalf("effective request = %s, want overridden service_tier", forwarder.inputs[0].Request.Body) + } +} + func TestHandlerAppliesExactCooldownDeadline(t *testing.T) { attemptNow := time.Date(2026, time.July, 21, 12, 0, 0, 0, time.UTC) tests := []struct { @@ -5230,6 +5342,7 @@ func newHandlerForTestWithStats( func newConvertedFallbackHandlerTestRuntime( t *testing.T, forwarder AttemptForwarder, + groupSettings ...config.Settings, ) (*gin.Engine, *state.CredentialRegistry) { t.Helper() gin.SetMode(gin.TestMode) @@ -5246,6 +5359,11 @@ func newConvertedFallbackHandlerTestRuntime( Models: []state.ModelConfig{{ID: "upstream-two", Alias: "claude-client"}}, }, } + if len(groupSettings) > 0 { + for index := range groups { + groups[index].Settings = groupSettings[0] + } + } credentials := []state.CredentialConfig{ {ID: 1, GroupID: 1, Status: state.CredentialStatusActive, Version: 1, IdentityGeneration: 1, Fingerprint: "credential-one"}, {ID: 2, GroupID: 2, Status: state.CredentialStatusActive, Version: 1, IdentityGeneration: 2, Fingerprint: "credential-two"}, diff --git a/internal/gateway/parameter_override_cache_test.go b/internal/gateway/parameter_override_cache_test.go new file mode 100644 index 00000000..4d08c37f --- /dev/null +++ b/internal/gateway/parameter_override_cache_test.go @@ -0,0 +1,78 @@ +package gateway + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + "time" + + "gpt-load/internal/dialect" + "gpt-load/internal/platform/config" + "gpt-load/internal/protocol" + "gpt-load/internal/state" +) + +func TestHandlerParameterOverrideCacheKeepsOnlyLastGroup(t *testing.T) { + failure := UpstreamResult{StatusCode: http.StatusUnauthorized, Header: make(http.Header), + Body: []byte(`{"error":"invalid_api_key"}`), ClassificationBody: []byte(`{"error":"invalid_api_key"}`), RequestWritten: true} + success := UpstreamResult{StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true} + forwarder := &scriptedForwarder{results: []UpstreamResult{failure, failure, failure, success, success}} + settings := func(field string) config.Settings { + return config.Settings{ + state.SettingRetryCount: 3, + state.SettingParameterOverrides: []any{map[string]any{"set": map[string]any{field: true}}}, + } + } + engine, registry := newDialectGatewayEngineWithForwarder(t, protocol.OpenAICompletions, "public", + dialect.NewSet(dialect.NewOpenAI()), forwarder, + dialectGatewayGroup{id: 1, name: "first", upstreamURL: "https://first.example", apiKeys: []string{"sk-one", "sk-two", "sk-three"}, settings: settings("first")}, + dialectGatewayGroup{id: 2, name: "second", upstreamURL: "https://second.example", apiKeys: []string{"sk-four"}, settings: settings("second")}, + ) + // 暂时冷却第三个凭据,形成同分组重试、跨分组、返回原分组的顺序。 + until := time.Now().Add(time.Hour) + forwarder.onCall = func(index int) { + switch index { + case 1: + if !registry.SetCooldown(3, until) { + t.Fatal("set credential cooldown") + } + case 2: + if !registry.ClearCooldownIfMatch(3, until) { + t.Fatal("clear credential cooldown") + } + } + } + for _, original := range []string{"one", "two"} { + request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", + bytes.NewBufferString(`{"model":"public","original":"`+original+`"}`)) + request.Header.Set("Authorization", "Bearer gl-client") + response := httptest.NewRecorder() + engine.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("response = %d %s", response.Code, response.Body.String()) + } + } + if len(forwarder.inputs) != 5 { + t.Fatalf("attempts = %d, want 5", len(forwarder.inputs)) + } + for index, groupID := range []uint{1, 1, 2, 1, 1} { + input := forwarder.inputs[index] + if input.Group.ID != groupID { + t.Fatalf("attempt %d group = %d, want %d", index, input.Group.ID, groupID) + } + if groupID == 1 && bytes.Contains(input.Request.Body, []byte(`"second"`)) { + t.Fatalf("attempt %d inherited another group's override", index) + } + } + if forwarder.inputs[0].Request != forwarder.inputs[1].Request { + t.Fatal("consecutive attempts in the same group did not reuse the prepared request") + } + if forwarder.inputs[0].Request == forwarder.inputs[3].Request { + t.Fatal("previous group's prepared request remained cached after switching groups") + } + if forwarder.inputs[3].Request == forwarder.inputs[4].Request || + !bytes.Contains(forwarder.inputs[4].Request.Body, []byte(`"original":"two"`)) { + t.Fatal("prepared request was shared across client requests") + } +} diff --git a/internal/gateway/parameter_override_size_test.go b/internal/gateway/parameter_override_size_test.go new file mode 100644 index 00000000..e6173c4d --- /dev/null +++ b/internal/gateway/parameter_override_size_test.go @@ -0,0 +1,57 @@ +package gateway + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gpt-load/internal/dialect" + "gpt-load/internal/platform/config" + "gpt-load/internal/protocol" + "gpt-load/internal/state" +) + +func TestHandlerParameterOverrideBodyLimits(t *testing.T) { + for _, test := range []struct { + name string + character string + count int + }{ + {name: "10 MiB request is overridden", character: "x", count: 10 << 20}, + {name: "HTML characters stay compact", character: "<>&", count: (10 << 20) / 3}, + } { + t.Run(test.name, func(t *testing.T) { + forwarder := &scriptedForwarder{results: []UpstreamResult{{ + StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true, + }}} + groups := []dialectGatewayGroup{{ + id: 1, name: "override", upstreamURL: "https://first.example", apiKeys: []string{"sk-first"}, + settings: config.Settings{state.SettingParameterOverrides: []any{ + map[string]any{"set": map[string]any{"temperature": 0.5}}, + }}, + }} + engine, _ := newDialectGatewayEngineWithForwarder(t, protocol.OpenAICompletions, "public", + dialect.NewSet(dialect.NewOpenAI()), forwarder, groups...) + body := `{"model":"public","messages":[{"role":"user","content":"` + strings.Repeat(test.character, test.count) + `"}]}` + request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body)) + request.Header.Set("Authorization", "Bearer gl-client") + response := httptest.NewRecorder() + engine.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("response = %d %s, want 200", response.Code, response.Body.String()) + } + if len(forwarder.inputs) != 1 || forwarder.inputs[0].Group.ID != 1 { + t.Fatalf("unexpected forwarded group or attempt count: %d", len(forwarder.inputs)) + } + got := forwarder.inputs[0].Request.Body + if !bytes.Contains(got, []byte(`"temperature":0.5`)) { + t.Fatal("large request was forwarded without the configured override") + } + if len(got) > len(body)+32 { + t.Fatalf("request expanded unexpectedly: input = %d, output = %d", len(body), len(got)) + } + }) + } +} diff --git a/internal/gateway/reason.go b/internal/gateway/reason.go index 68edcc3b..e45db671 100644 --- a/internal/gateway/reason.go +++ b/internal/gateway/reason.go @@ -63,6 +63,11 @@ var ( Code: "configuration_changed", Message: "Configuration changed; retry the request.", } + reasonParameterOverrideUnavailable = reason{ + Status: http.StatusServiceUnavailable, + Code: "parameter_override_unavailable", + Message: "No upstream candidate could apply the configured parameter overrides.", + } ) type accessKeyCostLimitRuleError struct { diff --git a/internal/gateway/reason_test.go b/internal/gateway/reason_test.go index 65cfdd44..8f401ebc 100644 --- a/internal/gateway/reason_test.go +++ b/internal/gateway/reason_test.go @@ -26,6 +26,7 @@ func TestWriteReasonUsesStableDataPlaneEnvelope(t *testing.T) { reasonModelListTooLarge, reasonAccessKeyRateLimited, reasonConfigurationChanged, + reasonParameterOverrideUnavailable, } gin.SetMode(gin.TestMode) for _, want := range tests { diff --git a/internal/gateway/request_log.go b/internal/gateway/request_log.go index 9880ada4..146757ff 100644 --- a/internal/gateway/request_log.go +++ b/internal/gateway/request_log.go @@ -39,11 +39,15 @@ type requestOutcome struct { } type frozenAttemptPricing struct { - channelID string - groupID uint - upstreamModel string - table *pricing.Table - applicable bool + channelID string + groupID uint + upstreamModel string + table *pricing.Table + applicable bool + metadataSet bool + pricingMode pricing.Mode + usageDiagnostics usage.Diagnostics + reasoning reasoning.Config } type requestRecorder struct { @@ -520,18 +524,27 @@ func (recorder *requestRecorder) bindUsage( if attempt.GroupID == 0 || attempt.ChannelID == "" || attempt.CredentialID == 0 || attempt.Sequence < 1 { return } - if !applicable || !recorder.usageApplicable { - result = usage.Result{State: usage.StateNotApplicable} - } else if !validCapturedUsage(result) { - result = usage.Result{State: usage.StateMissing} - } else { - result.Diagnostics.Merge(recorder.usageDiagnostics) - } frozen := frozenAttemptPricing{} if attemptIndex < len(recorder.attemptPricing) { frozen = recorder.attemptPricing[attemptIndex] } - pricingMode := effectivePricingMode(recorder.requestedPricingMode) + usageApplicable := recorder.usageApplicable + requestDiagnostics := recorder.usageDiagnostics + pricingMode := recorder.requestedPricingMode + if frozen.metadataSet { + usageApplicable = frozen.applicable + requestDiagnostics = frozen.usageDiagnostics + pricingMode = frozen.pricingMode + recorder.setReasoning(frozen.reasoning) + } + if !applicable || !usageApplicable { + result = usage.Result{State: usage.StateNotApplicable} + } else if !validCapturedUsage(result) { + result = usage.Result{State: usage.StateMissing} + } else { + result.Diagnostics.Merge(requestDiagnostics) + } + pricingMode = effectivePricingMode(pricingMode) pricingObservation := quoteFrozenAttempt(frozen, result, pricingMode) recorder.usage = telemetry.UsageObservation{ Result: result, diff --git a/internal/gateway/request_log_test.go b/internal/gateway/request_log_test.go index add6d05c..95b203f6 100644 --- a/internal/gateway/request_log_test.go +++ b/internal/gateway/request_log_test.go @@ -25,6 +25,7 @@ import ( "gpt-load/internal/execution" "gpt-load/internal/health" "gpt-load/internal/httplifecycle" + "gpt-load/internal/platform/config" "gpt-load/internal/platform/encryption" "gpt-load/internal/platform/redact" "gpt-load/internal/pricing" @@ -1090,6 +1091,104 @@ func TestRequestRecorderMergesRequestPricingDiagnosticsIntoKnownCost(t *testing. } } +func TestRequestRecorderUsesFrozenAttemptMetadata(t *testing.T) { + recorder := newRequestRecorder( + &recordingRequestLogSink{}, "attempt-metadata", time.Unix(100, 0), 1, + protocol.OpenAICompletions, func() time.Time { return time.Unix(101, 0) }, + ) + originalDiagnostics := usage.Diagnostics{} + originalDiagnostics.Add(usage.DiagnosticUnsupportedBillableDetail) + recorder.setUsageDiagnostics(originalDiagnostics) + recorder.setPricingMode(pricing.ModeStandard) + recorder.setReasoning(reasoning.Config{Effort: "low"}) + model := "gpt-4o" + recorder.freezeNextAttemptPricing(frozenAttemptPricing{ + channelID: string(channel.OpenAI), groupID: 1, + upstreamModel: model, + table: mustGatewayPriceTableWithFast(t, 2_000_000_000, 5_000_000_000), + applicable: true, + metadataSet: true, + pricingMode: pricing.ModeFast, + reasoning: reasoning.Config{Effort: "high"}, + }) + selection := requestLogSelection(1, 2, "group") + selection.UpstreamModelID = &model + index := recorder.appendAttempt( + selection, + UpstreamResult{StatusCode: http.StatusOK}, + telemetry.FailureCategoryOK, telemetry.ActionTerminate, "", "", + time.Unix(100, 0), time.Unix(101, 0), + ) + recorder.completeResponse(UpstreamResult{ + StatusCode: http.StatusOK, + Usage: usage.Result{ + State: usage.StateComplete, + Tokens: usage.Tokens{UncachedInput: 1_000_000, Output: 1_000_000}, + }, + }, health.Decision{}, model, index) + + if recorder.usage.Result.Diagnostics.Has(usage.DiagnosticUnsupportedBillableDetail) { + t.Fatalf("usage diagnostics = %#v, want attempt-local diagnostics", recorder.usage.Result.Diagnostics) + } + if got := withoutPricingReceipt(recorder.usage.Pricing); got.EstimatedCostNanoUSD != 10_000_000_000 { + t.Fatalf("attempt pricing = %#v", got) + } + if recorder.reasoning.Effort != "high" { + t.Fatalf("request reasoning = %#v", recorder.reasoning) + } +} + +func TestRequestRecorderDoesNotReuseClientMetadataWhenAttemptObservationIsUnavailable(t *testing.T) { + recorder := newRequestRecorder( + &recordingRequestLogSink{}, "attempt-metadata-unavailable", time.Unix(100, 0), 1, + protocol.OpenAICompletions, func() time.Time { return time.Unix(101, 0) }, + ) + recorder.setPricingMode(pricing.ModeFast) + recorder.setReasoning(reasoning.Config{Effort: "low"}) + model := "gpt-4o" + provider := &mutableGatewayPriceTableProvider{ + table: mustGatewayPriceTableWithFast(t, 2_000_000_000, 5_000_000_000), + } + handler := &Handler{priceTables: provider} + selection := requestLogSelection(1, 2, "group") + selection.UpstreamModelID = &model + recorder.freezeNextAttemptPricing(handler.freezeAttemptPricing( + selection, + dialect.RequestMetadata{ObserveUsage: true}, + false, + )) + index := recorder.appendAttempt( + selection, + UpstreamResult{StatusCode: http.StatusOK}, + telemetry.FailureCategoryOK, + telemetry.ActionTerminate, + "", + "", + time.Unix(100, 0), + time.Unix(101, 0), + ) + recorder.completeResponse(UpstreamResult{ + StatusCode: http.StatusOK, + Usage: usage.Result{ + State: usage.StateComplete, + Tokens: usage.Tokens{UncachedInput: 1_000_000}, + }, + }, health.Decision{}, model, index) + + if recorder.reasoning != (reasoning.Config{}) { + t.Fatalf("request reasoning = %#v, want unknown", recorder.reasoning) + } + if got := withoutPricingReceipt(recorder.usage.Pricing); got != (telemetry.PricingObservation{ + UpstreamModel: model, + CostState: string(pricing.CostStateUnpriced), PricingCompleteness: string(pricing.CompletenessUnavailable), + }) { + t.Fatalf("attempt pricing = %#v", got) + } + if provider.LoadCount() != 0 { + t.Fatalf("PriceTableProvider.Load calls = %d, want 0", provider.LoadCount()) + } +} + func TestHandlerUsesSelectedProviderModelForPricingInsteadOfAliasOrBodyModel(t *testing.T) { model := "provider-model" table, err := pricing.NewTable([]pricing.Rule{{ @@ -1187,6 +1286,68 @@ func TestHandlerUsesRequestedFastModeWhenResponseOmitsEffectiveMode(t *testing.T } } +func TestHandlerUsesParameterOverrideAttemptObservations(t *testing.T) { + table := mustGatewayPriceTableWithFast(t, 2_000_000_000, 5_000_000_000) + forwarder := &scriptedForwarder{results: []UpstreamResult{{ + StatusCode: http.StatusOK, Header: make(http.Header), RequestWritten: true, + Usage: usage.Result{ + State: usage.StateComplete, + Tokens: usage.Tokens{UncachedInput: 1_000_000, Output: 1_000_000}, + }, + }}} + sink := &recordingRequestLogSink{} + engine, handler, manager, _ := newRequestLogHandlerTestRuntime( + t, forwarder, &recordingAccessKeyRPMLimiter{}, sink, "sk-first", + ) + if _, err := manager.Publish(state.CompileInput{ + ChannelRegistry: channel.NewRegistry(), + Groups: []state.GroupConfig{{ + ConnectionType: "api_key", ID: 1, Name: "openai", ChannelID: channel.OpenAI, + Params: json.RawMessage(`{}`), Models: []state.ModelConfig{{ID: "gpt-4o"}}, Enabled: true, + Settings: config.Settings{state.SettingParameterOverrides: []any{ + map[string]any{ + "match": map[string]any{"model": "gpt-*"}, + "set": map[string]any{ + "service_tier": "priority", + "reasoning_effort": "high", + }, + }, + }}, + }}, + Credentials: []state.CredentialConfig{{ + ID: 1, GroupID: 1, Status: state.CredentialStatusActive, + Version: 1, IdentityGeneration: 1, Fingerprint: "credential-1", + }}, + AccessKeys: []state.AccessKeyConfig{{ + ID: 1, Name: "client", KeyHash: handler.encryption.Hash("gl-client"), + Status: state.AccessKeyStatusActive, + }}, + }); err != nil { + t.Fatal(err) + } + handler.priceTables = &mutableGatewayPriceTableProvider{table: table} + request := httptest.NewRequest( + http.MethodPost, + "/v1/chat/completions", + strings.NewReader(`{"model":"gpt-4o"}`), + ) + request.Header.Set("Authorization", "Bearer gl-client") + engine.ServeHTTP(httptest.NewRecorder(), request) + + events := sink.snapshot() + if len(events) != 1 || len(forwarder.inputs) != 1 || + !bytes.Contains(forwarder.inputs[0].Request.Body, []byte(`"service_tier":"priority"`)) { + t.Fatalf("events/inputs = %#v/%#v", events, forwarder.inputs) + } + var receipt pricing.Receipt + if err := json.Unmarshal([]byte(events[0].Usage.Pricing.ReceiptJSON), &receipt); err != nil || + receipt.PricingMode != pricing.ModeFast || + events[0].Usage.Pricing.EstimatedCostNanoUSD != 10_000_000_000 || + events[0].Reasoning.Effort != "high" { + t.Fatalf("override pricing = %#v, receipt=%#v, error=%v", events[0].Usage.Pricing, receipt, err) + } +} + func TestHandlerUsesClientRequestPricingMode(t *testing.T) { for _, test := range []struct { name string diff --git a/internal/parameteroverride/request.go b/internal/parameteroverride/request.go new file mode 100644 index 00000000..ecb7a66f --- /dev/null +++ b/internal/parameteroverride/request.go @@ -0,0 +1,239 @@ +package parameteroverride + +import ( + "bytes" + "encoding/json" + "unicode/utf8" + + "github.com/buger/jsonparser" +) + +// 只为规则涉及的路径建立节点;其他值始终引用原始 JSON,不展开数组或对象。 +// raw 为 nil 表示字段不存在,JSON null 则保留为四个字节。 +type requestValue struct { + raw []byte + fields map[string]*requestValue + loaded bool + dirty bool + // 当前字段在父对象原始 JSON 中最后一次出现的位置,不随覆盖值改变。 + start int + end int + // 重复对象通过代次使旧子字段失效,避免每次遍历全部规则字段清空。 + generation uint + parentGeneration uint +} + +func (value *requestValue) field(key string) *requestValue { + if value.fields == nil { + value.fields = make(map[string]*requestValue) + } + if value.fields[key] == nil { + value.fields[key] = &requestValue{} + } + return value.fields[key] +} + +func (value *requestValue) planSet(source map[string]any) { + for key, item := range source { + child := value.field(key) + if object, ok := item.(map[string]any); ok { + child.planSet(object) + } + } +} + +func (value *requestValue) planPath(path []string) { + for _, key := range path { + value = value.field(key) + } +} + +func (value *requestValue) reset(raw []byte) { + value.raw, value.loaded, value.dirty = raw, false, false +} + +func (value *requestValue) isObject() bool { + return len(value.raw) > 0 && value.raw[0] == '{' +} + +func (value *requestValue) load() error { + if value.loaded { + return nil + } + _, err := value.scan(value.raw) + return err +} + +// 按规则路径直接下行,返回对象终点;父层不先扫描整个子树再递归。 +func (value *requestValue) scan(body []byte) (int, error) { + value.generation++ + end, err := value.eachField(body, true, nil) + if err != nil { + return 0, err + } + value.raw, value.loaded = body[:end], true + return end, nil +} + +func (value *requestValue) currentField(key string) *requestValue { + child := value.fields[key] + if child.parentGeneration != value.generation { + child.reset(nil) + child.start, child.end = 0, 0 + child.parentGeneration = value.generation + } + return child +} + +func (value *requestValue) remove(path []string) error { + if !value.isObject() { + return nil + } + if err := value.load(); err != nil { + return err + } + child := value.currentField(path[0]) + if len(path) == 1 { + child.reset(nil) + child.dirty = true + } else if err := child.remove(path[1:]); err != nil { + return err + } + value.dirty = value.dirty || child.dirty + return nil +} + +func (value *requestValue) merge(source any) error { + if object, ok := source.(map[string]any); ok && value.isObject() { + if len(object) == 0 { + return nil + } + if err := value.load(); err != nil { + return err + } + for key, item := range object { + if err := value.currentField(key).merge(item); err != nil { + return err + } + } + value.dirty = true + return nil + } + // 仅序列化受配置大小限制的替换值,客户端的大值不会进入通用 JSON 树。 + var encoded bytes.Buffer + encoder := json.NewEncoder(&encoded) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(source); err != nil { + return err + } + value.reset(bytes.TrimSuffix(encoded.Bytes(), []byte("\n"))) + value.dirty = true + return nil +} + +type requestOutput struct { + body []byte + size int +} + +func (output *requestOutput) append(raw []byte) { + output.size += len(raw) + if output.body != nil { + output.body = append(output.body, raw...) + } +} + +func (value *requestValue) write(output *requestOutput) error { + if !value.dirty || !value.loaded { + output.append(value.raw) + return nil + } + output.append([]byte{'{'}) + first := true + separator := func() { + if !first { + output.append([]byte{','}) + } + first = false + } + if _, err := value.eachField(value.raw, false, func(key, member []byte) { + if child := value.fields[string(key)]; child != nil && child.parentGeneration == value.generation && child.dirty { + return + } + separator() + output.append(member) + }); err != nil { + return err + } + for key, child := range value.fields { + if child.parentGeneration != value.generation || !child.dirty || child.raw == nil { + continue + } + separator() + encodedKey, err := json.Marshal(key) + if err != nil { + return err + } + output.append(encodedKey) + output.append([]byte{':'}) + if err := child.write(output); err != nil { + return err + } + } + output.append([]byte{'}'}) + return nil +} + +// 调用方已用 json.Valid 校验完整请求。这里只扫描字段边界,值引用原始字节; +// 仅含转义或非法 UTF-8 的键需要解码,沿用 encoding/json 的键名语义。 +func (value *requestValue) eachField(body []byte, discover bool, visit func(key, member []byte)) (int, error) { + remaining := bytes.TrimLeft(body[1:], " \t\r\n") + for remaining[0] != '}' { + member := remaining + key, _, end, err := jsonparser.Get(remaining) + if err != nil { + return 0, err + } + if bytes.ContainsRune(key, '\\') || !utf8.Valid(key) { + var decoded string + if err := json.Unmarshal(remaining[:end], &decoded); err != nil { + return 0, err + } + key = []byte(decoded) + } + remaining = bytes.TrimLeft(remaining[end:], " \t\r\n") + remaining = bytes.TrimLeft(remaining[1:], " \t\r\n") // 跳过冒号。 + start := len(body) - len(remaining) + child := value.fields[string(key)] + switch { + case discover && child != nil && remaining[0] == '{' && len(child.fields) > 0: + child.reset(nil) + end, err = child.scan(remaining) + case !discover && child != nil && child.parentGeneration == value.generation && child.start == start && child.end > start: + // 测量和输出直接使用已发现的终点,不重复扫描深层的大值。 + end = child.end - start + default: + _, _, end, err = jsonparser.Get(remaining) + if discover && child != nil { + child.reset(nil) + } + } + if err != nil { + return 0, err + } + if discover && child != nil { + // 重复键只保留最后一次的位置,索引大小不随客户端字段数量增长。 + child.raw = remaining[:end] + child.start, child.end = start, start+end + child.parentGeneration = value.generation + } + if visit != nil { + visit(key, member[:len(member)-len(remaining)+end]) + } + remaining = bytes.TrimLeft(remaining[end:], " \t\r\n") + if remaining[0] == ',' { + remaining = bytes.TrimLeft(remaining[1:], " \t\r\n") + } + } + return len(body) - len(remaining) + 1, nil +} diff --git a/internal/parameteroverride/request_test.go b/internal/parameteroverride/request_test.go new file mode 100644 index 00000000..d44dd5d9 --- /dev/null +++ b/internal/parameteroverride/request_test.go @@ -0,0 +1,237 @@ +package parameteroverride + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" + + "gpt-load/internal/execution" + "gpt-load/internal/protocol" +) + +func TestApplyAllocationsDoNotScaleWithUntouchedElements(t *testing.T) { + var fields strings.Builder + for index := range 8192 { + fmt.Fprintf(&fields, `"field%d":0,`, index) + } + for name, payload := range map[string]string{ + "numbers": `{"input":[` + strings.Repeat("0,", 32768) + `0]}`, + "objects": `{"input":[` + strings.Repeat(`{"a":0},`, 8192) + `{}]}`, + "root fields": `{` + fields.String() + `"last":0}`, + "nested fields": `{"nested":{` + fields.String() + `"last":0}}`, + "duplicate targets": `{` + strings.Repeat(`"nested":{"payload":[0],"marker":false},`, 8192) + `"last":0}`, + } { + t.Run(name, func(t *testing.T) { + rules := compileRulesForTest(t, []any{map[string]any{ + "set": map[string]any{"marker": true, "nested": map[string]any{"marker": true}}, + }}) + body := []byte(payload) + allocations := testing.AllocsPerRun(1, func() { + result, applied, err := rules.Apply(protocol.OpenAICompletions, execution.OperationChatCompletion, "model", body) + if err != nil || !applied || !json.Valid(result) { + t.Fatalf("Apply() applied = %t, error = %v", applied, err) + } + }) + if allocations > 200 { + t.Fatalf("Apply() allocations = %.0f for %d bytes; untouched elements must not become heap objects", allocations, len(body)) + } + }) + } +} + +func TestRequestLoadIndexesNestedPathsTogether(t *testing.T) { + const depth = 64 + body := []byte(strings.Repeat(`{"n":`, depth-1) + `{"payload":[0,1,2],"flag":true}` + strings.Repeat("}", depth-1)) + root := &requestValue{raw: body} + path := strings.Split(strings.Repeat("n/", depth-1)+"flag", "/") + root.planPath(path) + if err := root.load(); err != nil { + t.Fatal(err) + } + node := root + for index, key := range path { + if !node.loaded { + t.Fatalf("depth %d requires another descendant scan", index+1) + } + node = node.fields[key] + } + if string(node.raw) != "true" { + t.Fatalf("leaf = %s, want true", node.raw) + } +} + +func TestApplyDuplicateObjectsDiscardEarlierChildren(t *testing.T) { + rules := compileRulesForTest(t, []any{map[string]any{ + "set": map[string]any{"nested": map[string]any{"branch": map[string]any{"new": true}}}, + }}) + for _, body := range []string{ + `{"nested":{"branch":{"old":true}},"nested":{}}`, + `{"nested":{"branch":{"old":true}},"nested":null}`, + `{"nested":{"branch":{"old":true}},"nested":{"branch":{}}}`, + } { + got, _, err := rules.Apply(protocol.OpenAICompletions, execution.OperationChatCompletion, "model", []byte(body)) + if err != nil || string(got) != `{"nested":{"branch":{"new":true}}}` { + t.Fatalf("Apply(%s) = %s, %v", body, got, err) + } + } +} + +func BenchmarkApplyNestedRequest(b *testing.B) { + for _, depth := range []int{1, 4, 16, 64} { + b.Run(fmt.Sprintf("depth%d", depth), func(b *testing.B) { + body := []byte(strings.Repeat(`{"n":`, depth-1) + `{"payload":"` + strings.Repeat("x", 4<<20) + `","flag":true}` + strings.Repeat("}", depth-1)) + rules, err := Compile([]any{map[string]any{"remove": []any{strings.Repeat("/n", depth-1) + "/flag"}}}) + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + b.SetBytes(int64(len(body))) + b.ResetTimer() + for b.Loop() { + if _, _, err := rules.Apply(protocol.OpenAICompletions, execution.OperationChatCompletion, "model", body); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func TestApplyPreservesSequentialMergeAndRemoveSemantics(t *testing.T) { + for _, body := range []string{ + `{"nested":{"keep":1,"remove":2},"array":[0,{},null],"text":"<>&","number":-0}`, + `{"nested":[0,1],"array":{"0":true},"0":{"child":1},"a/b":{"~":1}}`, + `{"nested":{"first":1},"nested":{"second":2},"\u0061":0,"a":1}`, + `{"nested":{"first":1},"nested":null,"tail":true}`, + `{"nested":null,"nested":{"first":1},"tail":true}`, + `{"nested":null,"empty":{},"literal":"\\\"{}[]","a.b":3}`, + `{"nested":{"keep":1},"\ud800":1,"\ud800\udc00":2}`, + } { + t.Run(body, func(t *testing.T) { + rules := compileRulesForTest(t, []any{ + map[string]any{"remove": []any{"/nested/remove", "/array/child"}, "set": map[string]any{"nested": map[string]any{"first": true}, "empty": map[string]any{}, "0": map[string]any{"new": true}}}, + map[string]any{"remove": []any{"/nested", "/a~1b/~0"}, "set": map[string]any{"nested": map[string]any{"last": true}, "array": []any{1, nil}, "a": nil}}, + map[string]any{"remove": []any{"/nested/last"}, "set": map[string]any{"nested": map[string]any{"final": true}}}, + map[string]any{"set": map[string]any{"empty": 0}}, + map[string]any{"set": map[string]any{"empty": map[string]any{}}}, + map[string]any{"set": map[string]any{"empty": map[string]any{"value": nil}}}, + }) + var want map[string]any + decodeJSONForTest(t, []byte(body), &want) + for _, entry := range rules.entries { + for _, path := range entry.remove { + removeObjectField(want, path) + } + mergeObject(want, entry.set) + } + gotBody, _, err := rules.Apply(protocol.OpenAICompletions, execution.OperationChatCompletion, "model", []byte(body)) + if err != nil { + t.Fatal(err) + } + var got map[string]any + decodeJSONForTest(t, gotBody, &got) + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %s, want %#v", gotBody, want) + } + if bytes.Contains(gotBody, []byte(`\u003c`)) { + t.Fatal("unchanged strings were HTML escaped") + } + }) + } +} + +func TestApplyDoesNotRetainRemovedPayloadInOutput(t *testing.T) { + rules := compileRulesForTest(t, []any{map[string]any{"remove": []any{"/input"}}}) + body := []byte(`{"input":"` + strings.Repeat("x", 1<<20) + `","keep":true}`) + got, applied, err := rules.Apply(protocol.OpenAICompletions, execution.OperationChatCompletion, "model", body) + if err != nil || !applied || string(got) != `{"keep":true}` { + t.Fatalf("Apply() = %s, %t, %v", got, applied, err) + } + if cap(got) > 1024 { + t.Fatalf("small output retained a %d-byte backing buffer", cap(got)) + } + if !bytes.Contains(body, []byte(`"input"`)) { + t.Fatal("original request was modified") + } +} + +func FuzzApplyMatchesTreeSemantics(f *testing.F) { + for _, body := range []string{ + `{}`, `{"nested":{"keep":1,"remove":2}}`, `{"nested":[{}],"input":[0,1]}`, + `{"nested":{"x":1},"nested":{"y":2}}`, `{"\ud800":1}`, + `{"nested":{"remove":{"deep":[0]}},"nested":null,"tail":true}`, + } { + f.Add(body) + } + f.Fuzz(func(t *testing.T, body string) { + if len(body) > 4096 || !json.Valid([]byte(body)) { + return + } + var want map[string]any + if decodeJSON([]byte(body), &want) != nil || want == nil { + return + } + rules := compileRulesForTest(t, []any{ + map[string]any{"remove": []any{"/nested/remove"}, "set": map[string]any{"nested": map[string]any{"keep": true}}}, + map[string]any{"remove": []any{"/nested/keep"}, "set": map[string]any{"input": []any{1, nil}, "nested": map[string]any{"final": true}}}, + }) + for _, entry := range rules.entries { + for _, path := range entry.remove { + removeObjectField(want, path) + } + mergeObject(want, entry.set) + } + gotBody, _, err := rules.Apply(protocol.OpenAICompletions, execution.OperationChatCompletion, "model", []byte(body)) + if err != nil { + t.Fatal(err) + } + var got map[string]any + decodeJSONForTest(t, gotBody, &got) + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %s, want %#v", gotBody, want) + } + }) +} + +func BenchmarkApplyDenseRequest(b *testing.B) { + rules, err := Compile([]any{map[string]any{"set": map[string]any{"temperature": 0.5}}}) + if err != nil { + b.Fatal(err) + } + body := []byte(`{"input":[` + strings.Repeat("0,", 1<<19) + `0]}`) + b.ReportAllocs() + b.SetBytes(int64(len(body))) + b.ResetTimer() + for b.Loop() { + if _, _, err := rules.Apply(protocol.OpenAICompletions, execution.OperationChatCompletion, "model", body); err != nil { + b.Fatal(err) + } + } +} + +// 保留原整树算法作为小数据的独立语义参照,不再用于数据面请求。 +func mergeObject(target, source map[string]any) { + for key, value := range source { + sourceObject, sourceIsObject := value.(map[string]any) + targetObject, targetIsObject := target[key].(map[string]any) + if sourceIsObject && targetIsObject { + mergeObject(targetObject, sourceObject) + continue + } + target[key] = cloneJSON(value) + } +} + +func removeObjectField(target map[string]any, pointer []string) { + current := target + for _, segment := range pointer[:len(pointer)-1] { + nested, ok := current[segment].(map[string]any) + if !ok { + return + } + current = nested + } + delete(current, pointer[len(pointer)-1]) +} diff --git a/internal/parameteroverride/rules.go b/internal/parameteroverride/rules.go new file mode 100644 index 00000000..86c58003 --- /dev/null +++ b/internal/parameteroverride/rules.go @@ -0,0 +1,457 @@ +// Package parameteroverride owns Group-scoped request-parameter override rules. +package parameteroverride + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "math/big" + "strconv" + "strings" + + "gpt-load/internal/execution" + "gpt-load/internal/protocol" +) + +const ( + maxRules = 100 + maxConfigBytes = 256 << 10 + maxRemovePaths = 256 + maxJSONDepth = 64 + maxSafeInteger = int64(1<<53 - 1) +) + +var forbiddenRootFields = []string{"model", "stream", "store"} + +type rule struct { + clientProtocol protocol.Protocol + model string + modelPrefix bool + set map[string]any + remove [][]string +} + +// Rules is an immutable compiled ordered rule set. +type Rules struct { + entries []rule +} + +// Compile validates and compiles one JSON-decoded parameter_overrides value. +func Compile(value any) (Rules, error) { + encoded, err := json.Marshal(value) + if err != nil { + return Rules{}, fmt.Errorf("encode parameter overrides: %w", err) + } + if len(encoded) > maxConfigBytes { + return Rules{}, fmt.Errorf("parameter overrides exceed %d bytes", maxConfigBytes) + } + var rawRules []map[string]json.RawMessage + if err := decodeJSON(encoded, &rawRules); err != nil { + return Rules{}, fmt.Errorf("parameter overrides must be an array: %w", err) + } + if rawRules == nil && !bytes.Equal(bytes.TrimSpace(encoded), []byte("[]")) { + return Rules{}, fmt.Errorf("parameter overrides must be an array") + } + if len(rawRules) > maxRules { + return Rules{}, fmt.Errorf("parameter overrides exceed %d rules", maxRules) + } + + compiled := Rules{entries: make([]rule, 0, len(rawRules))} + totalRemovePaths := 0 + for index, raw := range rawRules { + entry, removeCount, err := compileRule(raw) + if err != nil { + return Rules{}, fmt.Errorf("parameter override rule %d: %w", index+1, err) + } + totalRemovePaths += removeCount + if totalRemovePaths > maxRemovePaths { + return Rules{}, fmt.Errorf("parameter overrides exceed %d remove paths", maxRemovePaths) + } + compiled.entries = append(compiled.entries, entry) + } + return compiled, nil +} + +func compileRule(raw map[string]json.RawMessage) (rule, int, error) { + for key := range raw { + switch key { + case "match", "set", "remove": + default: + return rule{}, 0, fmt.Errorf("unknown field %q", key) + } + } + entry := rule{} + if rawMatch, exists := raw["match"]; exists { + match, err := compileMatch(rawMatch) + if err != nil { + return rule{}, 0, err + } + entry.clientProtocol = match.clientProtocol + entry.model = match.model + entry.modelPrefix = match.modelPrefix + } + if rawSet, exists := raw["set"]; exists { + trimmed := bytes.TrimSpace(rawSet) + if len(trimmed) == 0 || trimmed[0] != '{' { + return rule{}, 0, fmt.Errorf("set must be a JSON object") + } + if err := decodeJSON(rawSet, &entry.set); err != nil || entry.set == nil { + return rule{}, 0, fmt.Errorf("set must be a JSON object") + } + if err := validateSetValue(entry.set, 1); err != nil { + return rule{}, 0, err + } + for key := range entry.set { + if forbiddenRootField(key) { + return rule{}, 0, fmt.Errorf("set cannot change root field %q", key) + } + } + } + var removePaths []string + if rawRemove, exists := raw["remove"]; exists { + trimmed := bytes.TrimSpace(rawRemove) + if len(trimmed) == 0 || trimmed[0] != '[' || decodeJSON(rawRemove, &removePaths) != nil || removePaths == nil { + return rule{}, 0, fmt.Errorf("remove must be an array of JSON Pointer paths") + } + } + seenRemovePaths := make(map[string]struct{}, len(removePaths)) + for _, path := range removePaths { + if _, duplicate := seenRemovePaths[path]; duplicate { + return rule{}, 0, fmt.Errorf("remove path %q is duplicated", path) + } + seenRemovePaths[path] = struct{}{} + segments, err := parsePointer(path) + if err != nil { + return rule{}, 0, fmt.Errorf("remove path %q: %w", path, err) + } + if forbiddenRootField(segments[0]) { + return rule{}, 0, fmt.Errorf("remove cannot change root field %q", segments[0]) + } + entry.remove = append(entry.remove, segments) + } + if len(entry.set) == 0 && len(entry.remove) == 0 { + return rule{}, 0, fmt.Errorf("at least one set field or remove path is required") + } + return entry, len(removePaths), nil +} + +type compiledMatch struct { + clientProtocol protocol.Protocol + model string + modelPrefix bool +} + +func compileMatch(raw json.RawMessage) (compiledMatch, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '{' { + return compiledMatch{}, fmt.Errorf("match must be a JSON object") + } + var values map[string]json.RawMessage + if err := decodeJSON(raw, &values); err != nil || values == nil { + return compiledMatch{}, fmt.Errorf("match must be a JSON object") + } + for key := range values { + if key != "protocol" && key != "model" { + return compiledMatch{}, fmt.Errorf("match has unknown field %q", key) + } + } + result := compiledMatch{} + if rawProtocol, exists := values["protocol"]; exists { + var value string + if err := decodeJSON(rawProtocol, &value); err != nil { + return compiledMatch{}, fmt.Errorf("match.protocol must be a string") + } + if value == "" || strings.TrimSpace(value) != value { + return compiledMatch{}, fmt.Errorf("match.protocol must be omitted or contain a canonical protocol") + } + result.clientProtocol = protocol.Protocol(value) + if !result.clientProtocol.Valid() { + return compiledMatch{}, fmt.Errorf("match.protocol %q is invalid", value) + } + } + if rawModel, exists := values["model"]; exists { + var value string + if err := decodeJSON(rawModel, &value); err != nil { + return compiledMatch{}, fmt.Errorf("match.model must be a string") + } + if value == "" || strings.TrimSpace(value) != value { + return compiledMatch{}, fmt.Errorf("match.model must be omitted or contain a non-empty model") + } + wildcards := strings.Count(value, "*") + switch { + case wildcards == 0: + result.model = value + case wildcards == 1 && strings.HasSuffix(value, "*") && len(value) > 1: + result.model = strings.TrimSuffix(value, "*") + result.modelPrefix = true + default: + return compiledMatch{}, fmt.Errorf("match.model supports only one trailing prefix wildcard") + } + } + return result, nil +} + +// Empty reports whether no rule can be applied. +func (rules Rules) Empty() bool { return len(rules.entries) == 0 } + +// Clone returns an independently owned rule set. +func (rules Rules) Clone() Rules { + cloned := Rules{entries: make([]rule, len(rules.entries))} + for index, entry := range rules.entries { + cloned.entries[index] = rule{ + clientProtocol: entry.clientProtocol, + model: entry.model, + modelPrefix: entry.modelPrefix, + set: cloneObject(entry.set), + remove: clonePointers(entry.remove), + } + } + return cloned +} + +// Apply applies all matching rules to one supported client request body. +func (rules Rules) Apply( + clientProtocol protocol.Protocol, + operation execution.Operation, + clientModel string, + body []byte, +) ([]byte, bool, error) { + if rules.Empty() || !supports(clientProtocol, operation) { + return body, false, nil + } + matched := make([]rule, 0, len(rules.entries)) + for _, entry := range rules.entries { + if entry.matches(clientProtocol, clientModel) { + matched = append(matched, entry) + } + } + if len(matched) == 0 { + return body, false, nil + } + body = bytes.TrimSpace(body) + if len(body) == 0 || body[0] != '{' || !json.Valid(body) { + return nil, false, fmt.Errorf("request body must be a JSON object") + } + object := &requestValue{raw: body} + for _, entry := range matched { + object.planSet(entry.set) + for _, pointer := range entry.remove { + object.planPath(pointer) + } + } + for _, entry := range matched { + for _, pointer := range entry.remove { + if err := object.remove(pointer); err != nil { + return nil, false, err + } + } + if err := object.merge(entry.set); err != nil { + return nil, false, err + } + } + // 先计算最终长度,再精确分配一次;不为中间规则结果保留完整请求体。 + var measured requestOutput + if err := object.write(&measured); err != nil { + return nil, false, fmt.Errorf("encode overridden request body: %w", err) + } + encoded := requestOutput{body: make([]byte, 0, measured.size)} + if err := object.write(&encoded); err != nil { + return nil, false, fmt.Errorf("encode overridden request body: %w", err) + } + return encoded.body, true, nil +} + +func supports(clientProtocol protocol.Protocol, operation execution.Operation) bool { + switch clientProtocol { + case protocol.OpenAICompletions, protocol.Anthropic, protocol.Gemini: + return operation == execution.OperationChatCompletion + case protocol.OpenAIResponses: + return operation == execution.OperationResponsesCreate + case protocol.OpenAIImages: + return operation == execution.OperationImagesGenerate + case protocol.OpenAIEmbeddings: + return operation == execution.OperationEmbeddingsCreate + default: + return false + } +} + +func (entry rule) matches(clientProtocol protocol.Protocol, clientModel string) bool { + if entry.clientProtocol != "" && entry.clientProtocol != clientProtocol { + return false + } + if entry.model == "" { + return true + } + if entry.modelPrefix { + return strings.HasPrefix(clientModel, entry.model) + } + return clientModel == entry.model +} + +func parsePointer(value string) ([]string, error) { + if value == "" || value[0] != '/' { + return nil, fmt.Errorf("must start with / and cannot target the document root") + } + if strings.Count(value, "/") > maxJSONDepth { + return nil, fmt.Errorf("exceeds maximum path depth %d", maxJSONDepth) + } + rawSegments := strings.Split(value[1:], "/") + segments := make([]string, len(rawSegments)) + for index, raw := range rawSegments { + decoded, err := decodePointerSegment(raw) + if err != nil { + return nil, err + } + if decoded == "" { + return nil, fmt.Errorf("contains an empty path segment") + } + segments[index] = decoded + if decoded == "-" { + return nil, fmt.Errorf("array element paths are not supported") + } + if _, err := strconv.ParseUint(decoded, 10, 64); err == nil { + return nil, fmt.Errorf("array element paths are not supported") + } + } + return segments, nil +} + +func decodePointerSegment(value string) (string, error) { + var result strings.Builder + for index := 0; index < len(value); index++ { + if value[index] != '~' { + result.WriteByte(value[index]) + continue + } + if index+1 >= len(value) { + return "", fmt.Errorf("contains an invalid ~ escape") + } + index++ + switch value[index] { + case '0': + result.WriteByte('~') + case '1': + result.WriteByte('/') + default: + return "", fmt.Errorf("contains an invalid ~ escape") + } + } + return result.String(), nil +} + +func forbiddenRootField(value string) bool { + for _, field := range forbiddenRootFields { + if strings.EqualFold(value, field) { + return true + } + } + return false +} + +func validateSetValue(value any, depth int) error { + if depth > maxJSONDepth { + return fmt.Errorf("set exceeds maximum JSON depth %d", maxJSONDepth) + } + switch typed := value.(type) { + case map[string]any: + for key, nested := range typed { + if key == "" { + return fmt.Errorf("set contains an empty object field name") + } + if err := validateSetValue(nested, depth+1); err != nil { + return err + } + } + case []any: + for _, nested := range typed { + if err := validateSetValue(nested, depth+1); err != nil { + return err + } + } + case json.Number: + if err := validateJSONNumber(typed); err != nil { + return err + } + } + return nil +} + +func validateJSONNumber(value json.Number) error { + literal := value.String() + parsed, err := strconv.ParseFloat(literal, 64) + if err != nil || math.IsInf(parsed, 0) || math.IsNaN(parsed) { + return fmt.Errorf("set contains a number outside the supported range") + } + if parsed == 0 && math.Signbit(parsed) { + return fmt.Errorf("set contains a number that cannot round-trip through the management UI") + } + exact, ok := new(big.Rat).SetString(literal) + if !ok { + return fmt.Errorf("set contains an invalid number") + } + if exact.IsInt() { + absolute := new(big.Int).Abs(exact.Num()) + if absolute.Cmp(big.NewInt(maxSafeInteger)) > 0 { + return fmt.Errorf("set contains an integer outside the JSON safe range") + } + } + roundTrip, ok := new(big.Rat).SetString(strconv.FormatFloat(parsed, 'g', -1, 64)) + if !ok || exact.Cmp(roundTrip) != 0 { + return fmt.Errorf("set contains a number that cannot round-trip through the management UI") + } + return nil +} + +func clonePointers(source [][]string) [][]string { + cloned := make([][]string, len(source)) + for index, pointer := range source { + cloned[index] = append([]string(nil), pointer...) + } + return cloned +} + +func cloneObject(source map[string]any) map[string]any { + if source == nil { + return nil + } + cloned := make(map[string]any, len(source)) + for key, value := range source { + cloned[key] = cloneJSON(value) + } + return cloned +} + +func cloneJSON(value any) any { + switch typed := value.(type) { + case map[string]any: + return cloneObject(typed) + case []any: + cloned := make([]any, len(typed)) + for index, nested := range typed { + cloned[index] = cloneJSON(nested) + } + return cloned + default: + return value + } +} + +func decodeJSON(data []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := decoder.Decode(target); err != nil { + return err + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + return fmt.Errorf("multiple JSON values") + } + return err + } + return nil +} diff --git a/internal/parameteroverride/rules_test.go b/internal/parameteroverride/rules_test.go new file mode 100644 index 00000000..a36a5963 --- /dev/null +++ b/internal/parameteroverride/rules_test.go @@ -0,0 +1,306 @@ +package parameteroverride + +import ( + "bytes" + "encoding/json" + "reflect" + "strings" + "testing" + + "gpt-load/internal/execution" + "gpt-load/internal/protocol" +) + +func TestRulesApplyMatchingRulesInOrder(t *testing.T) { + rules := compileRulesForTest(t, []any{ + map[string]any{ + "match": map[string]any{}, + "set": map[string]any{ + "temperature": json.Number("0.7"), + "nested": map[string]any{"keep": true, "replace": "first"}, + "items": []any{"replacement"}, + "literal_null": nil, + }, + }, + map[string]any{ + "match": map[string]any{ + "protocol": string(protocol.OpenAIResponses), + "model": "gpt-5*", + }, + "remove": []any{"/temperature", "/nested/keep", "/a~1b/c~0d", "/missing"}, + "set": map[string]any{ + "temperature": json.Number("1.25"), + "nested": map[string]any{"replace": "last"}, + }, + }, + }) + + body, applied, err := rules.Apply( + protocol.OpenAIResponses, + execution.OperationResponsesCreate, + "gpt-5.4", + []byte(`{"model":"gpt-5.4","items":[1,2],"nested":{"keep":false,"original":1},"a/b":{"c~d":true,"keep":true}}`), + ) + if err != nil { + t.Fatal(err) + } + if !applied { + t.Fatal("Apply() applied = false, want true") + } + var got any + decodeJSONForTest(t, body, &got) + want := map[string]any{ + "model": "gpt-5.4", + "temperature": json.Number("1.25"), + "items": []any{"replacement"}, + "literal_null": nil, + "a/b": map[string]any{"keep": true}, + "nested": map[string]any{ + "original": json.Number("1"), + "replace": "last", + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Apply() body = %#v, want %#v", got, want) + } +} + +func TestRulesMatchClientProtocolAndClientModel(t *testing.T) { + rules := compileRulesForTest(t, []any{ + map[string]any{ + "match": map[string]any{ + "protocol": string(protocol.Anthropic), + "model": "public-*", + }, + "set": map[string]any{"max_tokens": json.Number("4096")}, + }, + }) + + for _, test := range []struct { + name string + protocol protocol.Protocol + operation execution.Operation + model string + applied bool + }{ + {name: "match", protocol: protocol.Anthropic, operation: execution.OperationChatCompletion, model: "public-sonnet", applied: true}, + {name: "protocol", protocol: protocol.OpenAICompletions, operation: execution.OperationChatCompletion, model: "public-sonnet"}, + {name: "model", protocol: protocol.Anthropic, operation: execution.OperationChatCompletion, model: "private-sonnet"}, + {name: "utility operation", protocol: protocol.Anthropic, operation: execution.OperationCountTokens, model: "public-sonnet"}, + } { + t.Run(test.name, func(t *testing.T) { + _, applied, err := rules.Apply(test.protocol, test.operation, test.model, []byte(`{"model":"public-sonnet"}`)) + if err != nil { + t.Fatal(err) + } + if applied != test.applied { + t.Fatalf("Apply() applied = %t, want %t", applied, test.applied) + } + }) + } +} + +func TestCompileRejectsInvalidRules(t *testing.T) { + tests := []struct { + name string + value any + }{ + {name: "not array", value: map[string]any{}}, + {name: "empty action", value: []any{map[string]any{"match": map[string]any{}}}}, + {name: "enabled field", value: []any{map[string]any{"enabled": true, "set": map[string]any{"temperature": 1}}}}, + {name: "operation condition", value: []any{map[string]any{"match": map[string]any{"operation": "chat_completion"}, "set": map[string]any{"temperature": 1}}}}, + {name: "empty protocol", value: []any{map[string]any{"match": map[string]any{"protocol": ""}, "set": map[string]any{"temperature": 1}}}}, + {name: "padded protocol", value: []any{map[string]any{"match": map[string]any{"protocol": " anthropic "}, "set": map[string]any{"temperature": 1}}}}, + {name: "empty model", value: []any{map[string]any{"match": map[string]any{"model": ""}, "set": map[string]any{"temperature": 1}}}}, + {name: "padded model", value: []any{map[string]any{"match": map[string]any{"model": " gpt-5"}, "set": map[string]any{"temperature": 1}}}}, + {name: "invalid protocol", value: []any{map[string]any{"match": map[string]any{"protocol": "openai"}, "set": map[string]any{"temperature": 1}}}}, + {name: "wildcard only", value: []any{map[string]any{"match": map[string]any{"model": "*"}, "set": map[string]any{"temperature": 1}}}}, + {name: "middle wildcard", value: []any{map[string]any{"match": map[string]any{"model": "gpt-*mini"}, "set": map[string]any{"temperature": 1}}}}, + {name: "forbidden set", value: []any{map[string]any{"set": map[string]any{"MODEL": "other"}}}}, + {name: "empty root set field", value: []any{map[string]any{"set": map[string]any{"": 1}}}}, + {name: "empty nested set field", value: []any{map[string]any{"set": map[string]any{"metadata": map[string]any{"": 1}}}}}, + {name: "forbidden remove", value: []any{map[string]any{"remove": []any{"/store/value"}}}}, + {name: "root remove", value: []any{map[string]any{"remove": []any{""}}}}, + {name: "empty root remove field", value: []any{map[string]any{"remove": []any{"/"}}}}, + {name: "empty nested remove field", value: []any{map[string]any{"remove": []any{"/metadata/"}}}}, + {name: "array remove", value: []any{map[string]any{"remove": []any{"/tools/0"}}}}, + {name: "deep remove", value: []any{map[string]any{"remove": []any{strings.Repeat("/field", maxJSONDepth+1)}}}}, + {name: "unsafe integer", value: []any{map[string]any{"set": map[string]any{"value": json.Number("9007199254740992")}}}}, + {name: "overflowing number", value: []any{map[string]any{"set": map[string]any{"value": json.Number("1e400")}}}}, + {name: "lossy decimal", value: []any{map[string]any{"set": map[string]any{"value": json.Number("0.10000000000000001")}}}}, + {name: "negative zero", value: []any{map[string]any{"set": map[string]any{"value": json.Number("-0")}}}}, + {name: "negative decimal zero", value: []any{map[string]any{"set": map[string]any{"value": json.Number("-0.0")}}}}, + {name: "negative exponent zero", value: []any{map[string]any{"set": map[string]any{"value": json.Number("-0e3")}}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := Compile(test.value); err == nil { + t.Fatal("Compile() accepted invalid rules") + } + }) + } +} + +func TestCompileAcceptsNumbersThatRoundTripThroughManagementUI(t *testing.T) { + for _, value := range []json.Number{ + "9007199254740991", + "-9007199254740991", + "0.1", + "1.0", + "1e3", + "1e-7", + } { + t.Run(value.String(), func(t *testing.T) { + if _, err := Compile([]any{map[string]any{"set": map[string]any{"value": value}}}); err != nil { + t.Fatalf("Compile(%s) error = %v", value, err) + } + }) + } +} + +func TestRulesPreserveHTMLCharactersWithoutExpansion(t *testing.T) { + rules := compileRulesForTest(t, []any{ + map[string]any{"set": map[string]any{"instruction": "&value"}}, + }) + input := strings.Repeat("<>&", 1024) + body := []byte(`{"model":"model","input":"` + input + `","precise":1.2300}`) + got, applied, err := rules.Apply(protocol.OpenAICompletions, execution.OperationChatCompletion, "model", body) + if err != nil || !applied { + t.Fatalf("Apply() applied = %t, error = %v", applied, err) + } + if len(got) > len(body)+64 || bytes.Contains(got, []byte(`\u003c`)) || bytes.Contains(got, []byte(`\u003e`)) || bytes.Contains(got, []byte(`\u0026`)) { + t.Fatalf("HTML characters expanded: input bytes = %d, output bytes = %d", len(body), len(got)) + } + var values map[string]any + decodeJSONForTest(t, got, &values) + if values["input"] != input || values["instruction"] != "&value" || values["precise"] != json.Number("1.2300") { + t.Fatal("parameter values or numeric literal changed") + } + if bytes.HasSuffix(got, []byte("\n")) { + t.Fatal("encoded body has an extra trailing newline") + } +} + +func TestRulesApplyLargeBodies(t *testing.T) { + rules := compileRulesForTest(t, []any{ + map[string]any{ + "match": map[string]any{"model": "matched"}, + "set": map[string]any{"temperature": json.Number("0.5")}, + }, + }) + prefix := []byte(`{"model":"matched","input":"`) + suffix := []byte(`"}`) + body := append(prefix, bytes.Repeat([]byte("x"), (10<<20)-len(prefix)-len(suffix))...) + body = append(body, suffix...) + + if got, applied, err := rules.Apply( + protocol.OpenAICompletions, + execution.OperationChatCompletion, + "matched", + body, + ); err != nil || !applied || !bytes.Contains(got, []byte(`"temperature":0.5`)) { + t.Fatalf("matching Apply() = applied %t, error %v", applied, err) + } + got, applied, err := rules.Apply( + protocol.OpenAICompletions, + execution.OperationChatCompletion, + "other", + body, + ) + if err != nil || applied || !bytes.Equal(got, body) { + t.Fatalf("non-matching Apply() = %d bytes, applied %t, error %v", len(got), applied, err) + } +} + +func TestRulesDoNotInterpretVariables(t *testing.T) { + rules := compileRulesForTest(t, []any{ + map[string]any{"set": map[string]any{"metadata": "${REQUEST_ID}"}}, + }) + body, applied, err := rules.Apply( + protocol.OpenAICompletions, + execution.OperationChatCompletion, + "gpt-4o", + []byte(`{"model":"gpt-4o"}`), + ) + if err != nil || !applied { + t.Fatalf("Apply() = applied %t, error %v", applied, err) + } + var got map[string]any + decodeJSONForTest(t, body, &got) + if got["metadata"] != "${REQUEST_ID}" { + t.Fatalf("metadata = %#v", got["metadata"]) + } +} + +func TestRulesUseFixedOperationAllowlist(t *testing.T) { + rules := compileRulesForTest(t, []any{ + map[string]any{"set": map[string]any{"marker": true}}, + }) + tests := []struct { + protocol protocol.Protocol + operation execution.Operation + want bool + }{ + {protocol.OpenAICompletions, execution.OperationChatCompletion, true}, + {protocol.OpenAIResponses, execution.OperationResponsesCreate, true}, + {protocol.OpenAIImages, execution.OperationImagesGenerate, true}, + {protocol.OpenAIEmbeddings, execution.OperationEmbeddingsCreate, true}, + {protocol.Anthropic, execution.OperationChatCompletion, true}, + {protocol.Gemini, execution.OperationChatCompletion, true}, + {protocol.OpenAICompletions, execution.OperationListModels, false}, + {protocol.OpenAIResponses, execution.OperationResponsesCompact, false}, + {protocol.OpenAIResponses, execution.OperationResponsesInputTokens, false}, + {protocol.OpenAIImages, execution.OperationImagesEdit, false}, + {protocol.Anthropic, execution.OperationCountTokens, false}, + {protocol.Gemini, execution.OperationProbe, false}, + } + for _, test := range tests { + _, applied, err := rules.Apply(test.protocol, test.operation, "model", []byte(`{}`)) + if err != nil { + t.Fatal(err) + } + if applied != test.want { + t.Errorf("Apply(%s, %s) = %t, want %t", test.protocol, test.operation, applied, test.want) + } + } +} + +func TestRulesCloneOwnsMutableValues(t *testing.T) { + rules := compileRulesForTest(t, []any{ + map[string]any{ + "set": map[string]any{"nested": map[string]any{"value": "original"}}, + "remove": []any{"/removed"}, + }, + }) + cloned := rules.Clone() + rules.entries[0].set["nested"].(map[string]any)["value"] = "changed" + rules.entries[0].remove[0][0] = "other" + + body, applied, err := cloned.Apply( + protocol.OpenAICompletions, + execution.OperationChatCompletion, + "model", + []byte(`{"removed":true}`), + ) + if err != nil || !applied || string(body) != `{"nested":{"value":"original"}}` { + t.Fatalf("cloned Apply() = %s, %t, %v", body, applied, err) + } +} + +func compileRulesForTest(t *testing.T, value any) Rules { + t.Helper() + rules, err := Compile(value) + if err != nil { + t.Fatal(err) + } + return rules +} + +func decodeJSONForTest(t *testing.T, body []byte, target any) { + t.Helper() + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + if err := decoder.Decode(target); err != nil { + t.Fatal(err) + } +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index fc1a4203..b78161cf 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -380,6 +380,7 @@ func cloneGroupView(group state.GroupView) state.GroupView { group.HeaderRules.Set = cloneStringMap(group.HeaderRules.Set) group.HeaderRules.Remove = append([]string(nil), group.HeaderRules.Remove...) group.ResolvedTarget.TargetConfig = append([]byte(nil), group.ResolvedTarget.TargetConfig...) + group.ParameterOverrides = group.ParameterOverrides.Clone() return group } diff --git a/internal/state/parameter_overrides_test.go b/internal/state/parameter_overrides_test.go new file mode 100644 index 00000000..a62dac82 --- /dev/null +++ b/internal/state/parameter_overrides_test.go @@ -0,0 +1,42 @@ +package state + +import ( + "encoding/json" + "testing" + + "gpt-load/internal/execution" + "gpt-load/internal/protocol" +) + +func TestResolveGroupRuntimeSettingsCompilesParameterOverrides(t *testing.T) { + resolved, err := ResolveGroupRuntimeSettings(DefaultRuntimeSettings(), map[string]any{ + SettingParameterOverrides: []any{ + map[string]any{ + "match": map[string]any{"model": "public-*"}, + "set": map[string]any{"temperature": json.Number("0.25")}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + body, applied, err := resolved.ParameterOverrides.Apply( + protocol.OpenAICompletions, + execution.OperationChatCompletion, + "public-model", + []byte(`{"model":"public-model"}`), + ) + if err != nil || !applied || string(body) != `{"model":"public-model","temperature":0.25}` { + t.Fatalf("Apply() = %s, %t, %v", body, applied, err) + } +} + +func TestResolveRuntimeSettingsRejectsGroupOnlyParameterOverrides(t *testing.T) { + _, err := ResolveRuntimeSettings(map[string]any{SettingParameterOverrides: []any{}}) + if err == nil { + t.Fatal("ResolveRuntimeSettings() accepted group-only parameter overrides") + } + if IsRuntimeSettingKey(SettingParameterOverrides) { + t.Fatal("IsRuntimeSettingKey() exposed group-only parameter overrides") + } +} diff --git a/internal/state/runtime_settings.go b/internal/state/runtime_settings.go index 19cd09c8..2e3050f0 100644 --- a/internal/state/runtime_settings.go +++ b/internal/state/runtime_settings.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "gpt-load/internal/parameteroverride" "gpt-load/internal/platform/config" "gpt-load/internal/platform/httpheader" ) @@ -29,6 +30,7 @@ const ( SettingValidationInterval = "validation_interval" SettingRequestLogRetentionDays = "request_log_retention_days" SettingModelsDevAutoSyncEnabled = "models_dev_auto_sync_enabled" + SettingParameterOverrides = "parameter_overrides" ) const ( @@ -65,6 +67,7 @@ type ResolvedGroupSettings struct { RetryCount int BlacklistThreshold int AffinityEnabled bool + ParameterOverrides parameteroverride.Rules } func DefaultRuntimeSettings() RuntimeSettings { @@ -282,6 +285,12 @@ func ResolveGroupRuntimeSettings( return ResolvedGroupSettings{}, err } resolved.AffinityEnabled = parsed + case SettingParameterOverrides: + parsed, err := parameteroverride.Compile(value) + if err != nil { + return ResolvedGroupSettings{}, err + } + resolved.ParameterOverrides = parsed default: return ResolvedGroupSettings{}, fmt.Errorf("unknown group setting %q", key) } diff --git a/internal/state/snapshot.go b/internal/state/snapshot.go index bb27ec28..fcef8aee 100644 --- a/internal/state/snapshot.go +++ b/internal/state/snapshot.go @@ -14,6 +14,7 @@ import ( "gpt-load/internal/connection" "gpt-load/internal/execution" "gpt-load/internal/outboundproxy" + "gpt-load/internal/parameteroverride" "gpt-load/internal/platform/config" "gpt-load/internal/protocol" ) @@ -138,6 +139,7 @@ type GroupView struct { AffinityEnabled bool WeightManual *int Proxy outboundproxy.Effective + ParameterOverrides parameteroverride.Rules } type GroupCatalogView struct { @@ -230,6 +232,7 @@ func Compile(input CompileInput) (*ConfigSnapshot, error) { WeightManual: cloneWeight(group.WeightManual), ConnectionType: connection.Normalize(group.ConnectionType), Proxy: groupProxy, + ParameterOverrides: resolved.ParameterOverrides, } params, err := input.ChannelRegistry.ValidateParams(group.ChannelID, group.Params) if err != nil { diff --git a/web/src/api/control/types.ts b/web/src/api/control/types.ts index 7bb10b2b..4b3c2307 100644 --- a/web/src/api/control/types.ts +++ b/web/src/api/control/types.ts @@ -92,6 +92,20 @@ export interface HeaderRulesDto { remove: string[] } +export type ParameterJSONValue = + null | boolean | number | string | unknown[] | Record + +export interface ParameterOverrideMatchDto { + protocol?: AccessProtocol + model?: string +} + +export interface ParameterOverrideRuleDto { + match: ParameterOverrideMatchDto + set?: Record + remove?: string[] +} + export interface GroupRuntimeConfigDto { first_byte_timeout?: number request_timeout?: number @@ -101,6 +115,7 @@ export interface GroupRuntimeConfigDto { header_rules?: HeaderRulesDto inject_usage_options?: boolean affinity_enabled?: boolean + parameter_overrides?: ParameterOverrideRuleDto[] } export interface GroupEffectiveConfigDto { diff --git a/web/src/app/resources/groups.ts b/web/src/app/resources/groups.ts index 2385a3ac..659d9679 100644 --- a/web/src/app/resources/groups.ts +++ b/web/src/app/resources/groups.ts @@ -18,13 +18,18 @@ import type { GroupOptionDto, GroupSettingsDto, GroupSummaryDto, + ParameterJSONValue, + ParameterOverrideMatchDto, + ParameterOverrideRuleDto, ProxyConfigInput, ProxyMutation, } from '@/api/control/types' +import { enabledDataProtocols } from '@/api/control/protocols' import { InvalidResponseError } from '@/api/errors' import { controlQueryKeys, normalizeGroupCollectionFilters } from '@/app/query-keys' import { projectChannelID } from '@/app/resources/channels' import { projectModelCandidate, type ModelCandidate } from '@/app/resources/providers' +import { isJSONSafeNumber } from '@/lib/json-number' import { assertNoSecretLikeFields, @@ -32,6 +37,7 @@ import { projectBoolean, projectEpochMilliseconds, projectEnum, + projectFiniteNumber, projectRecord, projectSafeInteger, projectString, @@ -105,6 +111,7 @@ const runtimeSettingFields = [ 'inject_usage_options', 'affinity_enabled', ] as const +const groupRuntimeSettingFields = [...runtimeSettingFields, 'parameter_overrides'] as const export interface HeaderRulesDto { set: Record @@ -120,6 +127,7 @@ export interface GroupRuntimeConfigDto { header_rules?: HeaderRulesDto inject_usage_options?: boolean affinity_enabled?: boolean + parameter_overrides?: ParameterOverrideRuleDto[] } export interface GroupEffectiveConfigDto { @@ -277,6 +285,58 @@ function projectHeaderRules(value: unknown): HeaderRulesDto { } } +function projectParameterJSONValue(value: unknown, depth = 0): ParameterJSONValue { + if (depth > 64) throw new InvalidResponseError() + if (value === null || typeof value === 'boolean' || typeof value === 'string') return value + if (typeof value === 'number') { + const number = projectFiniteNumber(value) + if (!isJSONSafeNumber(number)) throw new InvalidResponseError() + return number + } + if (Array.isArray(value)) return value.map((item) => projectParameterJSONValue(item, depth + 1)) + const record = projectRecord(value) + return Object.fromEntries( + Object.entries(record).map(([key, nested]) => [ + key, + projectParameterJSONValue(nested, depth + 1), + ]), + ) +} + +function projectParameterOverrideMatch(value: unknown): ParameterOverrideMatchDto { + const record = projectRecord(value) + assertNoSecretLikeFields(record, ['protocol', 'model']) + const result: ParameterOverrideMatchDto = {} + if (Object.prototype.hasOwnProperty.call(record, 'protocol')) + result.protocol = projectEnum(record.protocol, enabledDataProtocols) + if (Object.prototype.hasOwnProperty.call(record, 'model')) + result.model = projectString(record.model, { allowEmpty: true }) + return result +} + +function projectParameterOverrideRule(value: unknown): ParameterOverrideRuleDto { + const record = projectRecord(value) + assertNoSecretLikeFields(record, ['match', 'set', 'remove']) + const result: ParameterOverrideRuleDto = { + match: Object.prototype.hasOwnProperty.call(record, 'match') + ? projectParameterOverrideMatch(record.match) + : {}, + } + if (Object.prototype.hasOwnProperty.call(record, 'set')) { + const set = projectRecord(record.set) + result.set = Object.fromEntries( + Object.entries(set).map(([key, nested]) => [key, projectParameterJSONValue(nested, 1)]), + ) + } + if (Object.prototype.hasOwnProperty.call(record, 'remove')) + result.remove = projectArray(record.remove, (path) => projectString(path, { allowEmpty: true })) + return result +} + +function projectParameterOverrides(value: unknown): ParameterOverrideRuleDto[] { + return projectArray(value, projectParameterOverrideRule) +} + function projectRuntimeConfig(value: unknown, complete: false): GroupRuntimeConfigDto function projectRuntimeConfig(value: unknown, complete: true): GroupEffectiveConfigDto function projectRuntimeConfig( @@ -284,7 +344,7 @@ function projectRuntimeConfig( complete: boolean, ): GroupRuntimeConfigDto | GroupEffectiveConfigDto { const record = projectRecord(value) - assertNoSecretLikeFields(record, runtimeSettingFields) + assertNoSecretLikeFields(record, complete ? runtimeSettingFields : groupRuntimeSettingFields) const result: GroupRuntimeConfigDto = {} for (const field of ['first_byte_timeout', 'request_timeout', 'stream_idle_timeout'] as const) { @@ -306,6 +366,9 @@ function projectRuntimeConfig( if (complete || Object.prototype.hasOwnProperty.call(record, 'affinity_enabled')) { result.affinity_enabled = projectBoolean(record.affinity_enabled) } + if (!complete && Object.prototype.hasOwnProperty.call(record, 'parameter_overrides')) { + result.parameter_overrides = projectParameterOverrides(record.parameter_overrides) + } return result as GroupRuntimeConfigDto | GroupEffectiveConfigDto } diff --git a/web/src/components/config/ParameterOverrideRulesEditor.vue b/web/src/components/config/ParameterOverrideRulesEditor.vue new file mode 100644 index 00000000..b40f4fd5 --- /dev/null +++ b/web/src/components/config/ParameterOverrideRulesEditor.vue @@ -0,0 +1,1321 @@ + + + + + diff --git a/web/src/features/groups/group-route.ts b/web/src/features/groups/group-route.ts index f8db35fe..5569934c 100644 --- a/web/src/features/groups/group-route.ts +++ b/web/src/features/groups/group-route.ts @@ -12,7 +12,8 @@ import { } from '@/app/route-query' export type GroupTab = 'credentials' | 'models' | 'settings' -export type GroupSettingsSection = 'general' | 'routing' | 'runtime' | 'headers' | 'danger' +export type GroupSettingsSection = + 'general' | 'routing' | 'runtime' | 'parameters' | 'headers' | 'danger' export type GroupModelDiscoveryFilter = 'unadded' | 'all' export interface CredentialRouteState { @@ -43,6 +44,7 @@ const settingsSections = new Set([ 'general', 'routing', 'runtime', + 'parameters', 'headers', 'danger', ]) diff --git a/web/src/features/groups/settings/GroupSettingsTab.vue b/web/src/features/groups/settings/GroupSettingsTab.vue index f70b4e1f..f80d6d72 100644 --- a/web/src/features/groups/settings/GroupSettingsTab.vue +++ b/web/src/features/groups/settings/GroupSettingsTab.vue @@ -5,7 +5,13 @@ import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' -import type { GroupSettingsDto, HeaderRulesDto, ProxyConfiguredMode } from '@/api/control/types' +import type { + AccessProtocol, + GroupSettingsDto, + HeaderRulesDto, + ParameterOverrideRuleDto, + ProxyConfiguredMode, +} from '@/api/control/types' import { RequestCancelledError } from '@/api/errors' import { useApiClient } from '@/api/client-context' @@ -14,6 +20,7 @@ import { proxyDraftState, proxyOverrideToggleMode } from '@/app/resources/proxy' import { channelsQueryOptions, type ChannelFieldDto } from '@/app/resources/channels' import { cacheGroupSettings, + groupModelsQueryOptions, groupSettingsQueryOptions, invalidateGroupSettingsDependents, updateGroupSettings, @@ -22,6 +29,7 @@ import { useUnsavedChanges } from '@/app/unsaved-changes' import { useTransientFlag } from '@/app/use-transient-flag' import { groupDetailLocation } from '@/app/route-locations' import HeaderRulesEditor from '@/components/config/HeaderRulesEditor.vue' +import ParameterOverrideRulesEditor from '@/components/config/ParameterOverrideRulesEditor.vue' import ProxyOverrideControl from '@/components/config/ProxyOverrideControl.vue' import RuntimeOverrideRow from '@/components/config/RuntimeOverrideRow.vue' import AppButton from '@/components/ui/AppButton.vue' @@ -68,6 +76,7 @@ const router = useRouter() const { t } = useI18n() const routeState = computed(() => parseGroupSettingsRouteQuery(route.query)) const query = useQuery(groupSettingsQueryOptions(client, () => props.groupId)) +const modelsQuery = useQuery(groupModelsQueryOptions(client, () => props.groupId)) const channelsQuery = useQuery(channelsQueryOptions(client, '')) const initialLoading = useStableLoading( () => query.isPending.value && query.data.value === undefined, @@ -82,6 +91,9 @@ const error = ref('') const headerRulesValid = ref(true) const headerRulesInvalidEdits = ref(false) const headerRulesEditorRevision = ref(0) +const parameterOverridesValid = ref(true) +const parameterOverridesInvalidEdits = ref(false) +const parameterOverridesEditorRevision = ref(0) const proxyMode = ref('inherit') const proxyEndpoint = ref('') const { @@ -111,11 +123,25 @@ const channelParamFields = computed(() => : (selectedChannel.value?.param_fields ?? []), ) const channelParamsDisabled = computed(() => selectedChannel.value === undefined) +const parameterOverrideOperations = new Set([ + 'chat_completion', + 'responses_create', + 'images_generate', + 'embeddings_create', +]) +const parameterOverrideProtocols = computed(() => [ + ...new Set( + (selectedChannel.value?.routes ?? []) + .filter(({ operation }) => parameterOverrideOperations.has(operation)) + .map(({ client_protocol }) => client_protocol), + ), +]) let controller: AbortController | undefined const navItems = computed(() => [ { id: 'settings-general', label: t('group.settings.sections.general') }, { id: 'settings-routing', label: t('group.settings.sections.routing') }, { id: 'settings-runtime', label: t('group.settings.sections.runtime') }, + { id: 'settings-parameters', label: t('group.settings.sections.parameters') }, { id: 'settings-headers', label: t('group.settings.sections.headers') }, { id: 'settings-danger', label: t('group.settings.sections.danger') }, ]) @@ -151,6 +177,7 @@ const dirty = computed( !deleted.value && (Object.keys(patch.value).length > 0 || headerRulesInvalidEdits.value || + parameterOverridesInvalidEdits.value || proxyState.value.dirty), ) const mutationPending = computed(() => pending.value || deletePending.value) @@ -195,6 +222,7 @@ const valid = computed( timeoutValid.value && policyCountsValid.value && headerRulesValid.value && + parameterOverridesValid.value && !proxyState.value.invalid, ) const showInjectUsage = computed( @@ -232,6 +260,9 @@ function resetSavedDraft(settings: GroupSettingsDto): void { headerRulesValid.value = true headerRulesInvalidEdits.value = false headerRulesEditorRevision.value += 1 + parameterOverridesValid.value = true + parameterOverridesInvalidEdits.value = false + parameterOverridesEditorRevision.value += 1 proxyMode.value = settings.proxy.configured_mode proxyEndpoint.value = '' } @@ -288,6 +319,7 @@ function sectionFromID(id: string): GroupSettingsSection | undefined { return value === 'general' || value === 'routing' || value === 'runtime' || + value === 'parameters' || value === 'headers' || value === 'danger' ? value @@ -361,6 +393,14 @@ function updateHeaderRules(value: HeaderRulesDto): void { } } +function updateParameterOverrides(value: ParameterOverrideRuleDto[]): void { + if (!draft.value) return + const overrides = { ...draft.value.overrides } + if (value.length === 0) delete overrides.parameter_overrides + else overrides.parameter_overrides = value + draft.value = { ...draft.value, overrides } +} + function setInjectUsageOverride(enabled: boolean): void { if (!draft.value || !saved.value) return const overrides = { ...draft.value.overrides } @@ -734,6 +774,22 @@ onBeforeUnmount(() => { +
+
+

{{ t('group.settings.sections.parameters') }}

+

{{ t('group.settings.parameterOverrides.description') }}

+
+ +

{{ t('group.settings.sections.headers') }}

diff --git a/web/src/features/groups/settings/group-settings-patch.ts b/web/src/features/groups/settings/group-settings-patch.ts index becc1bba..8a38d2ba 100644 --- a/web/src/features/groups/settings/group-settings-patch.ts +++ b/web/src/features/groups/settings/group-settings-patch.ts @@ -1,4 +1,9 @@ -import type { ChannelParamsDto, GroupSettingsDto } from '@/api/control/types' +import type { + ChannelParamsDto, + GroupSettingsDto, + ParameterJSONValue, + ParameterOverrideRuleDto, +} from '@/api/control/types' import type { GroupRuntimeConfigDto, GroupSettingsUpdateRequest, @@ -33,6 +38,37 @@ function cloneHeaders(value: HeaderRulesDto): HeaderRulesDto { return { set: { ...value.set }, remove: [...value.remove] } } +function cloneParameterValue(value: unknown): ParameterJSONValue { + if (Array.isArray(value)) return value.map(cloneParameterValue) + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => [key, cloneParameterValue(nested)]), + ) + } + if ( + value === null || + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string' + ) + return value + throw new TypeError('invalid JSON value') +} + +function cloneParameterOverrides(value: ParameterOverrideRuleDto[]): ParameterOverrideRuleDto[] { + return value.map((rule) => ({ + match: { ...rule.match }, + ...(rule.set + ? { + set: Object.fromEntries( + Object.entries(rule.set).map(([key, nested]) => [key, cloneParameterValue(nested)]), + ), + } + : {}), + ...(rule.remove ? { remove: [...rule.remove] } : {}), + })) +} + function cloneOverrides(value: GroupRuntimeConfigDto): GroupRuntimeConfigDto { const next: GroupRuntimeConfigDto = {} for (const key of groupTimeoutKeys) if (value[key] !== undefined) next[key] = value[key] @@ -41,6 +77,8 @@ function cloneOverrides(value: GroupRuntimeConfigDto): GroupRuntimeConfigDto { if (value.inject_usage_options !== undefined) next.inject_usage_options = value.inject_usage_options if (value.affinity_enabled !== undefined) next.affinity_enabled = value.affinity_enabled + if (value.parameter_overrides?.length) + next.parameter_overrides = cloneParameterOverrides(value.parameter_overrides) return next } @@ -56,9 +94,50 @@ function normalizeHeaders(value: HeaderRulesDto): HeaderRulesDto { function normalizeOverrides(value: GroupRuntimeConfigDto): GroupRuntimeConfigDto { const next = cloneOverrides(value) if (next.header_rules) next.header_rules = normalizeHeaders(next.header_rules) + if (next.parameter_overrides) { + const rules = next.parameter_overrides.map((rule) => { + const model = rule.match.model?.trim() + const match = { + ...(rule.match.protocol ? { protocol: rule.match.protocol } : {}), + ...(model ? { model } : {}), + } + const set = rule.set ? normalizeParameterObject(rule.set) : undefined + return { + match, + ...(set && Object.keys(set).length > 0 ? { set } : {}), + ...(rule.remove && rule.remove.length > 0 ? { remove: [...rule.remove] } : {}), + } + }) + if (rules.length > 0) next.parameter_overrides = rules + else delete next.parameter_overrides + } return next } +function normalizeParameterObject( + value: Record, +): Record { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => [key, normalizeParameterValue(nested)]), + ) +} + +function normalizeParameterValue(value: unknown): ParameterJSONValue { + if (Array.isArray(value)) return value.map(normalizeParameterValue) + if (value !== null && typeof value === 'object') + return normalizeParameterObject(value as Record) + if ( + value === null || + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string' + ) + return value + throw new TypeError('invalid JSON value') +} + export function createGroupSettingsDraft(group: GroupSettingsDto): GroupSettingsDraft { return { ...group, params: { ...group.params }, overrides: cloneOverrides(group.overrides) } } diff --git a/web/src/i18n/locales/en-US/group.ts b/web/src/i18n/locales/en-US/group.ts index dd487cc6..afb64fa4 100644 --- a/web/src/i18n/locales/en-US/group.ts +++ b/web/src/i18n/locales/en-US/group.ts @@ -201,6 +201,7 @@ export default { general: 'General', routing: 'Scheduling', runtime: 'Runtime overrides', + parameters: 'Parameter overrides', headers: 'HeaderRules', danger: 'Danger zone', }, @@ -212,6 +213,68 @@ export default { description: 'Rules are collapsed by default; editing replaces the complete HeaderRules object', }, + parameterOverrides: { + description: + 'Rewrite parameters on inference requests only. Values are stored as plaintext; do not enter credentials.', + order: 'On the same field, the lower rule wins', + addRule: 'Add rule', + empty: 'No rules; requests pass through unchanged', + moved: 'Rule moved to position {position}', + moveUp: 'Move rule up', + moveDown: 'Move rule down', + copy: 'Copy rule', + deleteRule: 'Delete rule', + match: 'Match', + protocol: 'Protocol', + model: 'Model', + allProtocols: 'All protocols', + allModels: 'All models', + modelMatch: 'Matches {count} configured models', + modelNoMatch: 'No model matches', + params: 'Params', + paramAction: 'Action', + paramPath: 'Parameter path', + paramValue: 'Value', + paramKind: 'Type', + kind: { + text: 'Text', + number: 'Number', + boolean: 'Boolean', + null: 'Null', + json: 'JSON', + }, + opSet: 'Set', + opRemove: 'Remove', + addParam: 'Add parameter', + addRemovePath: 'Add removal path', + deleteParam: 'Delete this row', + setLabel: 'Set parameters', + toJSON: 'To JSON', + toRows: 'To rows', + formatJSON: 'Format', + pathsCross: 'Paths intersect; removals run before sets', + pathHint: 'Separate levels with /; write / in a key as ~1 and ~ as ~0', + mergeHint: 'Objects merge recursively; arrays and scalars replace. Variables stay literal', + errors: { + modelPattern: 'Use an exact model or one trailing * prefix wildcard.', + pathRequired: 'Enter a parameter path.', + pathInvalid: + 'Separate non-empty levels with /. Array indexes are not supported, and ~ is only valid in ~0 or ~1.', + pathDuplicate: 'Paths must be unique.', + pathAncestor: + 'A path cannot be the parent or child of another set path in the same rule.', + forbiddenField: 'Root fields model, stream, and store cannot be set or removed.', + valueRequired: 'Enter a value.', + valueNumber: 'Not a valid number. Switch the type to Text to store it as text.', + valueBoolean: 'A boolean must be true or false.', + valueJSON: 'Invalid JSON. Provide a complete structure starting with { or [.', + emptyKey: 'Parameter object field names cannot be empty.', + unsafeNumber: 'Numbers must round-trip safely and losslessly through the management UI.', + setObject: 'Set parameters must be a JSON object.', + invalidJSON: 'The JSON format is invalid.', + actionRequired: 'Add at least one parameter.', + }, + }, dangerDescription: 'Keep irreversible actions clearly separated from routine settings.', saveFailed: 'Unable to update the Group settings.', base: { diff --git a/web/src/i18n/locales/ja-JP/group.ts b/web/src/i18n/locales/ja-JP/group.ts index 72e8969f..1a494791 100644 --- a/web/src/i18n/locales/ja-JP/group.ts +++ b/web/src/i18n/locales/ja-JP/group.ts @@ -201,6 +201,7 @@ export default { general: '基本情報', routing: 'スケジューリング', runtime: 'ランタイム上書き', + parameters: 'パラメーター上書き', headers: 'HeaderRules', danger: '危険な操作', }, @@ -211,6 +212,68 @@ export default { headers: { description: 'ルールは既定で折りたたまれ、編集すると HeaderRules 全体を置き換えます', }, + parameterOverrides: { + description: + '推論リクエストのパラメータのみを書き換えます。値は平文で保存されるため、認証情報は入力しないでください。', + order: '同じフィールドが競合した場合は下のルールが優先', + addRule: 'ルールを追加', + empty: 'ルールなし。リクエストはそのまま転送されます', + moved: 'ルールを {position} 番目に移動しました', + moveUp: 'ルールを上へ', + moveDown: 'ルールを下へ', + copy: 'ルールを複製', + deleteRule: 'ルールを削除', + match: '条件', + protocol: 'プロトコル', + model: 'モデル', + allProtocols: 'すべてのプロトコル', + allModels: 'すべてのモデル', + modelMatch: '設定済みモデル {count} 件に一致', + modelNoMatch: '一致するモデルなし', + params: 'パラメータ', + paramAction: '操作', + paramPath: 'パラメータパス', + paramValue: '値', + paramKind: '型', + kind: { + text: 'テキスト', + number: '数値', + boolean: '真偽値', + null: 'Null', + json: 'JSON', + }, + opSet: '設定', + opRemove: '削除', + addParam: 'パラメータを追加', + addRemovePath: '削除パスを追加', + deleteParam: 'この行を削除', + setLabel: 'パラメータを設定', + toJSON: 'JSON へ', + toRows: '行へ', + formatJSON: '整形', + pathsCross: 'パスが交差しています。削除が設定より先に実行されます', + pathHint: '階層は / で区切ります。キー内の / は ~1、~ は ~0 と書きます', + mergeHint: 'オブジェクトは再帰的にマージ、配列とスカラーは置換。変数は展開されません', + errors: { + modelPattern: 'モデルは完全一致、または末尾に * を 1 つ付けた前方一致のみです。', + pathRequired: 'パラメータパスを入力してください。', + pathInvalid: + '空でない階層を / で区切ります。配列インデックスは使用できず、~ は ~0 または ~1 のみ有効です。', + pathDuplicate: 'パスは重複できません。', + pathAncestor: '同一ルール内の別の設定パスと親子関係にすることはできません。', + forbiddenField: 'ルートフィールド model、stream、store は設定も削除もできません。', + valueRequired: '値を入力してください。', + valueNumber: + '有効な数値ではありません。テキストとして保存する場合は型をテキストに変更してください。', + valueBoolean: '真偽値は true または false のみです。', + valueJSON: 'JSON の形式が正しくありません。{ または [ で始まる完全な構造が必要です。', + emptyKey: 'パラメータオブジェクトのフィールド名を空にはできません。', + unsafeNumber: '数値は管理画面を通じて安全かつ無損失に保存できる必要があります。', + setObject: '設定パラメータは JSON オブジェクトである必要があります。', + invalidJSON: 'JSON の形式が正しくありません。', + actionRequired: 'パラメータを 1 つ以上追加してください。', + }, + }, dangerDescription: '元に戻せない操作を通常の設定から明確に分離します。', saveFailed: 'グループ設定を更新できませんでした。', base: { diff --git a/web/src/i18n/locales/zh-CN/group.ts b/web/src/i18n/locales/zh-CN/group.ts index 013acb11..44eb3bd5 100644 --- a/web/src/i18n/locales/zh-CN/group.ts +++ b/web/src/i18n/locales/zh-CN/group.ts @@ -199,6 +199,7 @@ export default { general: '基本信息', routing: '调度', runtime: '运行时覆盖', + parameters: '参数覆盖', headers: 'HeaderRules', danger: '危险区域', }, @@ -207,6 +208,65 @@ export default { weightHelp: '自动模式由运行时调度决定;手动模式范围为 1–100。', }, headers: { description: '规则默认折叠;展开后编辑将完整替换全局 HeaderRules。' }, + parameterOverrides: { + description: '只改写推理请求的参数;明文保存,请勿填写凭据。', + order: '同一字段冲突时,靠下的规则赢', + addRule: '添加规则', + empty: '暂无规则,请求原样转发', + moved: '规则已移动到第 {position} 位', + moveUp: '上移规则', + moveDown: '下移规则', + copy: '复制规则', + deleteRule: '删除规则', + match: '匹配', + protocol: '协议', + model: '模型', + allProtocols: '全部协议', + allModels: '全部模型', + modelMatch: '匹配 {count} 个已配置模型', + modelNoMatch: '无匹配模型', + params: '参数', + paramAction: '动作', + paramPath: '参数路径', + paramValue: '值', + paramKind: '类型', + kind: { + text: '文本', + number: '数字', + boolean: '布尔', + null: '空值', + json: 'JSON', + }, + opSet: '设置', + opRemove: '删除', + addParam: '添加参数', + addRemovePath: '添加删除路径', + deleteParam: '移除这一行', + setLabel: '设置参数', + toJSON: '转 JSON', + toRows: '转快捷行', + formatJSON: '格式化', + pathsCross: '路径相交,删除先于设置执行', + pathHint: '用 / 分层;键名里的 / 写作 ~1、~ 写作 ~0', + mergeHint: '对象递归合并,数组和标量整体替换;变量不展开', + errors: { + modelPattern: '模型只支持精确值或末尾一个 * 的前缀匹配。', + pathRequired: '请输入参数路径。', + pathInvalid: '路径用 / 分层,各层不能为空且不支持数组下标;~ 只能写作 ~0 或 ~1。', + pathDuplicate: '路径不能重复。', + pathAncestor: '路径不能与同一规则的另一条设置路径互为父子。', + forbiddenField: '不能设置或删除根字段 model、stream 或 store。', + valueRequired: '请输入值。', + valueNumber: '不是有效数字;如果要按文本保存,请把类型改成文本。', + valueBoolean: '布尔值只能是 true 或 false。', + valueJSON: 'JSON 格式不正确,需要以 { 或 [ 开头的完整结构。', + emptyKey: '参数对象的字段名不能为空。', + unsafeNumber: '数字必须可安全、无损地通过管理界面保存。', + setObject: '设置参数必须是 JSON 对象。', + invalidJSON: 'JSON 格式不正确。', + actionRequired: '至少添加一个参数。', + }, + }, dangerDescription: '不可逆操作与常规设置保持明确距离。', saveFailed: '无法更新分组设置。', base: { diff --git a/web/src/lib/json-number.ts b/web/src/lib/json-number.ts new file mode 100644 index 00000000..c7832760 --- /dev/null +++ b/web/src/lib/json-number.ts @@ -0,0 +1,60 @@ +export class JSONNumberPrecisionError extends Error {} + +export function isJSONSafeNumber(value: number): boolean { + return ( + Number.isFinite(value) && + !Object.is(value, -0) && + (!Number.isInteger(value) || Number.isSafeInteger(value)) + ) +} + +export function assertJSONNumbersRoundTrip(source: string): void { + let index = 0 + while (index < source.length) { + if (source[index] === '"') { + index += 1 + while (index < source.length) { + if (source[index] === '\\') index += 2 + else if (source[index] === '"') { + index += 1 + break + } else index += 1 + } + continue + } + const character = source[index] + if (character !== '-' && (character === undefined || character < '0' || character > '9')) { + index += 1 + continue + } + let end = index + 1 + while (end < source.length && '0123456789eE+-.'.includes(source[end] ?? '')) end += 1 + if (!numberRoundTrips(source.slice(index, end))) throw new JSONNumberPrecisionError() + index = end + } +} + +function numberRoundTrips(literal: string): boolean { + const value = Number(literal) + if (!isJSONSafeNumber(value)) return false + const serialized = JSON.stringify(value) + return ( + typeof serialized === 'string' && normalizeDecimal(literal) === normalizeDecimal(serialized) + ) +} + +function normalizeDecimal(literal: string): string { + const match = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/u.exec(literal) + if (!match) throw new JSONNumberPrecisionError() + const fraction = match[3] ?? '' + const digits = `${match[2] ?? ''}${fraction}`.replace(/^0+/u, '') || '0' + if (digits === '0') return '0' + const parsedExponent = Number(match[4] ?? '0') + if (!Number.isSafeInteger(parsedExponent)) throw new JSONNumberPrecisionError() + const trailingZeroCount = /0+$/u.exec(digits)?.[0].length ?? 0 + const coefficientDigits = trailingZeroCount === 0 ? digits : digits.slice(0, -trailingZeroCount) + const exponent = parsedExponent - fraction.length + trailingZeroCount + let coefficient = BigInt(coefficientDigits) + if (match[1] === '-') coefficient = -coefficient + return `${coefficient}e${exponent}` +} diff --git a/web/src/lib/parameter-paths.ts b/web/src/lib/parameter-paths.ts new file mode 100644 index 00000000..5c314ebb --- /dev/null +++ b/web/src/lib/parameter-paths.ts @@ -0,0 +1,120 @@ +import type { ParameterJSONValue } from '@/api/control/types' + +/** + * 参数覆盖的路径写法:设置与删除共用一套 `a/b` 分层文本,段内的 `~` 与 `/` 按 + * JSON Pointer 规则转义成 `~0` 与 `~1`。后端 remove 存的是带前导斜杠的 Pointer, + * set 存的是嵌套对象;界面统一省略前导斜杠,两者只差这一个字符。 + * + * 用 `/` 而不是 `.` 分层,是因为转义规则让它无歧义:`{"a.b":1}` 展平成 `a.b`、 + * `{"a":{"b":1}}` 成 `a/b`、`{"a/b":1}` 成 `a~1b`,三者互不相撞。 + */ + +/** 快捷行的一行:路径 + 该路径上的 JSON 值。 */ +export interface ParameterPathEntry { + path: string + value: ParameterJSONValue +} + +class ParameterPathError extends Error {} + +// 转义顺序不可交换:先 ~ 再 /,否则 ~1 里的 ~ 会被二次转义。 +function escapeSegment(segment: string): string { + return segment.replaceAll('~', '~0').replaceAll('/', '~1') +} + +// 解码顺序同样固定:先 ~1 再 ~0,与 JSON Pointer 规范一致。 +function unescapeSegment(raw: string): string | undefined { + if (/~(?![01])/u.test(raw)) return undefined + return raw.replaceAll('~1', '/').replaceAll('~0', '~') +} + +function isPlainObject(value: ParameterJSONValue): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function createParameterObject(): Record { + return Object.create(null) as Record +} + +// 展平只递归非空对象:空对象没有叶子,必须作为值整体保留,否则整条会消失。 +function isNestedObject(value: ParameterJSONValue): value is Record { + return isPlainObject(value) && Object.keys(value).length > 0 +} + +function encodeParameterPath(segments: readonly string[]): string { + return segments.map(escapeSegment).join('/') +} + +/** 解析分层路径;remove 不支持数组下标与 `-`,set 将它们视为普通对象键。 */ +export function decodeParameterPath( + path: string, + operation: 'set' | 'remove', +): string[] | undefined { + if (!path) return undefined + const segments: string[] = [] + for (const raw of path.split('/')) { + const decoded = unescapeSegment(raw) + if ( + decoded === undefined || + decoded === '' || + (operation === 'remove' && (decoded === '-' || /^\d+$/u.test(decoded))) + ) + return undefined + segments.push(decoded) + } + return segments +} + +/** + * 两条路径是否相交:完全相同,或一方是另一方的祖先。 + * 段内的 `/` 已转义为 `~1`,所以 `/` 只出现在段边界,前缀比较是安全的。 + */ +export function parameterPathsCross(left: string, right: string): boolean { + return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`) +} + +export function flattenParameterSet(set: Record): ParameterPathEntry[] { + const entries: ParameterPathEntry[] = [] + const walk = (node: Record, prefix: readonly string[]): void => { + for (const [key, value] of Object.entries(node)) { + const segments = [...prefix, key] + if (isNestedObject(value)) walk(value, segments) + else entries.push({ path: encodeParameterPath(segments), value }) + } + } + walk(set, []) + return entries +} + +/** + * 还原展平结果。祖先与后代同时出现属于矛盾配置,调用方应先用 + * parameterPathsCross 拦下来;这里遇到就直接抛错,不做静默取舍。 + */ +export function expandParameterSet( + entries: readonly ParameterPathEntry[], +): Record { + const result = createParameterObject() + for (const { path, value } of entries) { + const segments = decodeParameterPath(path, 'set') + const leaf = segments?.at(-1) + if (!segments || leaf === undefined) throw new ParameterPathError(path) + let node = result + for (const segment of segments.slice(0, -1)) { + const existing: ParameterJSONValue | undefined = node[segment] + if (existing === undefined) node[segment] = createParameterObject() + else if (!isPlainObject(existing)) throw new ParameterPathError(path) + node = node[segment] as Record + } + node[leaf] = value + } + return result +} + +/** remove 在后端存 JSON Pointer;界面省略前导斜杠。 */ +export function toParameterPointer(path: string): string { + return `/${path}` +} + +export function fromParameterPointer(pointer: string): string { + return pointer.startsWith('/') ? pointer.slice(1) : pointer +} diff --git a/web/src/lib/parameter-values.ts b/web/src/lib/parameter-values.ts new file mode 100644 index 00000000..5e7aa5e9 --- /dev/null +++ b/web/src/lib/parameter-values.ts @@ -0,0 +1,141 @@ +import type { ParameterJSONValue } from '@/api/control/types' +import { assertJSONNumbersRoundTrip } from '@/lib/json-number' + +/** + * 参数值的输入表示:类型是显式的一列,值不需要用户加引号。 + * 类型默认跟随输入推断(`0.7` → 数字、`enabled` → 文本、`true` → 布尔), + * 用户改过之后就以他选的为准 —— 想把 `7` 当文本存,改类型而不是打引号。 + * + * 引号只剩一个用途:保住往返。文本 `"7"` 若裸着显示成 7,读出再存回就变成数字, + * 所以这类值显示时补引号,由 formatValueText 与 textValue 成对处理,用户不必知道。 + */ + +export type ParameterValueKind = 'text' | 'number' | 'boolean' | 'null' | 'json' + +export const parameterValueKinds: readonly ParameterValueKind[] = [ + 'text', + 'number', + 'boolean', + 'null', + 'json', +] + +export class ParameterValueError extends Error {} + +/** JSON 数字字面量。刻意严格:`007` 与 `.5` 不算数字,推断成文本后由类型列暴露出来。 */ +const jsonNumberPattern = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/u + +function isJSONNumberLiteral(text: string): boolean { + return jsonNumberPattern.test(text) +} + +/** 参数对象的字段名不能为空;快捷路径无法把空字段与“尚未输入”区分开。 */ +export function hasEmptyParameterKey(value: unknown): boolean { + if (Array.isArray(value)) return value.some(hasEmptyParameterKey) + if (value === null || typeof value !== 'object') return false + return Object.entries(value).some(([key, nested]) => key === '' || hasEmptyParameterKey(nested)) +} + +/** 裸文本会被重新推断成别的类型时,才需要补引号。 */ +function needsQuoting(value: string): boolean { + return ( + value === '' || + value.trim() !== value || + /[\u0000-\u001f]/u.test(value) || + value === 'true' || + value === 'false' || + value === 'null' || + isJSONNumberLiteral(value) || + value.startsWith('{') || + value.startsWith('[') || + value.startsWith('"') + ) +} + +export function formatValueText(value: ParameterJSONValue): string { + if (typeof value === 'string') return needsQuoting(value) ? JSON.stringify(value) : value + return JSON.stringify(value) ?? 'null' +} + +/** 从已有 JSON 值反推类型,用于载入时填充类型列。 */ +export function valueKind(value: ParameterJSONValue): ParameterValueKind { + if (value === null) return 'null' + if (typeof value === 'object') return 'json' + switch (typeof value) { + case 'number': + return 'number' + case 'boolean': + return 'boolean' + default: + return 'text' + } +} + +/** 从输入文本推断类型;用户没有手动指定时跟随它。 */ +export function inferValueKind(text: string): ParameterValueKind { + const trimmed = text.trim() + if (!trimmed) return 'text' + if (trimmed.startsWith('{') || trimmed.startsWith('[')) return 'json' + if (trimmed.startsWith('"')) return 'text' + if (isJSONNumberLiteral(trimmed)) return 'number' + if (trimmed === 'true' || trimmed === 'false') return 'boolean' + if (trimmed === 'null') return 'null' + return 'text' +} + +// 带引号的输入还原成文本本身,引号不完整就按字面处理。 +function textValue(text: string): string { + const trimmed = text.trim() + if (trimmed.startsWith('"')) { + try { + const parsed: unknown = JSON.parse(trimmed) + if (typeof parsed === 'string') return parsed + } catch { + // 落到字面文本 + } + } + return text +} + +/** 按类型列指定的类型解释输入;类型对不上就是错误,不做静默兜底。 */ +export function valueFromText(text: string, kind: ParameterValueKind): ParameterJSONValue { + if (kind === 'null') return null + const trimmed = text.trim() + if (kind === 'text') return textValue(text) + if (!trimmed) throw new ParameterValueError('empty') + + if (kind === 'number') { + if (!isJSONNumberLiteral(trimmed)) throw new ParameterValueError('number') + assertJSONNumbersRoundTrip(trimmed) + return JSON.parse(trimmed) as number + } + if (kind === 'boolean') { + if (trimmed === 'true') return true + if (trimmed === 'false') return false + throw new ParameterValueError('boolean') + } + + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { + throw new ParameterValueError('json') + } + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } catch { + throw new ParameterValueError('json') + } + if (hasEmptyParameterKey(parsed)) throw new ParameterValueError('empty-key') + assertJSONNumbersRoundTrip(trimmed) + return parsed as ParameterJSONValue +} + +export function tryValueFromText( + text: string, + kind: ParameterValueKind, +): ParameterJSONValue | undefined { + try { + return valueFromText(text, kind) + } catch { + return undefined + } +}