From 65621957cf2ad1a767e33738a1b659112b97b814 Mon Sep 17 00:00:00 2001 From: miaoheng <2020745908@qq.com> Date: Fri, 28 Aug 2026 15:18:24 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E7=BA=A7=E6=B8=A9=E5=BA=A6=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为账号凭据增加 inherit、override、omit 三态温度策略,并在 OpenAI、Anthropic、Gemini 与 Antigravity 网关入口统一应用。保留模型能力清洗优先级,补充管理端校验、映射场景和协议回归测试。 --- .../service/account_temperature_policy.go | 189 ++++++++++++++++ .../account_temperature_policy_test.go | 124 +++++++++++ backend/internal/service/admin_account.go | 23 ++ .../service/admin_service_bulk_update_test.go | 37 ++++ .../admin_service_credentials_merge_test.go | 24 ++ .../service/antigravity_gateway_claude.go | 5 + .../service/antigravity_gateway_compat.go | 10 + .../antigravity_gateway_compat_test.go | 6 +- .../service/antigravity_gateway_gemini.go | 5 + .../antigravity_gateway_service_test.go | 6 +- .../service/gateway_anthropic_passthrough.go | 5 + backend/internal/service/gateway_forward.go | 8 + .../gateway_forward_as_chat_completions.go | 6 + .../service/gateway_forward_as_responses.go | 6 + .../gateway_temperature_policy_test.go | 206 ++++++++++++++++++ .../service/gateway_upstream_request.go | 5 + .../gemini_chat_completions_compat_service.go | 5 + .../service/gemini_messages_compat_service.go | 12 + .../gemini_messages_compat_service_test.go | 37 ++++ .../openai_gateway_chat_completions.go | 17 ++ .../service/openai_gateway_forward.go | 9 + .../service/openai_gateway_messages.go | 10 + .../service/openai_gateway_request_body.go | 2 +- .../openai_passthrough_normalization_test.go | 13 +- 24 files changed, 765 insertions(+), 5 deletions(-) create mode 100644 backend/internal/service/account_temperature_policy.go create mode 100644 backend/internal/service/account_temperature_policy_test.go create mode 100644 backend/internal/service/gateway_temperature_policy_test.go diff --git a/backend/internal/service/account_temperature_policy.go b/backend/internal/service/account_temperature_policy.go new file mode 100644 index 000000000000..73f579f53dd4 --- /dev/null +++ b/backend/internal/service/account_temperature_policy.go @@ -0,0 +1,189 @@ +package service + +import ( + "context" + "encoding/json" + "math" + "strings" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +type accountTemperatureMode string +type temperaturePath string + +const ( + accountTemperatureModeInherit accountTemperatureMode = "inherit" + accountTemperatureModeOverride accountTemperatureMode = "override" + accountTemperatureModeOmit accountTemperatureMode = "omit" + + temperaturePathTopLevel temperaturePath = "temperature" + temperaturePathGemini temperaturePath = "generationConfig.temperature" +) + +type accountTemperaturePolicy struct { + mode accountTemperatureMode + temperature float64 +} + +func validateAccountTemperatureCredentials(credentials map[string]any) error { + _, err := parseAccountTemperaturePolicy(credentials) + return err +} + +func hasAccountTemperatureCredentialUpdate(credentials map[string]any) bool { + if credentials == nil { + return false + } + _, hasMode := credentials["temperature_mode"] + _, hasValue := credentials["temperature"] + return hasMode || hasValue +} + +func parseAccountTemperaturePolicy(credentials map[string]any) (accountTemperaturePolicy, error) { + policy := accountTemperaturePolicy{mode: accountTemperatureModeInherit} + if credentials == nil { + return policy, nil + } + + if rawMode, ok := credentials["temperature_mode"]; ok { + mode, valid := rawMode.(string) + if !valid { + return policy, invalidAccountTemperature("temperature_mode must be inherit, override, or omit") + } + policy.mode = accountTemperatureMode(strings.TrimSpace(mode)) + } + + switch policy.mode { + case accountTemperatureModeInherit, accountTemperatureModeOverride, accountTemperatureModeOmit: + default: + return policy, invalidAccountTemperature("temperature_mode must be inherit, override, or omit") + } + + rawTemperature, hasTemperature := credentials["temperature"] + if !hasTemperature || rawTemperature == nil { + if policy.mode == accountTemperatureModeOverride { + return policy, invalidAccountTemperature("temperature is required when temperature_mode is override") + } + return policy, nil + } + + temperature, valid := accountTemperatureNumber(rawTemperature) + if !valid { + return policy, invalidAccountTemperature("temperature must be a finite number") + } + policy.temperature = temperature + return policy, nil +} + +func applyAccountTemperaturePolicy(body []byte, account *Account, path temperaturePath) ([]byte, error) { + if !gjson.ValidBytes(body) { + return nil, infraerrors.BadRequest("INVALID_REQUEST_BODY", "request body must be valid JSON") + } + + var credentials map[string]any + if account != nil { + credentials = account.Credentials + } + policy, err := parseAccountTemperaturePolicy(credentials) + if err != nil { + return nil, err + } + + field := gjson.GetBytes(body, string(path)) + if field.Exists() && field.Type != gjson.Number && field.Type != gjson.Null { + return nil, infraerrors.BadRequest("INVALID_TEMPERATURE", "temperature must be a number or null") + } + if field.Type == gjson.Number { + value := field.Float() + if math.IsNaN(value) || math.IsInf(value, 0) { + return nil, infraerrors.BadRequest("INVALID_TEMPERATURE", "temperature must be a finite number") + } + } + + normalized := body + if field.Exists() && field.Type == gjson.Null { + normalized, err = sjson.DeleteBytes(normalized, string(path)) + if err != nil { + return nil, infraerrors.BadRequest("INVALID_TEMPERATURE", "temperature could not be removed from request body") + } + } + + switch policy.mode { + case accountTemperatureModeInherit: + return normalized, nil + case accountTemperatureModeOmit: + updated, deleteErr := sjson.DeleteBytes(normalized, string(path)) + if deleteErr != nil { + return nil, infraerrors.BadRequest("INVALID_TEMPERATURE", "temperature could not be removed from request body") + } + return updated, nil + case accountTemperatureModeOverride: + updated, setErr := sjson.SetBytes(normalized, string(path), policy.temperature) + if setErr != nil { + return nil, infraerrors.BadRequest("INVALID_TEMPERATURE", "temperature could not be written to request body") + } + return updated, nil + default: + return nil, invalidAccountTemperature("temperature_mode must be inherit, override, or omit") + } +} + +func applyResolvedAccountTemperaturePolicy( + ctx context.Context, + repo AccountRepository, + account *Account, + body []byte, + path temperaturePath, +) ([]byte, error) { + credentialAccount, err := resolveCredentialAccount(ctx, repo, account) + if err != nil { + return nil, err + } + return applyAccountTemperaturePolicy(body, credentialAccount, path) +} + +func accountTemperatureNumber(value any) (float64, bool) { + var number float64 + switch v := value.(type) { + case json.Number: + parsed, err := v.Float64() + if err != nil { + return 0, false + } + number = parsed + case float64: + number = v + case float32: + number = float64(v) + case int: + number = float64(v) + case int8: + number = float64(v) + case int16: + number = float64(v) + case int32: + number = float64(v) + case int64: + number = float64(v) + case uint: + number = float64(v) + case uint8: + number = float64(v) + case uint16: + number = float64(v) + case uint32: + number = float64(v) + case uint64: + number = float64(v) + default: + return 0, false + } + return number, !math.IsNaN(number) && !math.IsInf(number, 0) +} + +func invalidAccountTemperature(message string) error { + return infraerrors.BadRequest("INVALID_ACCOUNT_TEMPERATURE", message) +} diff --git a/backend/internal/service/account_temperature_policy_test.go b/backend/internal/service/account_temperature_policy_test.go new file mode 100644 index 000000000000..aeaa36c33a0c --- /dev/null +++ b/backend/internal/service/account_temperature_policy_test.go @@ -0,0 +1,124 @@ +package service + +import ( + "context" + "encoding/json" + "testing" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestValidateAccountTemperatureCredentials(t *testing.T) { + tests := []struct { + name string + credentials map[string]any + wantErr bool + }{ + {name: "legacy account inherits", credentials: map[string]any{}}, + {name: "inherit without value", credentials: map[string]any{"temperature_mode": "inherit"}}, + {name: "omit with stale value", credentials: map[string]any{"temperature_mode": "omit", "temperature": 0.7}}, + {name: "override accepts zero", credentials: map[string]any{"temperature_mode": "override", "temperature": 0.0}}, + {name: "override accepts json number", credentials: map[string]any{"temperature_mode": "override", "temperature": json.Number("0.75")}}, + {name: "override requires value", credentials: map[string]any{"temperature_mode": "override"}, wantErr: true}, + {name: "unknown mode", credentials: map[string]any{"temperature_mode": "automatic"}, wantErr: true}, + {name: "mode must be string", credentials: map[string]any{"temperature_mode": true}, wantErr: true}, + {name: "temperature must be numeric", credentials: map[string]any{"temperature_mode": "override", "temperature": "0.7"}, wantErr: true}, + {name: "temperature must be finite", credentials: map[string]any{"temperature_mode": "override", "temperature": json.Number("1e9999")}, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateAccountTemperatureCredentials(tt.credentials) + if tt.wantErr { + require.Error(t, err) + require.True(t, infraerrors.IsBadRequest(err)) + return + } + require.NoError(t, err) + }) + } +} + +func TestApplyAccountTemperaturePolicyTopLevel(t *testing.T) { + tests := []struct { + name string + credentials map[string]any + body string + wantExists bool + wantValue float64 + wantErr bool + }{ + {name: "inherit preserves caller value", credentials: map[string]any{}, body: `{"temperature":0}`, wantExists: true, wantValue: 0}, + {name: "inherit leaves missing value absent", credentials: map[string]any{"temperature_mode": "inherit"}, body: `{}`}, + {name: "inherit treats null as omitted", credentials: map[string]any{"temperature_mode": "inherit"}, body: `{"temperature":null}`}, + {name: "override replaces caller value", credentials: map[string]any{"temperature_mode": "override", "temperature": 0.35}, body: `{"temperature":1}`, wantExists: true, wantValue: 0.35}, + {name: "override adds missing value", credentials: map[string]any{"temperature_mode": "override", "temperature": 0.6}, body: `{}`, wantExists: true, wantValue: 0.6}, + {name: "omit removes caller value", credentials: map[string]any{"temperature_mode": "omit"}, body: `{"temperature":0.8}`}, + {name: "invalid caller type", credentials: map[string]any{}, body: `{"temperature":"0.8"}`, wantErr: true}, + {name: "invalid caller non finite value", credentials: map[string]any{}, body: `{"temperature":1e9999}`, wantErr: true}, + {name: "invalid account policy", credentials: map[string]any{"temperature_mode": "override"}, body: `{}`, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := applyAccountTemperaturePolicy([]byte(tt.body), &Account{Credentials: tt.credentials}, temperaturePathTopLevel) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + value := gjson.GetBytes(got, "temperature") + require.Equal(t, tt.wantExists, value.Exists()) + if tt.wantExists { + require.InDelta(t, tt.wantValue, value.Float(), 1e-9) + } + }) + } +} + +func TestApplyAccountTemperaturePolicyGemini(t *testing.T) { + override := &Account{Credentials: map[string]any{"temperature_mode": "override", "temperature": 0.25}} + got, err := applyAccountTemperaturePolicy([]byte(`{"generationConfig":{"topP":0.9}}`), override, temperaturePathGemini) + require.NoError(t, err) + require.InDelta(t, 0.25, gjson.GetBytes(got, "generationConfig.temperature").Float(), 1e-9) + require.InDelta(t, 0.9, gjson.GetBytes(got, "generationConfig.topP").Float(), 1e-9) + + omit := &Account{Credentials: map[string]any{"temperature_mode": "omit"}} + got, err = applyAccountTemperaturePolicy(got, omit, temperaturePathGemini) + require.NoError(t, err) + require.False(t, gjson.GetBytes(got, "generationConfig.temperature").Exists()) + require.InDelta(t, 0.9, gjson.GetBytes(got, "generationConfig.topP").Float(), 1e-9) +} + +func TestApplyResolvedAccountTemperaturePolicyUsesSparkParent(t *testing.T) { + parentID := int64(41) + parent := Account{ + ID: parentID, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "temperature_mode": "override", + "temperature": 0.15, + }, + } + shadow := &Account{ + ID: 42, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + ParentAccountID: &parentID, + Credentials: map[string]any{"temperature_mode": "omit"}, + } + + got, err := applyResolvedAccountTemperaturePolicy( + context.Background(), + stubOpenAIAccountRepo{accounts: []Account{parent}}, + shadow, + []byte(`{"temperature":0.9}`), + temperaturePathTopLevel, + ) + + require.NoError(t, err) + require.InDelta(t, 0.15, gjson.GetBytes(got, "temperature").Float(), 1e-9) +} diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index ae194f61e1e7..a71f3ae214de 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -502,6 +502,9 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { return nil, err } + if err := validateAccountTemperatureCredentials(input.Credentials); err != nil { + return nil, err + } // Never persist ephemeral SSO/password secrets after OAuth conversion. input.Credentials = SanitizeStoredCredentials(input.Platform, input.Credentials) @@ -622,6 +625,11 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if err := NormalizeHeaderOverrideCredentials(account.Credentials); err != nil { return nil, err } + if hasAccountTemperatureCredentialUpdate(input.Credentials) { + if err := validateAccountTemperatureCredentials(account.Credentials); err != nil { + return nil, err + } + } // Strip SSO/password residue that must never sit next to OAuth tokens. account.Credentials = SanitizeStoredCredentials(account.Platform, account.Credentials) } @@ -1041,6 +1049,21 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { return nil, err } + if hasAccountTemperatureCredentialUpdate(input.Credentials) { + for _, account := range cachedTargets { + if account == nil { + continue + } + merged := maps.Clone(account.Credentials) + if merged == nil { + merged = make(map[string]any, len(input.Credentials)) + } + maps.Copy(merged, input.Credentials) + if err := validateAccountTemperatureCredentials(merged); err != nil { + return nil, err + } + } + } // Bulk may mix platforms; always drop ephemeral SSO/password keys (cookie // only when platform is known Grok — empty platform still strips password/*). if input.Credentials != nil { diff --git a/backend/internal/service/admin_service_bulk_update_test.go b/backend/internal/service/admin_service_bulk_update_test.go index e935beaa9877..91b30b859840 100644 --- a/backend/internal/service/admin_service_bulk_update_test.go +++ b/backend/internal/service/admin_service_bulk_update_test.go @@ -244,6 +244,43 @@ func TestAdminService_BulkUpdateAccounts_NilGroupRepoReturnsError(t *testing.T) require.Contains(t, err.Error(), "group repository not configured") } +func TestAdminService_BulkUpdateAccounts_ValidatesMergedTemperaturePolicy(t *testing.T) { + repo := &accountRepoStubForBulkUpdate{ + getByIDsAccounts: []*Account{ + {ID: 1, Credentials: map[string]any{"temperature": 0.45}}, + {ID: 2, Credentials: map[string]any{"temperature": 0.8}}, + }, + } + svc := &adminServiceImpl{accountRepo: repo} + + result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{ + AccountIDs: []int64{1, 2}, + Credentials: map[string]any{"temperature_mode": "override"}, + }) + + require.NoError(t, err) + require.Equal(t, 2, result.Success) + require.Equal(t, 1, repo.bulkUpdateCalls) + require.Equal(t, "override", repo.lastBulkUpdate.Credentials["temperature_mode"]) +} + +func TestAdminService_BulkUpdateAccounts_RejectsInvalidTemperaturePolicyBeforeWrite(t *testing.T) { + repo := &accountRepoStubForBulkUpdate{ + getByIDsAccounts: []*Account{{ID: 1, Credentials: map[string]any{}}}, + } + svc := &adminServiceImpl{accountRepo: repo} + + result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{ + AccountIDs: []int64{1}, + Credentials: map[string]any{"temperature_mode": "override"}, + }) + + require.Nil(t, result) + require.Error(t, err) + require.True(t, infraerrors.IsBadRequest(err)) + require.Zero(t, repo.bulkUpdateCalls) +} + // TestAdminService_BulkUpdateAccounts_MixedChannelPreCheckBlocksOnExistingConflict verifies // that the global pre-check detects a conflict with existing group members and returns an // error before any DB write is performed. diff --git a/backend/internal/service/admin_service_credentials_merge_test.go b/backend/internal/service/admin_service_credentials_merge_test.go index 8250db281cb2..ba96b1e07197 100644 --- a/backend/internal/service/admin_service_credentials_merge_test.go +++ b/backend/internal/service/admin_service_credentials_merge_test.go @@ -6,6 +6,7 @@ import ( "context" "testing" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/stretchr/testify/require" ) @@ -115,3 +116,26 @@ func TestUpdateAccount_EmptyCredentialsSkipsUpdate(t *testing.T) { require.Equal(t, "rt-existing", repo.account.Credentials["refresh_token"], "空 credentials 不应触碰已有 token") require.Equal(t, "renamed", repo.account.Name) } + +func TestUpdateAccount_RejectsInvalidTemperaturePolicyBeforeWrite(t *testing.T) { + accountID := int64(205) + repo := &updateAccountCredsRepoStub{ + account: &Account{ + ID: accountID, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Status: StatusActive, + Credentials: map[string]any{"api_key": "sk-existing"}, + }, + } + svc := &adminServiceImpl{accountRepo: repo} + + updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + Credentials: map[string]any{"temperature_mode": "override"}, + }) + + require.Nil(t, updated) + require.Error(t, err) + require.True(t, infraerrors.IsBadRequest(err)) + require.Zero(t, repo.updateCalls) +} diff --git a/backend/internal/service/antigravity_gateway_claude.go b/backend/internal/service/antigravity_gateway_claude.go index 3511f60288da..241cd4de31c4 100644 --- a/backend/internal/service/antigravity_gateway_claude.go +++ b/backend/internal/service/antigravity_gateway_claude.go @@ -29,6 +29,11 @@ import ( // └─ 失败 → 设置模型限流 + 清除粘性绑定 → 切换账号 func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte, isStickySession bool) (*ForwardResult, error) { beginUpstreamResponseModelObservation(c) + policyBody, policyErr := applyAccountTemperaturePolicy(body, account, temperaturePathTopLevel) + if policyErr != nil { + return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", policyErr.Error()) + } + body = policyBody // 上游透传账号直接转发,不走 OAuth token 刷新 if account.Type == AccountTypeUpstream { return s.ForwardUpstream(ctx, c, account, body) diff --git a/backend/internal/service/antigravity_gateway_compat.go b/backend/internal/service/antigravity_gateway_compat.go index 1d6d39e1aa69..eff55da417cb 100644 --- a/backend/internal/service/antigravity_gateway_compat.go +++ b/backend/internal/service/antigravity_gateway_compat.go @@ -62,6 +62,11 @@ func (s *AntigravityGatewayService) ForwardAsChatCompletions( if err := s.validateAntigravityCompatAccount(c, account); err != nil { return nil, err } + policyBody, policyErr := applyAccountTemperaturePolicy(body, account, temperaturePathTopLevel) + if policyErr != nil { + return nil, s.writeAntigravityCompatError(c, http.StatusBadRequest, "invalid_request_error", policyErr.Error()) + } + body = policyBody var request apicompat.ChatCompletionsRequest if json.Unmarshal(body, &request) != nil { @@ -109,6 +114,11 @@ func (s *AntigravityGatewayService) ForwardAsResponses( if err := s.validateAntigravityCompatAccount(c, account); err != nil { return nil, err } + policyBody, policyErr := applyAccountTemperaturePolicy(body, account, temperaturePathTopLevel) + if policyErr != nil { + return nil, s.writeAntigravityCompatError(c, http.StatusBadRequest, "invalid_request_error", policyErr.Error()) + } + body = policyBody var request apicompat.ResponsesRequest if json.Unmarshal(body, &request) != nil { diff --git a/backend/internal/service/antigravity_gateway_compat_test.go b/backend/internal/service/antigravity_gateway_compat_test.go index a92b1654c231..9bf806e15577 100644 --- a/backend/internal/service/antigravity_gateway_compat_test.go +++ b/backend/internal/service/antigravity_gateway_compat_test.go @@ -309,11 +309,14 @@ func TestAntigravityCompatPreservesChatTokenLimit(t *testing.T) { svc := newAntigravityCompatService(config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, upstream) body := []byte(tt.body) c, _ := newAntigravityCompatContext(http.MethodPost, "/v1/chat/completions", body) + account := newAntigravityCompatAccount(AccountTypeOAuth) + account.Credentials["temperature_mode"] = "override" + account.Credentials["temperature"] = 0.3 result, err := svc.ForwardAsChatCompletions( context.Background(), c, - newAntigravityCompatAccount(AccountTypeOAuth), + account, body, nil, ) @@ -322,6 +325,7 @@ func TestAntigravityCompatPreservesChatTokenLimit(t *testing.T) { require.NotNil(t, result) require.Len(t, upstream.requestBodies, 1) require.Equal(t, tt.want, gjson.GetBytes(upstream.requestBodies[0], "request.generationConfig.maxOutputTokens").Int()) + require.InDelta(t, 0.3, gjson.GetBytes(upstream.requestBodies[0], "request.generationConfig.temperature").Float(), 1e-9) }) } } diff --git a/backend/internal/service/antigravity_gateway_gemini.go b/backend/internal/service/antigravity_gateway_gemini.go index 77a7be3df78f..312934b013ce 100644 --- a/backend/internal/service/antigravity_gateway_gemini.go +++ b/backend/internal/service/antigravity_gateway_gemini.go @@ -85,6 +85,11 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co default: return nil, s.writeGoogleError(c, http.StatusNotFound, "Unsupported action: "+action) } + policyBody, policyErr := applyAccountTemperaturePolicy(body, account, temperaturePathGemini) + if policyErr != nil { + return nil, s.writeGoogleError(c, http.StatusBadRequest, policyErr.Error()) + } + body = policyBody mappedModel := s.getMappedModel(account, originalModel) if mappedModel == "" { diff --git a/backend/internal/service/antigravity_gateway_service_test.go b/backend/internal/service/antigravity_gateway_service_test.go index c463a870e55e..b7dd1db52886 100644 --- a/backend/internal/service/antigravity_gateway_service_test.go +++ b/backend/internal/service/antigravity_gateway_service_test.go @@ -18,6 +18,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) // antigravityFailingWriter 模拟客户端断开连接的 gin.ResponseWriter @@ -401,7 +402,7 @@ func TestAntigravityGatewayService_ForwardGemini_ImageUsesDefaultMappingAndOAuth func TestAntigravityGatewayService_ForwardGemini_PreservesServerSideToolInvocationConfig(t *testing.T) { gin.SetMode(gin.TestMode) - body := []byte(`{"contents":[{"role":"user","parts":[{"text":"hello"}]}],"tools":[{"functionDeclarations":[{"name":"get_weather","parameters":{"type":"object","additionalProperties":false}}]},{"googleSearch":{}}],"toolConfig":{"includeServerSideToolInvocations":true}}`) + body := []byte(`{"contents":[{"role":"user","parts":[{"text":"hello"}]}],"generationConfig":{"temperature":0.8},"tools":[{"functionDeclarations":[{"name":"get_weather","parameters":{"type":"object","additionalProperties":false}}]},{"googleSearch":{}}],"toolConfig":{"includeServerSideToolInvocations":true}}`) writer := httptest.NewRecorder() c, _ := gin.CreateTestContext(writer) body = bytes.ReplaceAll(body, []byte{92}, nil) @@ -419,7 +420,7 @@ func TestAntigravityGatewayService_ForwardGemini_PreservesServerSideToolInvocati } account := &Account{ ID: 103, Name: "native-gemini", Platform: PlatformAntigravity, Type: AccountTypeOAuth, Status: StatusActive, Concurrency: 1, - Credentials: map[string]any{"access_token": "token", "project_id": "project-103", "model_mapping": map[string]any{"gemini-2.5-flash": "gemini-2.5-flash"}}, + Credentials: map[string]any{"access_token": "token", "project_id": "project-103", "model_mapping": map[string]any{"gemini-2.5-flash": "gemini-2.5-flash"}, "temperature_mode": "omit"}, } result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "generateContent", false, body, false) @@ -435,6 +436,7 @@ func TestAntigravityGatewayService_ForwardGemini_PreservesServerSideToolInvocati require.True(t, ok) require.Equal(t, true, toolConfig["includeServerSideToolInvocations"]) require.NotContains(t, toolConfig, "include_server_side_tool_invocations") + require.False(t, gjson.GetBytes(upstream.requestBodies[0], "request.generationConfig.temperature").Exists()) } func TestAntigravityGatewayService_ForwardGemini_MissingProjectReturnsLocalError(t *testing.T) { diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index bf899211e8aa..c406e0f1ba07 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -293,6 +293,11 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( token string, ) (*http.Request, []byte, error) { body = stripDeferredToolCacheControl(body) + policyBody, policyErr := applyAccountTemperaturePolicy(body, account, temperaturePathTopLevel) + if policyErr != nil { + return nil, nil, policyErr + } + body = policyBody targetURL := claudeAPIURL baseURL := account.GetBaseURL() if baseURL != "" { diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index 257107940330..a1db667f6485 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -104,6 +104,14 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A } }() beginUpstreamResponseModelObservation(c) + policyBody, err := applyAccountTemperaturePolicy(parsed.Body.Bytes(), account, temperaturePathTopLevel) + if err != nil { + writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } + if err := parsed.ReplaceBody(policyBody); err != nil { + return nil, fmt.Errorf("rewrite request body: %w", err) + } // Web Search 模拟:纯 web_search 请求时,直接调用搜索 API 构造响应 if account != nil && s.shouldEmulateWebSearch(ctx, account, parsed.GroupID, parsed.Body.Bytes()) { diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index b3d6c565ba31..98a73f100d5c 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -34,6 +34,12 @@ func (s *GatewayService) ForwardAsChatCompletions( parsed *ParsedRequest, ) (*ForwardResult, error) { startTime := time.Now() + policyBody, policyErr := applyAccountTemperaturePolicy(body, account, temperaturePathTopLevel) + if policyErr != nil { + writeGatewayCCError(c, http.StatusBadRequest, "invalid_request_error", policyErr.Error()) + return nil, policyErr + } + body = policyBody // 1. Parse Chat Completions request var ccReq apicompat.ChatCompletionsRequest diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index a46996d04ba1..495ebf554f25 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -36,6 +36,12 @@ func (s *GatewayService) ForwardAsResponses( parsed *ParsedRequest, ) (*ForwardResult, error) { startTime := time.Now() + policyBody, policyErr := applyAccountTemperaturePolicy(body, account, temperaturePathTopLevel) + if policyErr != nil { + writeResponsesError(c, http.StatusBadRequest, "invalid_temperature", policyErr.Error()) + return nil, policyErr + } + body = policyBody normalizedBody, normalized, err := normalizeOpenAIResponsesLegacyIngress(body) if err != nil { diff --git a/backend/internal/service/gateway_temperature_policy_test.go b/backend/internal/service/gateway_temperature_policy_test.go new file mode 100644 index 000000000000..f2f57180946e --- /dev/null +++ b/backend/internal/service/gateway_temperature_policy_test.go @@ -0,0 +1,206 @@ +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestGatewayBuildUpstreamRequestAppliesFinalTemperaturePolicy(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + credentials map[string]any + wantExists bool + wantValue float64 + }{ + { + name: "override replaces Claude OAuth default", + credentials: map[string]any{"temperature_mode": "override", "temperature": 0.2}, + wantExists: true, + wantValue: 0.2, + }, + { + name: "omit removes Claude OAuth default", + credentials: map[string]any{"temperature_mode": "omit"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + account := &Account{ + ID: 1, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Credentials: tt.credentials, + } + + req, wireBody, err := (&GatewayService{}).buildUpstreamRequest( + context.Background(), c, account, + []byte(`{"model":"claude-sonnet-4-5","temperature":1,"messages":[]}`), + "token", "oauth", "claude-sonnet-4-5", false, false, + ) + + require.NoError(t, err) + require.NotNil(t, req) + value := gjson.GetBytes(wireBody, "temperature") + require.Equal(t, tt.wantExists, value.Exists()) + if tt.wantExists { + require.InDelta(t, tt.wantValue, value.Float(), 1e-9) + } + }) + } +} + +func TestOpenAIChatTemperaturePolicyRespectsResponsesModelCapabilities(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + requestModel string + upstreamModel string + wantTemperature bool + }{ + {name: "supported model keeps account override", requestModel: "gpt-4.1", upstreamModel: "gpt-4.1", wantTemperature: true}, + {name: "reasoning model removes unsupported override", requestModel: "gpt-5.4", upstreamModel: "gpt-5.4"}, + {name: "mapped reasoning model removes unsupported override", requestModel: "gpt-4.1", upstreamModel: "gpt-5.4"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := []byte(`{"model":"` + tt.requestModel + `","messages":[{"role":"user","content":"hello"}],"stream":false}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"stop after request capture"}}`)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 2, + Name: "openai-api-key", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "temperature_mode": "override", + "temperature": 0.2, + }, + Extra: map[string]any{"openai_responses_supported": true}, + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", tt.upstreamModel) + + require.Error(t, err) + require.Nil(t, result) + temperature := gjson.GetBytes(upstream.lastBody, "temperature") + require.Equal(t, tt.wantTemperature, temperature.Exists()) + if tt.wantTemperature { + require.InDelta(t, 0.2, temperature.Float(), 1e-9) + } + }) + } +} + +func TestOpenAIMessagesTemperaturePolicyUsesMappedModelCapabilities(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"claude-sonnet-4","max_tokens":1024,"messages":[{"role":"user","content":"hello"}],"temperature":0.9}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"stop after request capture"}}`)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 2, + Name: "openai-api-key", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "temperature_mode": "override", + "temperature": 0.2, + }, + Extra: map[string]any{"openai_responses_supported": true}, + } + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4") + + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "temperature").Exists()) +} + +func TestOpenAIResponsesTemperaturePolicyRespectsModelCapabilities(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + model string + wantTemperature bool + }{ + {name: "supported model keeps account override", model: "gpt-4.1", wantTemperature: true}, + {name: "reasoning model removes unsupported override", model: "gpt-5.4"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := []byte(`{"model":"` + tt.model + `","input":"hello","stream":false}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"stop after request capture"}}`)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 2, + Name: "openai-api-key", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "temperature_mode": "override", + "temperature": 0.2, + }, + Extra: map[string]any{"openai_responses_supported": true}, + } + + result, err := svc.Forward(context.Background(), c, account, body) + + require.Error(t, err) + require.Nil(t, result) + temperature := gjson.GetBytes(upstream.lastBody, "temperature") + require.Equal(t, tt.wantTemperature, temperature.Exists()) + if tt.wantTemperature { + require.InDelta(t, 0.2, temperature.Float(), 1e-9) + } + }) + } +} diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index 0a8efbdd5e57..b81ccdd98d81 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -20,6 +20,11 @@ import ( func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, reqStream bool, mimicClaudeCode bool) (*http.Request, []byte, error) { body = stripDeferredToolCacheControl(body) + policyBody, policyErr := applyAccountTemperaturePolicy(body, account, temperaturePathTopLevel) + if policyErr != nil { + return nil, nil, policyErr + } + body = policyBody if account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount { req, err := s.buildUpstreamRequestAnthropicVertex(ctx, c, account, body, token, modelID, reqStream) return req, body, err diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index a56a1b3ebc16..661ab37bbc37 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -29,6 +29,11 @@ func (s *GeminiMessagesCompatService) ForwardAsChatCompletions( body []byte, ) (*ForwardResult, error) { startTime := time.Now() + policyBody, policyErr := applyAccountTemperaturePolicy(body, account, temperaturePathTopLevel) + if policyErr != nil { + return nil, s.writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", policyErr.Error()) + } + body = policyBody var ccReq apicompat.ChatCompletionsRequest if err := json.Unmarshal(body, &ccReq); err != nil { diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 5f9854167de8..877e95897d3b 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -584,6 +584,11 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex beginUpstreamResponseModelObservation(c) beginGeminiImageOutputObservation(c) startTime := time.Now() + policyBody, policyErr := applyAccountTemperaturePolicy(body, account, temperaturePathTopLevel) + if policyErr != nil { + return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", policyErr.Error()) + } + body = policyBody var req struct { Model string `json:"model"` @@ -1151,6 +1156,13 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. default: return nil, s.writeGoogleError(c, http.StatusNotFound, "Unsupported action: "+action) } + if action != "countTokens" { + policyBody, policyErr := applyAccountTemperaturePolicy(body, account, temperaturePathGemini) + if policyErr != nil { + return nil, s.writeGoogleError(c, http.StatusBadRequest, policyErr.Error()) + } + body = policyBody + } // Some Gemini upstreams validate tool call parts strictly; ensure any `functionCall` part includes a // `thoughtSignature` to avoid frequent INVALID_ARGUMENT 400s. diff --git a/backend/internal/service/gemini_messages_compat_service_test.go b/backend/internal/service/gemini_messages_compat_service_test.go index e9ee74e3beee..8fdc2c1e58c9 100644 --- a/backend/internal/service/gemini_messages_compat_service_test.go +++ b/backend/internal/service/gemini_messages_compat_service_test.go @@ -16,6 +16,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) type geminiCompatHTTPUpstreamStub struct { @@ -117,6 +118,42 @@ func TestGeminiForwardAsChatCompletions_OAuthRoutesToGeminiAndReturnsChatFormat( require.Equal(t, float64(10), usage["total_tokens"]) } +func TestGeminiForwardAsChatCompletionsAppliesAccountTemperatureOverride(t *testing.T) { + gin.SetMode(gin.TestMode) + upstreamBody := "data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{}}}\n\n" + httpStub := &geminiCompatHTTPUpstreamStub{response: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + svc := &GeminiMessagesCompatService{ + tokenProvider: &GeminiTokenProvider{}, + httpUpstream: httpStub, + cfg: &config.Config{}, + } + account := &Account{ + ID: 201, Platform: PlatformGemini, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{ + "access_token": "ya29.test-token", + "project_id": "project-1", + "temperature_mode": "override", + "temperature": 0.25, + }, + } + body := []byte(`{"model":"gemini-2.5-flash","temperature":0.9,"messages":[{"role":"user","content":"hi"}]}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + sent, err := io.ReadAll(httpStub.lastReq.Body) + require.NoError(t, err) + require.InDelta(t, 0.25, gjson.GetBytes(sent, "request.generationConfig.temperature").Float(), 1e-9) +} + func TestGeminiForwardAsChatCompletions_StreamsOpenAIChunksFromGeminiSSE(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index a3a992175cef..fe6cce4e785f 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -61,6 +61,12 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( ) (*OpenAIForwardResult, error) { beginUpstreamResponseModelObservation(c) ClearActualOpenAIUpstreamEndpoint(c) + policyBody, policyErr := applyResolvedAccountTemperaturePolicy(ctx, s.accountRepo, account, body, temperaturePathTopLevel) + if policyErr != nil { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", policyErr.Error()) + return nil, policyErr + } + body = policyBody if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions") } @@ -197,6 +203,13 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( responsesBody = stripped } } + if isOpenAICodexReasoningGPTModel(upstreamModel) { + for _, field := range []string{"temperature", "top_p"} { + if stripped, derr := sjson.DeleteBytes(responsesBody, field); derr == nil { + responsesBody = stripped + } + } + } var normalizedServiceTier string responsesBody, normalizedServiceTier, err = normalizeResponsesBodyServiceTier(responsesBody) if err != nil { @@ -219,6 +232,10 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( return nil, fmt.Errorf("convert chat completions to responses: %w", err) } responsesReq.Model = upstreamModel + if isOpenAICodexReasoningGPTModel(upstreamModel) { + responsesReq.Temperature = nil + responsesReq.TopP = nil + } normalizeResponsesRequestServiceTier(responsesReq) responsesBody, err = json.Marshal(responsesReq) if err != nil { diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 304af599093b..7cbe7b12b714 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -33,6 +33,12 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco clearOpenAIResponsesClientToolMapping(c) clearOpenAIResponsesNamespaceNames(c) setCodexToolNameReverse(c, nil) + policyBody, policyErr := applyResolvedAccountTemperaturePolicy(ctx, s.accountRepo, account, body, temperaturePathTopLevel) + if policyErr != nil { + writeResponsesError(c, http.StatusBadRequest, "invalid_temperature", policyErr.Error()) + return nil, policyErr + } + body = policyBody if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil { return nil, err } @@ -379,6 +385,9 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if upstreamModel != requestedModel { markPatchSet("model", upstreamModel) } + if isOpenAICodexReasoningGPTModel(upstreamModel) && gjson.GetBytes(body, "temperature").Exists() { + markPatchDelete("temperature") + } if upstreamModel != billingModel { if isCompactRequest { logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Compact model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", requestedModel, upstreamModel, account.Name, isCodexCLI) diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index a494be8adf9d..b17029a82af0 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -35,6 +35,12 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( ) (*OpenAIForwardResult, error) { beginUpstreamResponseModelObservation(c) ClearActualOpenAIUpstreamEndpoint(c) + policyBody, policyErr := applyResolvedAccountTemperaturePolicy(ctx, s.accountRepo, account, body, temperaturePathTopLevel) + if policyErr != nil { + writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", policyErr.Error()) + return nil, policyErr + } + body = policyBody if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions") } @@ -141,6 +147,10 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( } responsesReq.Model = upstreamModel + if isOpenAICodexReasoningGPTModel(upstreamModel) { + responsesReq.Temperature = nil + responsesReq.TopP = nil + } if responsesReq.Reasoning != nil { responsesReq.Reasoning.Effort = openAICompatAnthropicReasoningEffort(&anthropicReq, upstreamModel, responsesReq.Reasoning.Effort) } diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index dec535c543db..5069d3f608c7 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -1219,7 +1219,7 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, boo changed = true } - for _, field := range openAIChatGPTInternalUnsupportedFields { + for _, field := range openAICodexOAuthUnsupportedFields { if value := gjson.GetBytes(normalized, field); !value.Exists() { continue } diff --git a/backend/internal/service/openai_passthrough_normalization_test.go b/backend/internal/service/openai_passthrough_normalization_test.go index f86ecc6927be..726e5e610f65 100644 --- a/backend/internal/service/openai_passthrough_normalization_test.go +++ b/backend/internal/service/openai_passthrough_normalization_test.go @@ -13,13 +13,24 @@ func TestNormalizeOpenAIPassthroughOAuthBody_RemovesUnsupportedUser(t *testing.T normalized, changed, err := normalizeOpenAIPassthroughOAuthBody(body, false) require.NoError(t, err) require.True(t, changed) - for _, field := range openAIChatGPTInternalUnsupportedFields { + for _, field := range openAICodexOAuthUnsupportedFields { require.False(t, gjson.GetBytes(normalized, field).Exists(), "%s should be stripped", field) } require.True(t, gjson.GetBytes(normalized, "stream").Bool()) require.False(t, gjson.GetBytes(normalized, "store").Bool()) } +func TestNormalizeOpenAIPassthroughOAuthBody_RemovesSamplingParameters(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","temperature":0.6,"top_p":0.8,"input":"hello"}`) + + normalized, changed, err := normalizeOpenAIPassthroughOAuthBody(body, false) + + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(normalized, "temperature").Exists()) + require.False(t, gjson.GetBytes(normalized, "top_p").Exists()) +} + func TestNormalizeOpenAIPassthroughOAuthBody_NormalizesCompatibilityFields(t *testing.T) { body := []byte(`{"model":"gpt-5.5","prompt":"hello","commands":["unsupported"],"truncation":"auto","stop_sequences":["END"],"chat_template_kwargs":{"enable_thinking":true}}`) From 2373aefe72e3e82ed8e239f5fa81bd66b7beb848 Mon Sep 17 00:00:00 2001 From: miaoheng <2020745908@qq.com> Date: Fri, 28 Aug 2026 15:18:54 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E6=B8=A9=E5=BA=A6=E9=85=8D=E7=BD=AE=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在账号创建、编辑和批量编辑流程中加入温度三态配置,支持固定值 0、继承客户端参数和删除参数,并为中英文文案及凭据构建补充测试。 --- .../account/AccountTemperaturePolicyField.vue | 76 +++++++++++++++++++ .../account/BulkEditAccountModal.vue | 32 +++++++- .../components/account/CreateAccountModal.vue | 46 +++++++++-- .../components/account/EditAccountModal.vue | 30 +++++++- .../AccountTemperaturePolicyField.spec.ts | 64 ++++++++++++++++ .../__tests__/BulkEditAccountModal.spec.ts | 19 +++++ .../__tests__/CreateAccountModal.spec.ts | 34 +++++++++ .../__tests__/EditAccountModal.spec.ts | 26 +++++++ .../__tests__/credentialsBuilder.spec.ts | 56 ++++++++++++++ .../components/account/credentialsBuilder.ts | 42 ++++++++++ .../src/i18n/locales/en/admin/accounts.ts | 11 +++ .../src/i18n/locales/zh/admin/accounts.ts | 11 +++ 12 files changed, 437 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/account/AccountTemperaturePolicyField.vue create mode 100644 frontend/src/components/account/__tests__/AccountTemperaturePolicyField.spec.ts diff --git a/frontend/src/components/account/AccountTemperaturePolicyField.vue b/frontend/src/components/account/AccountTemperaturePolicyField.vue new file mode 100644 index 000000000000..23aea1e4b7e9 --- /dev/null +++ b/frontend/src/components/account/AccountTemperaturePolicyField.vue @@ -0,0 +1,76 @@ + + + diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index 0576194248cd..47da11dccf17 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -31,6 +31,13 @@

