From c66e700f054ea7ba36312aa0cf138cdab8b1e29d Mon Sep 17 00:00:00 2001
From: shaw
Date: Mon, 31 Aug 2026 14:41:21 +0800
Subject: [PATCH 1/3] fix(openai): move TTFT metric mode to admin settings
---
.../internal/handler/admin/setting_handler.go | 1 +
.../handler/admin/setting_handler_audit.go | 3 ++
.../handler/admin/setting_handler_update.go | 7 +++
backend/internal/handler/dto/settings.go | 1 +
backend/internal/service/domain_constants.go | 4 ++
.../service/openai_gateway_passthrough.go | 44 ++++++++++++++++++-
.../openai_gateway_response_handling.go | 18 ++++----
.../service/openai_visible_ttft_test.go | 29 ++++++++++--
.../service/setting_gateway_runtime.go | 16 ++++++-
backend/internal/service/setting_parse.go | 8 ++++
backend/internal/service/setting_update.go | 6 +++
backend/internal/service/settings_view.go | 1 +
frontend/src/api/admin/settings.ts | 2 +
.../src/i18n/locales/en/admin/settings.ts | 4 ++
.../src/i18n/locales/zh/admin/settings.ts | 4 ++
frontend/src/views/admin/SettingsView.vue | 29 ++++++++++++
.../admin/__tests__/SettingsView.spec.ts | 22 ++++++++++
17 files changed, 186 insertions(+), 13 deletions(-)
diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go
index 4c8cc145dc..e8b2c49a68 100644
--- a/backend/internal/handler/admin/setting_handler.go
+++ b/backend/internal/handler/admin/setting_handler.go
@@ -288,6 +288,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
MaxClaudeCodeVersion: settings.MaxClaudeCodeVersion,
AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling,
BackendModeEnabled: settings.BackendModeEnabled,
+ OpenAITTFTMode: settings.OpenAITTFTMode,
EnableFingerprintUnification: settings.EnableFingerprintUnification,
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
EnableCCHSigning: settings.EnableCCHSigning,
diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go
index d6110f43d5..66810e70b4 100644
--- a/backend/internal/handler/admin/setting_handler_audit.go
+++ b/backend/internal/handler/admin/setting_handler_audit.go
@@ -452,6 +452,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.EnableFingerprintUnification != after.EnableFingerprintUnification {
changed = append(changed, "enable_fingerprint_unification")
}
+ if before.OpenAITTFTMode != after.OpenAITTFTMode {
+ changed = append(changed, "openai_ttft_mode")
+ }
if before.EnableMetadataPassthrough != after.EnableMetadataPassthrough {
changed = append(changed, "enable_metadata_passthrough")
}
diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go
index b2c001fe73..b4840f0ba6 100644
--- a/backend/internal/handler/admin/setting_handler_update.go
+++ b/backend/internal/handler/admin/setting_handler_update.go
@@ -243,6 +243,7 @@ type UpdateSettingsRequest struct {
BackendModeEnabled bool `json:"backend_mode_enabled"`
// Gateway forwarding behavior
+ OpenAITTFTMode *string `json:"openai_ttft_mode"`
EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
EnableCCHSigning *bool `json:"enable_cch_signing"`
@@ -1683,6 +1684,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.EnableFingerprintUnification
}(),
+ OpenAITTFTMode: func() string {
+ if req.OpenAITTFTMode != nil {
+ return *req.OpenAITTFTMode
+ }
+ return previousSettings.OpenAITTFTMode
+ }(),
EnableMetadataPassthrough: func() bool {
if req.EnableMetadataPassthrough != nil {
return *req.EnableMetadataPassthrough
diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go
index a51601ced2..ff9797d86f 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -201,6 +201,7 @@ type SystemSettings struct {
BackendModeEnabled bool `json:"backend_mode_enabled"`
// Gateway forwarding behavior
+ OpenAITTFTMode string `json:"openai_ttft_mode"`
EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
EnableCCHSigning bool `json:"enable_cch_signing"`
diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go
index ebcc077c83..50de7a05d1 100644
--- a/backend/internal/service/domain_constants.go
+++ b/backend/internal/service/domain_constants.go
@@ -620,6 +620,10 @@ const (
SettingKeyBackendModeEnabled = "backend_mode_enabled"
// Gateway Forwarding Behavior
+ // SettingKeyOpenAITTFTMode 控制 first_token_ms 的统计口径。
+ SettingKeyOpenAITTFTMode = "openai_ttft_mode"
+ OpenAITTFTModeSemantic = "semantic"
+ OpenAITTFTModeVisible = "visible"
// SettingKeyEnableFingerprintUnification 是否统一 OAuth 账号的 X-Stainless-* 指纹头(默认 true)
SettingKeyEnableFingerprintUnification = "enable_fingerprint_unification"
// SettingKeyEnableMetadataPassthrough 是否透传客户端原始 metadata.user_id(默认 false)
diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go
index 746473ac29..4bd4ba26c1 100644
--- a/backend/internal/service/openai_gateway_passthrough.go
+++ b/backend/internal/service/openai_gateway_passthrough.go
@@ -1231,6 +1231,47 @@ func openAIStreamDataStartsVisibleOutput(data, eventType string) bool {
return false
}
+// openAIStreamDataStartsSemanticTTFT 保留 900194fab 之前的 first_token_ms
+// 口径:跳过 Responses preamble 后,首个语义 SSE 事件即视为首 token。
+func openAIStreamDataStartsSemanticTTFT(data, eventType string) bool {
+ trimmed := strings.TrimSpace(data)
+ if trimmed == "" || trimmed == "[DONE]" {
+ return false
+ }
+ eventType = strings.TrimSpace(eventType)
+ if eventType == "" && gjson.Valid(trimmed) {
+ eventType = strings.TrimSpace(gjson.Get(trimmed, "type").String())
+ }
+ switch eventType {
+ case "response.failed":
+ return false
+ case "error":
+ payload := []byte(trimmed)
+ return !openAIStreamFailedEventShouldFailover(payload, extractOpenAISSEErrorMessage(payload))
+ default:
+ return !openAIStreamEventIsPreamble(eventType)
+ }
+}
+
+func (s *OpenAIGatewayService) openAITTFTMode(ctx context.Context) string {
+ mode := OpenAITTFTModeSemantic
+ if s != nil && s.settingService != nil {
+ mode = s.settingService.GetOpenAITTFTMode(ctx)
+ } else if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
+ if cached.expiresAt == 0 || time.Now().UnixNano() < cached.expiresAt {
+ mode = normalizeOpenAITTFTMode(cached.openAITTFTMode)
+ }
+ }
+ return normalizeOpenAITTFTMode(mode)
+}
+
+func openAIStreamDataStartsTTFT(data, eventType string, forceOutput bool, mode string) bool {
+ if mode == OpenAITTFTModeVisible {
+ return openAIStreamDataStartsVisibleOutput(data, eventType)
+ }
+ return forceOutput || openAIStreamDataStartsSemanticTTFT(data, eventType)
+}
+
// openAIStreamFailedEventErrorCode 提取流内 failed 事件的错误码(小写),
// 兼容 response.failed 的嵌套形态与裸 error 形态。
func openAIStreamFailedEventErrorCode(payload []byte) string {
@@ -1806,6 +1847,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
imageCounter := newOpenAIImageOutputCounter()
var firstTokenMs *int
responseID := ""
+ ttftMode := s.openAITTFTMode(ctx)
clientDisconnected := false
sawDone := false
sawTerminalEvent := false
@@ -2096,7 +2138,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
openAIResponsesCompletedEventIsEmpty(dataBytes, usage) {
return resultWithUsage(), newOpenAIResponsesEmptyCompletedFailoverError(c, account, upstreamRequestID)
}
- if firstTokenMs == nil && openAIStreamDataStartsVisibleOutput(trimmedData, eventType) {
+ if firstTokenMs == nil && openAIStreamDataStartsTTFT(trimmedData, eventType, forceFlushFailedEvent, ttftMode) {
ms := int(time.Since(startTime).Milliseconds())
firstTokenMs = &ms
}
diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go
index 7e3dc38020..104f319dc5 100644
--- a/backend/internal/service/openai_gateway_response_handling.go
+++ b/backend/internal/service/openai_gateway_response_handling.go
@@ -119,6 +119,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
maxLineSize = s.cfg.Gateway.MaxLineSize
}
var firstTokenMs *int
+ ttftMode := s.openAITTFTMode(ctx)
firstOutputProgressObserved := false
bufferedWriter := bufio.NewWriterSize(w, 4*1024)
var firstOutputStage *openAIFirstOutputStage
@@ -263,7 +264,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
pendingSSEEventType := ""
eventInProgress := false
eventStartsClientOutput := false
- eventStartsVisibleOutput := false
+ eventStartsTTFTOutput := false
eventShouldFlush := false
handlePendingWriteError := func(err error) {
if firstOutputStage != nil && !firstOutputStage.closed {
@@ -283,7 +284,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
}
completeGuardedEvent := func(queueDrained bool) {
completedProgressEvent := eventStartsClientOutput
- completedVisibleEvent := eventStartsVisibleOutput
+ completedTTFTEvent := eventStartsTTFTOutput
shouldFlush := eventShouldFlush || (queueDrained && clientOutputStarted)
eventInProgress = false
if !clientDisconnected {
@@ -305,12 +306,12 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
firstOutputProgressObserved = true
stopFirstOutputTimer()
}
- if completedVisibleEvent && firstTokenMs == nil {
+ if completedTTFTEvent && firstTokenMs == nil {
ms := int(time.Since(startTime).Milliseconds())
firstTokenMs = &ms
}
eventStartsClientOutput = false
- eventStartsVisibleOutput = false
+ eventStartsTTFTOutput = false
eventShouldFlush = false
}
sendErrorEvent := func(reason string) {
@@ -662,9 +663,10 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
}
startsClientOutput := forceFlushFailedEvent || openAIStreamDataStartsClientOutput(data, eventType)
startsVisibleOutput := openAIStreamDataStartsVisibleOutput(data, eventType)
+ startsTTFTOutput := openAIStreamDataStartsTTFT(data, eventType, forceFlushFailedEvent, ttftMode)
if stageFirstOutput {
eventStartsClientOutput = eventStartsClientOutput || startsClientOutput
- eventStartsVisibleOutput = eventStartsVisibleOutput || startsVisibleOutput
+ eventStartsTTFTOutput = eventStartsTTFTOutput || startsTTFTOutput
if startsClientOutput {
firstOutputScanGuard.Store(false)
}
@@ -703,7 +705,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
}
// Record first token time
- if !guardFirstOutput && firstTokenMs == nil && startsVisibleOutput {
+ if !guardFirstOutput && firstTokenMs == nil && startsTTFTOutput {
ms := int(time.Since(startTime).Milliseconds())
firstTokenMs = &ms
stopFirstOutputTimer()
@@ -720,7 +722,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
terminalFailurePending = false
eventInProgress = false
eventStartsClientOutput = false
- eventStartsVisibleOutput = false
+ eventStartsTTFTOutput = false
eventShouldFlush = false
return
}
@@ -728,7 +730,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
terminalFailurePending = false
eventInProgress = false
eventStartsClientOutput = false
- eventStartsVisibleOutput = false
+ eventStartsTTFTOutput = false
eventShouldFlush = false
return
}
diff --git a/backend/internal/service/openai_visible_ttft_test.go b/backend/internal/service/openai_visible_ttft_test.go
index 4d811dfbb3..1dae291cfb 100644
--- a/backend/internal/service/openai_visible_ttft_test.go
+++ b/backend/internal/service/openai_visible_ttft_test.go
@@ -48,7 +48,7 @@ func TestOpenAIResponsesTTFTStartsAtVisibleOutput(t *testing.T) {
name = "passthrough"
}
t.Run(name, func(t *testing.T) {
- result := runSyntheticVisibleTTFTStream(t, passthrough, 120*time.Millisecond, 0,
+ result := runSyntheticVisibleTTFTStream(t, passthrough, 120*time.Millisecond, 0, OpenAITTFTModeVisible,
`{"type":"response.output_text.delta","delta":"test output"}`)
require.NotNil(t, result.firstTokenMs)
require.GreaterOrEqual(t, *result.firstTokenMs, 100)
@@ -63,7 +63,7 @@ func TestOpenAIResponsesTTFTStartsAtCompletedImage(t *testing.T) {
name = "passthrough"
}
t.Run(name, func(t *testing.T) {
- result := runSyntheticVisibleTTFTStream(t, passthrough, 120*time.Millisecond, 0,
+ result := runSyntheticVisibleTTFTStream(t, passthrough, 120*time.Millisecond, 0, OpenAITTFTModeVisible,
`{"type":"response.output_item.done","item":{"id":"item_test","type":"image_generation_call","result":"dGVzdA=="}}`)
require.NotNil(t, result.firstTokenMs)
require.GreaterOrEqual(t, *result.firstTokenMs, 100)
@@ -105,9 +105,32 @@ func TestOpenAINativeMetadataDoesNotDisarmFirstOutputTimeout(t *testing.T) {
}
}
-func runSyntheticVisibleTTFTStream(t *testing.T, passthrough bool, visibleDelay time.Duration, timeoutSeconds int, visibleEvent string) *openaiStreamingResult {
+func TestOpenAIResponsesTTFTDefaultsToSemanticOutput(t *testing.T) {
+ for _, passthrough := range []bool{false, true} {
+ name := "native"
+ if passthrough {
+ name = "passthrough"
+ }
+ t.Run(name, func(t *testing.T) {
+ result := runSyntheticVisibleTTFTStream(t, passthrough, 120*time.Millisecond, 0, "",
+ `{"type":"response.output_text.delta","delta":"test output"}`)
+ require.NotNil(t, result.firstTokenMs)
+ require.Less(t, *result.firstTokenMs, 100)
+ })
+ }
+}
+
+func runSyntheticVisibleTTFTStream(t *testing.T, passthrough bool, visibleDelay time.Duration, timeoutSeconds int, ttftMode string, visibleEvent string) *openaiStreamingResult {
t.Helper()
gin.SetMode(gin.TestMode)
+ mode := ttftMode
+ if mode == "" {
+ mode = OpenAITTFTModeSemantic
+ }
+ gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{openAITTFTMode: mode, expiresAt: time.Now().Add(time.Minute).UnixNano()})
+ t.Cleanup(func() {
+ gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{openAITTFTMode: OpenAITTFTModeSemantic, expiresAt: time.Now().Add(time.Minute).UnixNano()})
+ })
svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{
MaxLineSize: defaultMaxLineSize,
OpenAIFirstOutputTimeoutSeconds: timeoutSeconds,
diff --git a/backend/internal/service/setting_gateway_runtime.go b/backend/internal/service/setting_gateway_runtime.go
index e7eda7c30e..40fccd8fe1 100644
--- a/backend/internal/service/setting_gateway_runtime.go
+++ b/backend/internal/service/setting_gateway_runtime.go
@@ -53,6 +53,7 @@ const backendModeDBTimeout = 5 * time.Second
// cachedGatewayForwardingSettings 缓存网关转发行为设置(进程内缓存,60s TTL)
type cachedGatewayForwardingSettings struct {
+ openAITTFTMode string
fingerprintUnification bool
metadataPassthrough bool
cchSigning bool
@@ -736,6 +737,7 @@ func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool {
}
type gatewayForwardingSettingsResult struct {
+ openAITTFTMode string
fp, mp, cch, claudeOAuthSystemPromptInjection, cacheTTL1h, rewriteMessageCacheControl bool
clientDatelineNormalization bool
claudeOAuthSystemPrompt, claudeOAuthSystemPromptBlocks string
@@ -745,6 +747,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return gatewayForwardingSettingsResult{
+ openAITTFTMode: cached.openAITTFTMode,
fp: cached.fingerprintUnification,
mp: cached.metadataPassthrough,
cch: cached.cchSigning,
@@ -761,6 +764,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return gatewayForwardingSettingsResult{
+ openAITTFTMode: cached.openAITTFTMode,
fp: cached.fingerprintUnification,
mp: cached.metadataPassthrough,
cch: cached.cchSigning,
@@ -776,6 +780,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout)
defer cancel()
values, err := s.settingRepo.GetMultiple(dbCtx, []string{
+ SettingKeyOpenAITTFTMode,
SettingKeyEnableFingerprintUnification,
SettingKeyEnableMetadataPassthrough,
SettingKeyEnableCCHSigning,
@@ -789,6 +794,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
if err != nil {
slog.Warn("failed to get gateway forwarding settings", "error", err)
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
+ openAITTFTMode: OpenAITTFTModeSemantic,
fingerprintUnification: true,
metadataPassthrough: false,
cchSigning: false,
@@ -798,8 +804,9 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
clientDatelineNormalization: true,
expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(),
})
- return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), clientDatelineNormalization: true}, nil
+ return gatewayForwardingSettingsResult{openAITTFTMode: OpenAITTFTModeSemantic, fp: true, claudeOAuthSystemPromptInjection: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), clientDatelineNormalization: true}, nil
}
+ ttftMode := normalizeOpenAITTFTMode(values[SettingKeyOpenAITTFTMode])
fp := true
if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" {
fp = v == "true"
@@ -822,6 +829,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
clientDatelineNormalization = v == "true"
}
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
+ openAITTFTMode: ttftMode,
fingerprintUnification: fp,
metadataPassthrough: mp,
cchSigning: cch,
@@ -834,6 +842,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
})
return gatewayForwardingSettingsResult{
+ openAITTFTMode: ttftMode,
fp: fp,
mp: mp,
cch: cch,
@@ -851,6 +860,11 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, clientDatelineNormalization: true}
}
+// GetOpenAITTFTMode 返回 Responses first_token_ms 的统计口径。
+func (s *SettingService) GetOpenAITTFTMode(ctx context.Context) string {
+ return s.getGatewayForwardingSettingsCached(ctx).openAITTFTMode
+}
+
// GetGatewayForwardingSettings returns cached gateway forwarding settings.
// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path.
// Returns (fingerprintUnification, metadataPassthrough, cchSigning).
diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go
index a1a0c218e3..36e7706dda 100644
--- a/backend/internal/service/setting_parse.go
+++ b/backend/internal/service/setting_parse.go
@@ -846,6 +846,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
// Gateway forwarding behavior (defaults: fingerprint=true, metadata_passthrough=false,
// cch_signing=false, claude_oauth_system_prompt_injection=true)
+ result.OpenAITTFTMode = normalizeOpenAITTFTMode(settings[SettingKeyOpenAITTFTMode])
if v, ok := settings[SettingKeyEnableFingerprintUnification]; ok && v != "" {
result.EnableFingerprintUnification = v == "true"
} else {
@@ -979,6 +980,13 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
return result
}
+func normalizeOpenAITTFTMode(mode string) string {
+ if strings.EqualFold(strings.TrimSpace(mode), OpenAITTFTModeVisible) {
+ return OpenAITTFTModeVisible
+ }
+ return OpenAITTFTModeSemantic
+}
+
func clampAffiliateRebateRate(value float64) float64 {
if math.IsNaN(value) || math.IsInf(value, 0) {
return AffiliateRebateRateDefault
diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go
index 7a421fde75..8928a63534 100644
--- a/backend/internal/service/setting_update.go
+++ b/backend/internal/service/setting_update.go
@@ -460,6 +460,11 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyBackendModeEnabled] = strconv.FormatBool(settings.BackendModeEnabled)
// Gateway forwarding behavior
+ mode := normalizeOpenAITTFTMode(settings.OpenAITTFTMode)
+ if strings.TrimSpace(settings.OpenAITTFTMode) != "" && strings.ToLower(strings.TrimSpace(settings.OpenAITTFTMode)) != OpenAITTFTModeSemantic && strings.ToLower(strings.TrimSpace(settings.OpenAITTFTMode)) != OpenAITTFTModeVisible {
+ return nil, fmt.Errorf("%s must be one of: %s/%s", SettingKeyOpenAITTFTMode, OpenAITTFTModeSemantic, OpenAITTFTModeVisible)
+ }
+ updates[SettingKeyOpenAITTFTMode] = mode
updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification)
updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough)
updates[SettingKeyEnableCCHSigning] = strconv.FormatBool(settings.EnableCCHSigning)
@@ -697,6 +702,7 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) {
})
gatewayForwardingSF.Forget("gateway_forwarding")
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
+ openAITTFTMode: normalizeOpenAITTFTMode(settings.OpenAITTFTMode),
fingerprintUnification: settings.EnableFingerprintUnification,
metadataPassthrough: settings.EnableMetadataPassthrough,
cchSigning: settings.EnableCCHSigning,
diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go
index df5e39d850..002560e822 100644
--- a/backend/internal/service/settings_view.go
+++ b/backend/internal/service/settings_view.go
@@ -228,6 +228,7 @@ type SystemSettings struct {
BackendModeEnabled bool
// Gateway forwarding behavior
+ OpenAITTFTMode string // Responses first_token_ms 统计口径(默认 semantic)
EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true)
EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false)
EnableCCHSigning bool // 已废弃 no-op:新版 CLI 取消 cch 签名后网关不再注入/签名 cch,开关无效果
diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts
index 4ceb75cbd9..29e1550c3b 100644
--- a/frontend/src/api/admin/settings.ts
+++ b/frontend/src/api/admin/settings.ts
@@ -620,6 +620,7 @@ export interface SystemSettings {
allow_ungrouped_key_scheduling: boolean;
// Gateway forwarding behavior
+ openai_ttft_mode: string;
enable_fingerprint_unification: boolean;
enable_metadata_passthrough: boolean;
enable_cch_signing: boolean;
@@ -935,6 +936,7 @@ export interface UpdateSettingsRequest {
min_claude_code_version?: string;
max_claude_code_version?: string;
allow_ungrouped_key_scheduling?: boolean;
+ openai_ttft_mode?: string;
enable_fingerprint_unification?: boolean;
enable_metadata_passthrough?: boolean;
enable_cch_signing?: boolean;
diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts
index 4c23f239ae..79125850c4 100644
--- a/frontend/src/i18n/locales/en/admin/settings.ts
+++ b/frontend/src/i18n/locales/en/admin/settings.ts
@@ -473,6 +473,10 @@ export default {
grokBaseURLModeUSEast1: 'Regional API (us-east-1)',
grokBaseURLModeUSWest2: 'Regional API (us-west-2)',
grokBaseURLModeEUWest1: 'Regional API (eu-west-1)',
+ openaiTTFTMode: 'OpenAI Responses first-token metric',
+ openaiTTFTModeSemantic: 'Legacy-compatible (semantic event)',
+ openaiTTFTModeVisible: 'Actual visible output',
+ openaiTTFTModeHint: 'The default records first_token_ms at the first non-preamble semantic event. Actual visible output records it only when non-empty text, tool arguments, or image content arrives.',
fingerprintUnification: 'Fingerprint Unification',
fingerprintUnificationHint: 'Unify X-Stainless-* headers across users sharing the same OAuth account. Disabling passes through each client\'s original headers.',
metadataPassthrough: 'Metadata Passthrough',
diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts
index fb5371e8c5..07898e7a94 100644
--- a/frontend/src/i18n/locales/zh/admin/settings.ts
+++ b/frontend/src/i18n/locales/zh/admin/settings.ts
@@ -466,6 +466,10 @@ export default {
grokBaseURLModeUSEast1: '区域 API(us-east-1)',
grokBaseURLModeUSWest2: '区域 API(us-west-2)',
grokBaseURLModeEUWest1: '区域 API(eu-west-1)',
+ openaiTTFTMode: 'OpenAI Responses 首 token 统计口径',
+ openaiTTFTModeSemantic: '历史兼容(语义事件)',
+ openaiTTFTModeVisible: '真实可见输出',
+ openaiTTFTModeHint: '默认使用历史兼容口径,首个非预置语义事件即记录 first_token_ms。选择真实可见输出后,仅在首个非空文本、工具参数或图片内容到达时记录。',
fingerprintUnification: '指纹统一化',
fingerprintUnificationHint: '统一共享同一 OAuth 账号的用户的 X-Stainless-* 请求头。关闭后透传客户端原始请求头。',
metadataPassthrough: 'Metadata 透传',
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index 7d68bcb87b..8f6848af96 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -5268,6 +5268,32 @@
+
+
+
+
+
+ {{ t("admin.settings.gatewayForwarding.openaiTTFTModeHint") }}
+
+
+
@@ -9735,6 +9761,7 @@ const form = reactive({
openai_advanced_scheduler_weight_previous_response: "",
openai_advanced_scheduler_weight_session_sticky: "",
// Gateway forwarding behavior
+ openai_ttft_mode: "semantic",
enable_fingerprint_unification: true,
enable_metadata_passthrough: false,
enable_cch_signing: false,
@@ -11308,6 +11335,8 @@ async function saveSettings() {
min_claude_code_version: form.min_claude_code_version,
max_claude_code_version: form.max_claude_code_version,
allow_ungrouped_key_scheduling: form.allow_ungrouped_key_scheduling,
+ openai_ttft_mode:
+ form.openai_ttft_mode === "visible" ? "visible" : "semantic",
enable_fingerprint_unification: form.enable_fingerprint_unification,
enable_metadata_passthrough: form.enable_metadata_passthrough,
enable_cch_signing: form.enable_cch_signing,
diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts
index bf6fe5e68d..70fc723edb 100644
--- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts
+++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts
@@ -464,6 +464,7 @@ const baseSettingsResponse = {
min_claude_code_version: "",
max_claude_code_version: "",
allow_ungrouped_key_scheduling: false,
+ openai_ttft_mode: "semantic",
enable_fingerprint_unification: true,
enable_metadata_passthrough: false,
enable_cch_signing: false,
@@ -1363,6 +1364,27 @@ describe("admin SettingsView payment visible method controls", () => {
expect(payload.grok_cross_client_model_map_enabled).toBe(false);
});
+ it("loads and saves the OpenAI Responses first-token metric mode", async () => {
+ getSettings.mockResolvedValueOnce({
+ ...baseSettingsResponse,
+ openai_ttft_mode: "visible",
+ });
+ const wrapper = mountView();
+
+ await flushPromises();
+ await openGatewayTab(wrapper);
+
+ const modeSelect = wrapper.get('[data-testid="openai-ttft-mode"]');
+ expect((modeSelect.element as HTMLSelectElement).value).toBe("visible");
+
+ await modeSelect.setValue("semantic");
+ await wrapper.find("form").trigger("submit.prevent");
+ await flushPromises();
+
+ const payload = updateSettings.mock.calls.at(-1)?.[0] as Record;
+ expect(payload.openai_ttft_mode).toBe("semantic");
+ });
+
it("loads fail-safe-off Ollama Cloud usage refresh settings and saves an explicit opt-in", async () => {
const wrapper = mountView();
From 8553c91c1adb05d8124ea22af116aebe38a4cc3c Mon Sep 17 00:00:00 2001
From: shaw
Date: Mon, 31 Aug 2026 15:05:39 +0800
Subject: [PATCH 2/3] test(api): update settings contract fixture
---
backend/internal/server/api_contract_test.go | 2 ++
1 file changed, 2 insertions(+)
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index 3b6212c5ce..c7a16945f0 100644
--- a/backend/internal/server/api_contract_test.go
+++ b/backend/internal/server/api_contract_test.go
@@ -922,6 +922,7 @@ func TestAPIContracts(t *testing.T) {
"payment_visible_method_wxpay_enabled": false,
"openai_low_upstream_rate_priority_enabled": true,
"openai_oauth_scheduling_rate_multiplier": 0.05,
+ "openai_ttft_mode": "semantic",
"openai_advanced_scheduler_enabled": true,
"openai_advanced_scheduler_sticky_weighted_enabled": false,
"openai_advanced_scheduler_subscription_priority_enabled": false,
@@ -1236,6 +1237,7 @@ func TestAPIContracts(t *testing.T) {
"payment_visible_method_wxpay_enabled": false,
"openai_low_upstream_rate_priority_enabled": false,
"openai_oauth_scheduling_rate_multiplier": 1,
+ "openai_ttft_mode": "semantic",
"openai_advanced_scheduler_enabled": false,
"openai_advanced_scheduler_sticky_weighted_enabled": false,
"openai_advanced_scheduler_subscription_priority_enabled": false,
From 0fcec63b685f51cafaff3983dda66a718520c564 Mon Sep 17 00:00:00 2001
From: shaw
Date: Mon, 31 Aug 2026 15:15:44 +0800
Subject: [PATCH 3/3] test(openai): isolate visible TTFT fixture
---
.../service/openai_first_output_timeout_test.go | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/backend/internal/service/openai_first_output_timeout_test.go b/backend/internal/service/openai_first_output_timeout_test.go
index 6783de90d1..4ac568c541 100644
--- a/backend/internal/service/openai_first_output_timeout_test.go
+++ b/backend/internal/service/openai_first_output_timeout_test.go
@@ -403,6 +403,16 @@ func assertOpenAINativeLargeOpenEventTimesOutWithoutLeak(t *testing.T, line stri
}
func TestOpenAINativeFirstOutputEOFDispatchesTerminalEventWithoutBlankLine(t *testing.T) {
+ gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
+ openAITTFTMode: OpenAITTFTModeVisible,
+ expiresAt: time.Now().Add(time.Minute).UnixNano(),
+ })
+ t.Cleanup(func() {
+ gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
+ openAITTFTMode: OpenAITTFTModeSemantic,
+ expiresAt: time.Now().Add(time.Minute).UnixNano(),
+ })
+ })
cfg := &config.Config{Gateway: config.GatewayConfig{
OpenAIFirstOutputTimeoutSeconds: 1,
MaxLineSize: defaultMaxLineSize,