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/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, 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_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, 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();