+ +
([]) const selectedErrorCodes = ref([]) const customErrorCodeInput = ref(null) const interceptWarmupRequests = ref(false) +const temperatureMode = ref('unchanged') +const temperatureValue = ref(null) const headerOverrideEnabled = ref(false) const headerOverrideRows = ref([]) const proxyId = ref(null) @@ -2042,6 +2054,13 @@ const buildUpdatePayload = (): Record | null => { credentialsChanged = true } + if (temperatureMode.value !== 'unchanged') { + if (!applyAccountTemperaturePolicy(credentials, temperatureMode.value, temperatureValue.value)) { + return null + } + credentialsChanged = true + } + if (enableHeaderOverride.value) { // 后端使用 JSONB || merge 语义:关闭时显式写入 false + 空对象以清除旧配置 credentials[HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY] = headerOverrideEnabled.value @@ -2187,7 +2206,16 @@ const handleSubmit = async () => { return } + if ( + temperatureMode.value === 'override' && + (temperatureValue.value === null || !Number.isFinite(temperatureValue.value)) + ) { + appStore.showError(t('admin.accounts.temperature.invalid')) + return + } + const hasAnyFieldEnabled = + temperatureMode.value !== 'unchanged' || enableBaseUrl.value || enableOpenAIPassthrough.value || enableOpenAIFlattenNamespaces.value || @@ -2380,6 +2408,8 @@ watch( selectedErrorCodes.value = [] customErrorCodeInput.value = null interceptWarmupRequests.value = false + temperatureMode.value = 'unchanged' + temperatureValue.value = null headerOverrideEnabled.value = false headerOverrideRows.value = [] proxyId.value = null diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index ba30abfdac0b..99e2e2fa8437 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2472,6 +2472,12 @@
+ +
) => { applyHeaderOverride(credentials, headerOverrideEnabled.value, headerOverrideRows.value, 'create') } const interceptWarmupRequests = ref(false) +const temperatureMode = ref('inherit') +const temperatureValue = ref(1) const autoPauseOnExpired = ref(true) const openaiPassthroughEnabled = ref(false) // OpenAI Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留 @@ -4959,6 +4970,19 @@ const withAntigravityConfirmFlag = (payload: CreateAccountRequest): CreateAccoun return cloned } +const createAccountWithTemperature = async (payload: CreateAccountRequest) => { + const credentials = { ...payload.credentials } + if (!applyAccountTemperaturePolicy(credentials, temperatureMode.value, temperatureValue.value)) { + throw new Error(t('admin.accounts.temperature.invalid')) + } + return adminAPI.accounts.create( + withAntigravityConfirmFlag({ + ...payload, + credentials + }) + ) +} + const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise): Promise => { if (!needsMixedChannelCheck(form.platform)) { return true @@ -4992,7 +5016,7 @@ const ensureAntigravityMixedChannelConfirmed = async (onConfirm: () => Promise { submitting.value = true try { - const account = await adminAPI.accounts.create(withAntigravityConfirmFlag(payload)) + const account = await createAccountWithTemperature(payload) const modelMapping = payload.credentials.model_mapping const hasConcreteMappedTarget = payload.type === 'apikey' && typeof modelMapping === 'object' && @@ -5093,6 +5117,8 @@ const resetForm = () => { grokOAuthCustomBaseUrlEnabled.value = false grokOAuthBaseUrl.value = '' interceptWarmupRequests.value = false + temperatureMode.value = 'inherit' + temperatureValue.value = 1 autoPauseOnExpired.value = true openaiPassthroughEnabled.value = false openaiFlattenNamespacesEnabled.value = false @@ -5783,7 +5809,7 @@ const handleGrokValidateRT = async (refreshTokenInput: string) => { return } - await adminAPI.accounts.create({ + await createAccountWithTemperature({ name: accountName, notes: form.notes, platform: 'grok', @@ -5960,7 +5986,7 @@ const handleGrokAuthorizePassword = async (emailPasswordInput: string) => { return } - await adminAPI.accounts.create({ + await createAccountWithTemperature({ name: accountName, notes: form.notes, platform: 'grok', @@ -6059,7 +6085,7 @@ const handleOpenAIExchange = async (authCode: string) => { } if (shouldCreateOpenAI) { - await adminAPI.accounts.create({ + await createAccountWithTemperature({ name: form.name, notes: form.notes, platform: 'openai', @@ -6109,6 +6135,10 @@ const buildOpenAICodexImportCredentialExtras = (): Record | nul if (!applyTempUnschedConfig(credentials)) { return null } + if (!applyAccountTemperaturePolicy(credentials, temperatureMode.value, temperatureValue.value)) { + appStore.showError(t('admin.accounts.temperature.invalid')) + return null + } return credentials } @@ -6340,7 +6370,7 @@ const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string) const accountName = refreshTokens.length > 1 ? `${baseName} #${i + 1}` : baseName if (shouldCreateOpenAI) { - await adminAPI.accounts.create({ + await createAccountWithTemperature({ name: accountName, notes: form.notes, platform: 'openai', @@ -6455,7 +6485,7 @@ const handleAntigravityValidateRT = async (refreshTokenInput: string) => { expires_at: form.expires_at, auto_pause_on_expired: autoPauseOnExpired.value }) - await adminAPI.accounts.create(createPayload) + await createAccountWithTemperature(createPayload) successCount++ } catch (error: any) { failedCount++ @@ -6820,7 +6850,7 @@ const handleCookieAuth = async (sessionKey: string) => { credentials.temp_unschedulable_rules = tempUnschedPayload } - await adminAPI.accounts.create({ + await createAccountWithTemperature({ name: accountName, notes: form.notes, platform: form.platform, diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index b1a6d10e9791..7563b861170e 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1506,6 +1506,13 @@
+ +
@@ -2865,12 +2872,15 @@ import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue' import CnBaseUrlPresets from '@/components/account/CnBaseUrlPresets.vue' import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue' import OllamaCloudUsageSettings from '@/components/account/OllamaCloudUsageSettings.vue' +import AccountTemperaturePolicyField from '@/components/account/AccountTemperaturePolicyField.vue' import { + applyAccountTemperaturePolicy, applyAntigravityProjectID, applyHeaderOverride, applyInterceptWarmup, applyPlanType, buildPlanTypeOptions, + readAccountTemperaturePolicy, readPlanType, isCustomGrokBaseUrl, isHeaderOverrideCapable, @@ -2883,7 +2893,8 @@ import { type CnAccountMode, type CnApiProtocol, type CnNativeApiProtocol, - type HeaderOverrideRow + type HeaderOverrideRow, + type AccountTemperatureMode } from '@/components/account/credentialsBuilder' import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' @@ -3153,6 +3164,8 @@ const grokOAuthBaseUrl = ref('') const grokClientToolCacheEnabled = ref(true) const interceptWarmupRequests = ref(false) +const temperatureMode = ref('inherit') +const temperatureValue = ref(null) const autoPauseOnExpired = ref(false) const autoPause5hThreshold = ref(null) const autoPause7dThreshold = ref(null) @@ -3668,6 +3681,9 @@ const syncFormFromAccount = (newAccount: Account | null) => { // Load intercept warmup requests setting (applies to all account types) const credentials = newAccount.credentials as Record | undefined interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true + const temperaturePolicy = readAccountTemperaturePolicy(credentials) + temperatureMode.value = temperaturePolicy.mode + temperatureValue.value = temperaturePolicy.temperature autoPauseOnExpired.value = newAccount.auto_pause_on_expired === true editVertexProjectId.value = '' editVertexClientEmail.value = '' @@ -5276,6 +5292,18 @@ const handleSubmit = async () => { updatePayload.extra = newExtra } + if (!isSparkShadow.value) { + const currentCredentials = + (updatePayload.credentials as Record) || + ((props.account.credentials as Record) || {}) + const newCredentials = { ...currentCredentials } + if (!applyAccountTemperaturePolicy(newCredentials, temperatureMode.value, temperatureValue.value)) { + appStore.showError(t('admin.accounts.temperature.invalid')) + return + } + updatePayload.credentials = newCredentials + } + const canContinue = await ensureAntigravityMixedChannelConfirmed(async () => { await submitUpdateAccount(accountID, updatePayload) }) diff --git a/frontend/src/components/account/__tests__/AccountTemperaturePolicyField.spec.ts b/frontend/src/components/account/__tests__/AccountTemperaturePolicyField.spec.ts new file mode 100644 index 000000000000..722dd37d857e --- /dev/null +++ b/frontend/src/components/account/__tests__/AccountTemperaturePolicyField.spec.ts @@ -0,0 +1,64 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import AccountTemperaturePolicyField from '../AccountTemperaturePolicyField.vue' + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key + }) + } +}) + +describe('AccountTemperaturePolicyField', () => { + it('renders the three account modes and no unchanged mode by default', () => { + const wrapper = mount(AccountTemperaturePolicyField, { + props: { mode: 'inherit', temperature: null } + }) + + expect(wrapper.find('[data-testid="temperature-mode-inherit"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="temperature-mode-override"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="temperature-mode-omit"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="temperature-mode-unchanged"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="temperature-value"]').exists()).toBe(false) + }) + + it('emits mode changes and shows a numeric input for override', async () => { + const wrapper = mount(AccountTemperaturePolicyField, { + props: { mode: 'inherit', temperature: null } + }) + + await wrapper.get('[data-testid="temperature-mode-override"]').trigger('click') + expect(wrapper.emitted('update:mode')).toEqual([['override']]) + + await wrapper.setProps({ mode: 'override' }) + const input = wrapper.get('[data-testid="temperature-value"]') + expect(input.attributes('type')).toBe('number') + expect(input.attributes('required')).toBeDefined() + + await input.setValue('0') + expect(wrapper.emitted('update:temperature')?.at(-1)).toEqual([0]) + }) + + it('emits null when the override input is cleared', async () => { + const wrapper = mount(AccountTemperaturePolicyField, { + props: { mode: 'override', temperature: 0.7 } + }) + + await wrapper.get('[data-testid="temperature-value"]').setValue('') + expect(wrapper.emitted('update:temperature')?.at(-1)).toEqual([null]) + }) + + it('supports bulk unchanged mode when requested', () => { + const wrapper = mount(AccountTemperaturePolicyField, { + props: { mode: 'unchanged', temperature: null, allowUnchanged: true } + }) + + expect(wrapper.find('[data-testid="temperature-mode-unchanged"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="temperature-mode-unchanged"]').attributes('aria-pressed')).toBe( + 'true' + ) + }) +}) diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index b6fa614223e4..2b522a3b721e 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -98,6 +98,25 @@ describe('BulkEditAccountModal', () => { } as any) }) + it('keeps temperature unchanged by default and submits a zero override when selected', async () => { + const wrapper = mountModal() + expect(wrapper.get('[data-testid="temperature-mode-unchanged"]').attributes('aria-pressed')).toBe( + 'true' + ) + + await wrapper.get('[data-testid="temperature-mode-override"]').trigger('click') + await wrapper.get('[data-testid="temperature-value"]').setValue('0') + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + credentials: { + temperature_mode: 'override', + temperature: 0 + } + }) + }) + it('批量修改倍率时提示自动同步账号需要先关闭同步', async () => { const wrapper = mountModal() diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 26305c4233da..08287fe0538f 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -212,6 +212,24 @@ describe('CreateAccountModal OpenAI long-context billing', () => { createOpenAICodexPATMock.mockReset().mockResolvedValue({}) }) + it('writes an account temperature override including zero on API key creation', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="text"]').setValue('OpenAI account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + await wrapper.get('[data-testid="temperature-mode-override"]').trigger('click') + await wrapper.get('[data-testid="temperature-value"]').setValue('0') + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + + expect(createAccountMock).toHaveBeenCalledTimes(1) + expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({ + temperature_mode: 'override', + temperature: 0 + }) + }) + it('hides only the redundant account toggle when every selected group enables tier pricing', async () => { authIsSimpleMode.value = false const wrapper = mountModal([ @@ -480,6 +498,22 @@ describe('CreateAccountModal OpenAI long-context billing', () => { expect(importCodexSessionMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBeUndefined() }) + it('passes the selected temperature policy through Codex import credential extras', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await wrapper.get('[data-testid="temperature-mode-override"]').trigger('click') + await wrapper.get('[data-testid="temperature-value"]').setValue('0') + await wrapper.get('form#create-account-form input[type="text"]').setValue('Codex import') + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await wrapper.get('[data-testid="import-codex-session"]').trigger('click') + await flushPromises() + + expect(importCodexSessionMock.mock.calls[0]?.[0]?.credential_extras).toMatchObject({ + temperature_mode: 'override', + temperature: 0 + }) + }) + it('leaves Codex PAT import billing ownership to the backend', async () => { const wrapper = await openCodexImportStep() await wrapper.get('[data-testid="import-codex-pat"]').trigger('click') diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index eceb670c924f..41b36095ce11 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -324,6 +324,32 @@ function mountModal(account = buildAccount()) { } describe('EditAccountModal', () => { + it('loads and updates the stored account temperature policy', async () => { + const account = buildAccount() + account.credentials.temperature_mode = 'override' + account.credentials.temperature = 0.4 + updateAccountMock.mockReset().mockResolvedValue(account) + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + + const wrapper = mountModal(account) + expect(wrapper.get('[data-testid="temperature-value"]').element.value).toBe( + '0.4' + ) + + await wrapper.get('[data-testid="temperature-mode-omit"]').trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + const credentials = updateAccountMock.mock.calls[0]?.[1]?.credentials + expect(credentials?.temperature_mode).toBe('omit') + expect(credentials).not.toHaveProperty('temperature') + }) + + it('does not expose temperature configuration on a Spark shadow account', () => { + const wrapper = mountModal(buildOpenAISparkShadowAccount()) + expect(wrapper.find('[data-testid="temperature-mode-inherit"]').exists()).toBe(false) + }) + beforeEach(() => { authIsSimpleMode.value = true }) diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts index e5cee32ad00e..f30a0463d536 100644 --- a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts +++ b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts @@ -3,6 +3,7 @@ import { ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY, HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY, HEADER_OVERRIDES_CREDENTIAL_KEY, + applyAccountTemperaturePolicy, applyAntigravityProjectID, applyHeaderOverride, applyInterceptWarmup, @@ -14,12 +15,67 @@ import { GROK_BASE_URL_PRESETS, parseHeaderOverridesJson, planTypeDisplayLabel, + readAccountTemperaturePolicy, readPlanType, serializeHeaderOverrideRows, splitHeaderOverridesObject, validateHeaderOverrideRows } from '../credentialsBuilder' +describe('account temperature policy helpers', () => { + it('defaults missing or invalid stored policy to inherit', () => { + expect(readAccountTemperaturePolicy()).toEqual({ mode: 'inherit', temperature: null }) + expect(readAccountTemperaturePolicy({ temperature_mode: 'unknown', temperature: 0.8 })).toEqual({ + mode: 'inherit', + temperature: null + }) + }) + + it('reads override temperature including zero', () => { + expect(readAccountTemperaturePolicy({ temperature_mode: 'override', temperature: 0 })).toEqual({ + mode: 'override', + temperature: 0 + }) + }) + + it('writes inherit and omit without retaining a stale temperature', () => { + const inheritCredentials: Record = { api_key: 'sk', temperature: 0.7 } + expect(applyAccountTemperaturePolicy(inheritCredentials, 'inherit', null)).toBe(true) + expect(inheritCredentials).toEqual({ api_key: 'sk', temperature_mode: 'inherit' }) + + const omitCredentials: Record = { api_key: 'sk', temperature: 0.7 } + expect(applyAccountTemperaturePolicy(omitCredentials, 'omit', null)).toBe(true) + expect(omitCredentials).toEqual({ api_key: 'sk', temperature_mode: 'omit' }) + }) + + it('writes a finite override temperature including zero', () => { + const credentials: Record = { api_key: 'sk' } + expect(applyAccountTemperaturePolicy(credentials, 'override', 0)).toBe(true) + expect(credentials).toEqual({ + api_key: 'sk', + temperature_mode: 'override', + temperature: 0 + }) + }) + + it('rejects a missing or non-finite override without mutating credentials', () => { + for (const temperature of [null, Number.NaN, Number.POSITIVE_INFINITY]) { + const credentials: Record = { api_key: 'sk' } + expect(applyAccountTemperaturePolicy(credentials, 'override', temperature)).toBe(false) + expect(credentials).toEqual({ api_key: 'sk' }) + } + }) + + it('leaves credentials untouched in bulk unchanged mode', () => { + const credentials: Record = { + temperature_mode: 'override', + temperature: 0.4 + } + expect(applyAccountTemperaturePolicy(credentials, 'unchanged', null)).toBe(true) + expect(credentials).toEqual({ temperature_mode: 'override', temperature: 0.4 }) + }) +}) + describe('applyInterceptWarmup', () => { it('create + enabled=true: should set intercept_warmup_requests to true', () => { const creds: Record = { access_token: 'tok' } diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts index 7f844fba0a74..929b5416baf5 100644 --- a/frontend/src/components/account/credentialsBuilder.ts +++ b/frontend/src/components/account/credentialsBuilder.ts @@ -1,3 +1,45 @@ +export type AccountTemperatureMode = 'inherit' | 'override' | 'omit' +export type AccountTemperatureSelectionMode = AccountTemperatureMode | 'unchanged' + +export interface AccountTemperaturePolicy { + mode: AccountTemperatureMode + temperature: number | null +} + +export function readAccountTemperaturePolicy( + credentials?: Record | null +): AccountTemperaturePolicy { + const mode = credentials?.temperature_mode + if (mode !== 'inherit' && mode !== 'override' && mode !== 'omit') { + return { mode: 'inherit', temperature: null } + } + + const temperature = credentials?.temperature + if (mode === 'override' && typeof temperature === 'number' && Number.isFinite(temperature)) { + return { mode, temperature } + } + return { mode, temperature: null } +} + +export function applyAccountTemperaturePolicy( + credentials: Record, + mode: AccountTemperatureSelectionMode, + temperature: number | null +): boolean { + if (mode === 'unchanged') return true + if (mode === 'override' && (temperature === null || !Number.isFinite(temperature))) { + return false + } + + credentials.temperature_mode = mode + if (mode === 'override') { + credentials.temperature = temperature + } else { + delete credentials.temperature + } + return true +} + export function applyInterceptWarmup( credentials: Record, enabled: boolean, diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index ae0867b9f1d9..2d70fdd52b81 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -2,6 +2,17 @@ export default { accounts: { title: 'Account Management', description: 'Manage AI platform accounts and credentials', + temperature: { + label: 'Temperature policy', + value: 'Fixed temperature', + invalid: 'Fixed temperature must be a finite number', + modes: { + unchanged: 'Unchanged', + inherit: 'Default', + override: 'Fixed value', + omit: 'Do not send' + } + }, createAccount: 'Create Account', autoRefresh: 'Auto Refresh', enableAutoRefresh: 'Enable auto refresh', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index c277eb7c2fd8..980ee1e6975e 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -2,6 +2,17 @@ export default { accounts: { title: '账号管理', description: '管理 AI 平台账号和 Cookie', + temperature: { + label: '温度策略', + value: '固定温度', + invalid: '固定温度必须是有限数值', + modes: { + unchanged: '不修改', + inherit: '默认', + override: '固定值', + omit: '不发送' + } + }, createAccount: '添加账号', autoRefresh: '自动刷新', enableAutoRefresh: '启用自动刷新', From 80711696e60f51f29c1d66030a3a5263a30dd4ba Mon Sep 17 00:00:00 2001 From: miaoheng <2020745908@qq.com> Date: Fri, 28 Aug 2026 17:27:36 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E5=85=81=E8=AE=B8=20GPT-5.6=20?= =?UTF-8?q?=E8=B0=83=E8=8A=82=E6=B8=A9=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pkg/apicompat/anthropic_responses_test.go | 23 +++++ .../pkg/apicompat/anthropic_to_responses.go | 23 ++--- .../chatcompletions_anthropic_bridge.go | 3 +- .../chatcompletions_anthropic_bridge_test.go | 19 ++++ .../chatcompletions_responses_test.go | 22 +++++ .../apicompat/chatcompletions_to_responses.go | 4 +- .../gateway_temperature_policy_test.go | 91 +++++++++++++++++-- .../service/openai_codex_models_service.go | 9 ++ .../service/openai_codex_transform.go | 15 ++- .../service/openai_codex_transform_test.go | 28 ++++++ .../openai_gateway_chat_completions.go | 7 +- .../service/openai_gateway_forward.go | 2 +- .../service/openai_gateway_messages.go | 5 +- .../service/openai_gateway_request_body.go | 3 +- .../service/openai_model_alias_test.go | 20 ++++ .../openai_passthrough_normalization_test.go | 10 ++ .../openai_responses_rejected_field_retry.go | 9 +- ...nai_responses_rejected_field_retry_test.go | 46 ++++++++++ 18 files changed, 308 insertions(+), 31 deletions(-) diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index 539ccda0d7e8..0d8c3a652f1e 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -1683,6 +1683,29 @@ func TestAnthropicToResponses_TemperatureStrippedForAllGpt5Variants(t *testing.T } } +func TestAnthropicToResponses_TemperaturePreservedForGPT56(t *testing.T) { + temp := 0.35 + topP := 0.75 + for _, model := range []string{"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"} { + t.Run(model, func(t *testing.T) { + req := &AnthropicRequest{ + Model: model, + MaxTokens: 1024, + Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}}, + Temperature: &temp, + TopP: &topP, + } + + resp, err := AnthropicToResponses(req) + require.NoError(t, err) + require.NotNil(t, resp.Temperature) + assert.InDelta(t, temp, *resp.Temperature, 1e-9) + require.NotNil(t, resp.TopP) + assert.InDelta(t, topP, *resp.TopP, 1e-9) + }) + } +} + // --------------------------------------------------------------------------- // AnthropicToResponsesResponse: Anthropic input_tokens excludes cached tokens // while OpenAI Responses input_tokens is the total including cached tokens. diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses.go b/backend/internal/pkg/apicompat/anthropic_to_responses.go index dfbe7b5e0523..d318b34c2d1d 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses.go @@ -28,11 +28,7 @@ func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) { Include: []string{"reasoning.encrypted_content"}, } - // Reasoning models (gpt-5.x) served via the Responses API do not accept - // sampling parameters. Sending temperature or top_p causes a 400 - // "Unsupported parameter" error, so we only forward them for non-reasoning - // models. - if !isReasoningModel(req.Model) { + if !rejectsSamplingParameters(req.Model) { out.Temperature = req.Temperature out.TopP = req.TopP } @@ -465,12 +461,17 @@ func boolPtr(v bool) *bool { return &v } -// isReasoningModel reports whether model is a reasoning model that does not -// support sampling parameters (temperature, top_p) via the Responses API. -// All gpt-5.x models are reasoning-only; the Responses API returns -// "Unsupported parameter: temperature" if these fields are present. -func isReasoningModel(model string) bool { - return strings.HasPrefix(model, "gpt-5") +// rejectsSamplingParameters reports GPT-5 families that reject temperature +// and top_p. GPT-5.6 exposes both sampling parameters through the public API. +func rejectsSamplingParameters(model string) bool { + normalized := strings.ToLower(strings.TrimSpace(model)) + if slash := strings.LastIndex(normalized, "/"); slash >= 0 { + normalized = normalized[slash+1:] + } + normalized = strings.ReplaceAll(normalized, "_", "-") + return strings.HasPrefix(normalized, "gpt-5") && + normalized != "gpt-5.6" && + !strings.HasPrefix(normalized, "gpt-5.6-") } // normalizeToolParameters ensures the tool parameter schema is valid for diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go index 47d4601c24d2..25d3f73d9e8a 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go @@ -55,8 +55,7 @@ func AnthropicToChatCompletionsRequest(req *AnthropicRequest) (*ChatCompletionsR Stream: req.Stream, } - // Sampling params: reasoning models (gpt-5.x) reject temperature/top_p. - if !isReasoningModel(req.Model) { + if !rejectsSamplingParameters(req.Model) { out.Temperature = req.Temperature out.TopP = req.TopP } diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go index 8ebd5092fa5e..faacaacc56dd 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go @@ -240,6 +240,25 @@ func TestAnthropicToChatCompletionsRequest_TemperaturePreservedForNonReasoningMo require.Equal(t, 0.9, *out.TopP) } +func TestAnthropicToChatCompletionsRequest_TemperaturePreservedForGPT56(t *testing.T) { + temp := 0.35 + topP := 0.75 + req := &AnthropicRequest{ + Model: "gpt-5.6-terra", + MaxTokens: 100, + Temperature: &temp, + TopP: &topP, + Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}}, + } + + out, err := AnthropicToChatCompletionsRequest(req) + require.NoError(t, err) + require.NotNil(t, out.Temperature) + require.InDelta(t, temp, *out.Temperature, 1e-9) + require.NotNil(t, out.TopP) + require.InDelta(t, topP, *out.TopP, 1e-9) +} + func TestAnthropicToChatCompletionsRequest_MaxTokensFloor(t *testing.T) { req := &AnthropicRequest{ Model: "claude-sonnet-4-20250514", diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index a57bd73f9177..8fdae96d96fc 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -706,6 +706,28 @@ func TestChatCompletionsToResponses_TemperaturePreservedForNonReasoningModel(t * assert.InDelta(t, 0.7, *resp.TopP, 1e-9) } +func TestChatCompletionsToResponses_TemperaturePreservedForGPT56(t *testing.T) { + temp := 0.35 + topP := 0.75 + for _, model := range []string{"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"} { + t.Run(model, func(t *testing.T) { + req := &ChatCompletionsRequest{ + Model: model, + Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}}, + Temperature: &temp, + TopP: &topP, + } + + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + require.NotNil(t, resp.Temperature) + assert.InDelta(t, temp, *resp.Temperature, 1e-9) + require.NotNil(t, resp.TopP) + assert.InDelta(t, topP, *resp.TopP, 1e-9) + }) + } +} + func TestChatCompletionsToResponses_AssistantWithTextAndToolCalls(t *testing.T) { req := &ChatCompletionsRequest{ Model: "gpt-4o", diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index 384b58495b08..fbd5ee24ab0e 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -36,9 +36,7 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, ParallelToolCalls: req.ParallelToolCalls, } - // Reasoning models (gpt-5.x) do not accept sampling parameters. - // See isReasoningModel in anthropic_to_responses.go. - if !isReasoningModel(req.Model) { + if !rejectsSamplingParameters(req.Model) { out.Temperature = req.Temperature out.TopP = req.TopP } diff --git a/backend/internal/service/gateway_temperature_policy_test.go b/backend/internal/service/gateway_temperature_policy_test.go index f2f57180946e..feac1c890f79 100644 --- a/backend/internal/service/gateway_temperature_policy_test.go +++ b/backend/internal/service/gateway_temperature_policy_test.go @@ -72,6 +72,8 @@ func TestOpenAIChatTemperaturePolicyRespectsResponsesModelCapabilities(t *testin wantTemperature bool }{ {name: "supported model keeps account override", requestModel: "gpt-4.1", upstreamModel: "gpt-4.1", wantTemperature: true}, + {name: "gpt 5.6 keeps account override", requestModel: "gpt-5.6-terra", upstreamModel: "gpt-5.6-terra", wantTemperature: true}, + {name: "legacy gpt model mapped to gpt 5.6 keeps account override", requestModel: "gpt-5.4", upstreamModel: "gpt-5.6-sol", wantTemperature: true}, {name: "reasoning model removes unsupported override", requestModel: "gpt-5.4", upstreamModel: "gpt-5.4"}, {name: "mapped reasoning model removes unsupported override", requestModel: "gpt-4.1", upstreamModel: "gpt-5.4"}, } @@ -153,14 +155,87 @@ func TestOpenAIMessagesTemperaturePolicyUsesMappedModelCapabilities(t *testing.T require.False(t, gjson.GetBytes(upstream.lastBody, "temperature").Exists()) } +func TestOpenAIMessagesTemperaturePolicyKeepsGPT56Override(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-5.4","max_tokens":1024,"messages":[{"role":"user","content":"hello"}]}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"stop after request capture"}}`)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 2, + Name: "openai-api-key", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "temperature_mode": "override", + "temperature": 0.2, + }, + Extra: map[string]any{"openai_responses_supported": true}, + } + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.6-terra") + + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, "gpt-5.6-terra", gjson.GetBytes(upstream.lastBody, "model").String()) + require.InDelta(t, 0.2, gjson.GetBytes(upstream.lastBody, "temperature").Float(), 1e-9) +} + +func TestOpenAIChatResponsesShapeTemperaturePolicyUsesMappedGPT56(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-5.4","input":"hello","stream":false}`) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"stop after request capture"}}`)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 2, + Name: "openai-api-key", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "temperature_mode": "override", + "temperature": 0.2, + }, + Extra: map[string]any{"openai_responses_supported": true}, + } + + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.6-sol") + + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(upstream.lastBody, "model").String()) + require.InDelta(t, 0.2, gjson.GetBytes(upstream.lastBody, "temperature").Float(), 1e-9) +} + func TestOpenAIResponsesTemperaturePolicyRespectsModelCapabilities(t *testing.T) { gin.SetMode(gin.TestMode) tests := []struct { name string model string + mappedModel string wantTemperature bool }{ {name: "supported model keeps account override", model: "gpt-4.1", wantTemperature: true}, + {name: "gpt 5.6 keeps account override", model: "gpt-5.6-luna", wantTemperature: true}, + {name: "legacy gpt model mapped to gpt 5.6 keeps account override", model: "gpt-5.4", mappedModel: "gpt-5.6-terra", wantTemperature: true}, {name: "reasoning model removes unsupported override", model: "gpt-5.4"}, } @@ -178,18 +253,22 @@ func TestOpenAIResponsesTemperaturePolicyRespectsModelCapabilities(t *testing.T) Body: io.NopCloser(strings.NewReader(`{"error":{"message":"stop after request capture"}}`)), }} svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + credentials := map[string]any{ + "api_key": "sk-test", + "temperature_mode": "override", + "temperature": 0.2, + } + if tt.mappedModel != "" { + credentials["model_mapping"] = map[string]any{tt.model: tt.mappedModel} + } account := &Account{ ID: 2, Name: "openai-api-key", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1, - Credentials: map[string]any{ - "api_key": "sk-test", - "temperature_mode": "override", - "temperature": 0.2, - }, - Extra: map[string]any{"openai_responses_supported": true}, + Credentials: credentials, + Extra: map[string]any{"openai_responses_supported": true}, } result, err := svc.Forward(context.Background(), c, account, body) diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index b55549e753ad..caeb1fa3bed4 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -555,6 +555,15 @@ func isOpenAICodexReasoningGPTModel(modelID string) bool { return strings.HasPrefix(normalized, "gpt-5") } +func supportsOpenAICodexSamplingParameters(modelID string) bool { + normalized := canonicalizeOpenAIModelAliasSpelling(modelID) + return normalized == "gpt-5.6" || strings.HasPrefix(normalized, "gpt-5.6-") +} + +func isOpenAICodexSamplingUnsupportedModel(modelID string) bool { + return isOpenAICodexReasoningGPTModel(modelID) && !supportsOpenAICodexSamplingParameters(modelID) +} + func isOpenAICodexImageInputModel(modelID string) bool { normalized := canonicalizeOpenAIModelAliasSpelling(modelID) return strings.HasPrefix(normalized, "gpt-5") || diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index fbd14ba9672b..f1358e7dbfa6 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -154,12 +154,21 @@ var openAIChatGPTInternalUnsupportedFields = []string{ var openAICodexOAuthUnsupportedFields = append([]string{ "max_output_tokens", "max_completion_tokens", - "temperature", - "top_p", "frequency_penalty", "presence_penalty", }, openAIChatGPTInternalUnsupportedFields...) +var openAICodexOAuthSamplingFields = []string{"temperature", "top_p"} + +func openAICodexOAuthUnsupportedFieldsForModel(model string) []string { + if supportsOpenAICodexSamplingParameters(model) { + return openAICodexOAuthUnsupportedFields + } + fields := make([]string, 0, len(openAICodexOAuthUnsupportedFields)+len(openAICodexOAuthSamplingFields)) + fields = append(fields, openAICodexOAuthUnsupportedFields...) + return append(fields, openAICodexOAuthSamplingFields...) +} + func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact bool) codexTransformResult { return applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{ IsCodexCLI: isCodexCLI, @@ -211,7 +220,7 @@ func applyCodexOAuthTransformWithOptions(reqBody map[string]any, opts codexOAuth } // Strip parameters unsupported by ChatGPT internal Codex endpoint. - for _, key := range openAICodexOAuthUnsupportedFields { + for _, key := range openAICodexOAuthUnsupportedFieldsForModel(normalizedModel) { if _, ok := reqBody[key]; ok { delete(reqBody, key) result.Modified = true diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go index 96e7715d5d7b..5b4c9be04fab 100644 --- a/backend/internal/service/openai_codex_transform_test.go +++ b/backend/internal/service/openai_codex_transform_test.go @@ -1786,6 +1786,34 @@ func TestApplyCodexOAuthTransform_StripsChatGPTInternalUnsupportedFields(t *test } } +func TestApplyCodexOAuthTransform_PreservesGPT56SamplingParameters(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.6-terra", + "temperature": 0.35, + "top_p": 0.75, + "input": []any{map[string]any{"role": "user", "content": "hi"}}, + } + + applyCodexOAuthTransform(reqBody, false, false) + + require.InDelta(t, 0.35, reqBody["temperature"], 1e-9) + require.InDelta(t, 0.75, reqBody["top_p"], 1e-9) +} + +func TestApplyCodexOAuthTransform_StripsLegacySamplingParameters(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.4", + "temperature": 0.35, + "top_p": 0.75, + "input": []any{map[string]any{"role": "user", "content": "hi"}}, + } + + applyCodexOAuthTransform(reqBody, false, false) + + require.NotContains(t, reqBody, "temperature") + require.NotContains(t, reqBody, "top_p") +} + func TestApplyCodexOAuthTransform_NormalizesPromptAndCommands(t *testing.T) { reqBody := map[string]any{ "model": "gpt-5.5", diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index fe6cce4e785f..e1bc5a36b06a 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -203,7 +203,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( responsesBody = stripped } } - if isOpenAICodexReasoningGPTModel(upstreamModel) { + if isOpenAICodexSamplingUnsupportedModel(upstreamModel) { for _, field := range []string{"temperature", "top_p"} { if stripped, derr := sjson.DeleteBytes(responsesBody, field); derr == nil { responsesBody = stripped @@ -232,9 +232,12 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( return nil, fmt.Errorf("convert chat completions to responses: %w", err) } responsesReq.Model = upstreamModel - if isOpenAICodexReasoningGPTModel(upstreamModel) { + if isOpenAICodexSamplingUnsupportedModel(upstreamModel) { responsesReq.Temperature = nil responsesReq.TopP = nil + } else { + responsesReq.Temperature = chatReq.Temperature + responsesReq.TopP = chatReq.TopP } normalizeResponsesRequestServiceTier(responsesReq) responsesBody, err = json.Marshal(responsesReq) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 7cbe7b12b714..f5300588ce76 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -385,7 +385,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if upstreamModel != requestedModel { markPatchSet("model", upstreamModel) } - if isOpenAICodexReasoningGPTModel(upstreamModel) && gjson.GetBytes(body, "temperature").Exists() { + if isOpenAICodexSamplingUnsupportedModel(upstreamModel) && gjson.GetBytes(body, "temperature").Exists() { markPatchDelete("temperature") } if upstreamModel != billingModel { diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index b17029a82af0..7f88c2299b4a 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -147,9 +147,12 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( } responsesReq.Model = upstreamModel - if isOpenAICodexReasoningGPTModel(upstreamModel) { + if isOpenAICodexSamplingUnsupportedModel(upstreamModel) { responsesReq.Temperature = nil responsesReq.TopP = nil + } else { + responsesReq.Temperature = anthropicReq.Temperature + responsesReq.TopP = anthropicReq.TopP } if responsesReq.Reasoning != nil { responsesReq.Reasoning.Effort = openAICompatAnthropicReasoningEffort(&anthropicReq, upstreamModel, responsesReq.Reasoning.Effort) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 5069d3f608c7..d3e7df3cca38 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -1219,7 +1219,8 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, boo changed = true } - for _, field := range openAICodexOAuthUnsupportedFields { + model := strings.TrimSpace(gjson.GetBytes(normalized, "model").String()) + for _, field := range openAICodexOAuthUnsupportedFieldsForModel(model) { if value := gjson.GetBytes(normalized, field); !value.Exists() { continue } diff --git a/backend/internal/service/openai_model_alias_test.go b/backend/internal/service/openai_model_alias_test.go index 4cb27206c9fc..bd7bfa448f57 100644 --- a/backend/internal/service/openai_model_alias_test.go +++ b/backend/internal/service/openai_model_alias_test.go @@ -34,3 +34,23 @@ func TestUsageBillingModelCandidates_BareGPT56IncludesSol(t *testing.T) { usageBillingModelCandidates("openai/gpt-5.6"), ) } + +func TestSupportsOpenAICodexSamplingParametersMatchesOnlyGPT56Family(t *testing.T) { + tests := map[string]bool{ + "gpt-5.6": true, + "gpt-5.6-sol": true, + "openai/GPT_5.6_TERRA": true, + "gpt-5.6-cyber": true, + "gpt-5.6-2026-07-09": true, + "gpt-5.5": false, + "gpt-5.60": false, + "gpt-4.1": false, + "third-party/gpt-5.6ish": false, + } + + for model, expected := range tests { + t.Run(model, func(t *testing.T) { + require.Equal(t, expected, supportsOpenAICodexSamplingParameters(model)) + }) + } +} diff --git a/backend/internal/service/openai_passthrough_normalization_test.go b/backend/internal/service/openai_passthrough_normalization_test.go index 726e5e610f65..95d4b3e53ff4 100644 --- a/backend/internal/service/openai_passthrough_normalization_test.go +++ b/backend/internal/service/openai_passthrough_normalization_test.go @@ -31,6 +31,16 @@ func TestNormalizeOpenAIPassthroughOAuthBody_RemovesSamplingParameters(t *testin require.False(t, gjson.GetBytes(normalized, "top_p").Exists()) } +func TestNormalizeOpenAIPassthroughOAuthBody_PreservesGPT56SamplingParameters(t *testing.T) { + body := []byte(`{"model":"gpt-5.6-sol","temperature":0.35,"top_p":0.75,"input":"hello"}`) + + normalized, _, err := normalizeOpenAIPassthroughOAuthBody(body, false) + + require.NoError(t, err) + require.InDelta(t, 0.35, gjson.GetBytes(normalized, "temperature").Float(), 1e-9) + require.InDelta(t, 0.75, gjson.GetBytes(normalized, "top_p").Float(), 1e-9) +} + func TestNormalizeOpenAIPassthroughOAuthBody_NormalizesCompatibilityFields(t *testing.T) { body := []byte(`{"model":"gpt-5.5","prompt":"hello","commands":["unsupported"],"truncation":"auto","stop_sequences":["END"],"chat_template_kwargs":{"enable_thinking":true}}`) diff --git a/backend/internal/service/openai_responses_rejected_field_retry.go b/backend/internal/service/openai_responses_rejected_field_retry.go index f70bdd89acde..7bbeab806c5e 100644 --- a/backend/internal/service/openai_responses_rejected_field_retry.go +++ b/backend/internal/service/openai_responses_rejected_field_retry.go @@ -21,7 +21,7 @@ var ( openAIResponsesRejectedStatusParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.status$`) openAIResponsesRejectedContentParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.content$`) openAIResponsesRejectedCacheParamPattern = regexp.MustCompile(`(?i)^input\[(\d+)\]\.prompt_cache_breakpoint$`) - openAIResponsesRejectedMessageParamPattern = regexp.MustCompile(`(?i)(?:unknown|unsupported)[ _-]+parameter\s*(?::|=|is)?\s*["']?(max_output_tokens|truncation|input\[\d+\]\.(?:namespace|status))(?:["']|\b)`) + openAIResponsesRejectedMessageParamPattern = regexp.MustCompile(`(?i)(?:unknown|unsupported)[ _-]+parameter\s*(?::|=|is)?\s*["']?(max_output_tokens|truncation|temperature|top_p|input\[\d+\]\.(?:namespace|status))(?:["']|\b)`) openAIResponsesInvalidTypeMessageParamPattern = regexp.MustCompile(`(?i)invalid[ _-]+type\s+for\s+["']?(input\[\d+\]\.content)(?:["']|\b)[^\n]*\b(?:got|received)\s+null\b`) openAIResponsesMaxZeroContentMessagePattern = regexp.MustCompile(`(?i)invalid\s+["']?(input\[\d+\]\.content)["']?\s*:\s*array too long\.[^\n]*maximum length 0\b`) openAIResponsesCacheModelRejectionPattern = regexp.MustCompile(`(?i)["']?(prompt_cache_breakpoint|input\[\d+\]\.prompt_cache_breakpoint)["']?\s+is\s+not\s+supported\s+on\s+this\s+model\b`) @@ -177,6 +177,13 @@ func normalizeOpenAIResponsesRejectedFieldRetryBody(statusCode int, body, respon } return retryBody, "truncation parameter rejection", true, nil } + if (param == "temperature" || param == "top_p") && gjson.GetBytes(body, param).Exists() { + retryBody, err := sjson.DeleteBytes(body, param) + if err != nil { + return nil, "", false, fmt.Errorf("delete rejected %s: %w", param, err) + } + return retryBody, param + " parameter rejection", true, nil + } } messageContentParam := openAIResponsesInvalidTypeParamFromMessage(message) diff --git a/backend/internal/service/openai_responses_rejected_field_retry_test.go b/backend/internal/service/openai_responses_rejected_field_retry_test.go index 4ed3e3ba2204..b7a061133b8b 100644 --- a/backend/internal/service/openai_responses_rejected_field_retry_test.go +++ b/backend/internal/service/openai_responses_rejected_field_retry_test.go @@ -101,6 +101,30 @@ func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsAmbiguousErrors(t } } +func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRemovesRejectedSamplingParameter(t *testing.T) { + body := []byte(`{"model":"gpt-5.6-terra","temperature":0.35,"top_p":0.75,"input":[{"content":{"temperature":"keep","top_p":"keep"}}]}`) + tests := []struct { + param string + code string + }{ + {param: "temperature", code: "unsupported_parameter"}, + {param: "top_p", code: "unknown_parameter"}, + } + + for _, tt := range tests { + t.Run(tt.param, func(t *testing.T) { + responseBody := []byte(fmt.Sprintf(`{"error":{"code":%q,"message":"Unsupported parameter: %s","param":%q}}`, tt.code, tt.param, tt.param)) + retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody) + + require.NoError(t, err) + require.True(t, changed) + require.Contains(t, reason, tt.param) + require.False(t, gjson.GetBytes(retryBody, tt.param).Exists()) + require.Equal(t, "keep", gjson.GetBytes(retryBody, "input.0.content."+tt.param).String()) + }) + } +} + func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRepairsAutomationMissingRootType(t *testing.T) { body := []byte(`{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[{"type":"object"},{"type":"object","properties":{}}]}}]}`) responseBody := []byte(`{"error":{"code":"invalid_function_parameters","message":"Invalid schema for function 'automation_update': got 'type: \"None\"'.","param":"tools[0].parameters"}}`) @@ -623,6 +647,28 @@ func TestOpenAIGatewayService_RetriesExplicitMaxOutputTokensRejection(t *testing require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input.0.content.max_output_tokens").String()) } +func TestOpenAIGatewayService_RetriesExplicitTemperatureRejection(t *testing.T) { + body := []byte(`{"model":"gpt-5.6-terra","stream":false,"temperature":0.35,"input":[{"type":"message","role":"user","content":{"temperature":"keep"}}]}`) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: temperature","param":"temperature"}}`), + newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`), + }} + + result, err := newOpenAIRejectedFieldTestService(upstream).Forward( + context.Background(), + newOpenAIRejectedFieldTestContext(body), + newOpenAIRejectedFieldTestAccount(), + body, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 2) + require.InDelta(t, 0.35, gjson.GetBytes(upstream.bodies[0], "temperature").Float(), 1e-9) + require.False(t, gjson.GetBytes(upstream.bodies[1], "temperature").Exists()) + require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input.0.content.temperature").String()) +} + func TestOpenAIGatewayService_ComposesProactiveNamespaceStripWithRejectedFieldRetry(t *testing.T) { body := []byte(`{"model":"gpt-5.5","stream":false,"max_output_tokens":2048,"input":[{"type":"function_call","name":"first","namespace":"remove-first","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove-second","input":"{}"}]}`) upstream := &httpUpstreamRecorder{responses: []*http.Response{ From f7327a479f808c64d0a9520b37c158ab14c58010 Mon Sep 17 00:00:00 2001 From: miaoheng <2020745908@qq.com> Date: Wed, 2 Sep 2026 13:23:45 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=E9=AA=8C=E8=AF=81=20GPT-5.6=20?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E9=9B=B6=E6=B8=A9=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/openai_codex_transform_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go index 5b4c9be04fab..7d6a000cdd32 100644 --- a/backend/internal/service/openai_codex_transform_test.go +++ b/backend/internal/service/openai_codex_transform_test.go @@ -1789,14 +1789,15 @@ func TestApplyCodexOAuthTransform_StripsChatGPTInternalUnsupportedFields(t *test func TestApplyCodexOAuthTransform_PreservesGPT56SamplingParameters(t *testing.T) { reqBody := map[string]any{ "model": "gpt-5.6-terra", - "temperature": 0.35, + "temperature": 0.0, "top_p": 0.75, "input": []any{map[string]any{"role": "user", "content": "hi"}}, } applyCodexOAuthTransform(reqBody, false, false) - require.InDelta(t, 0.35, reqBody["temperature"], 1e-9) + require.Contains(t, reqBody, "temperature") + require.Zero(t, reqBody["temperature"]) require.InDelta(t, 0.75, reqBody["top_p"], 1e-9) }