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)
}