From 1d925b748812ea56a2f13943c8044c0c9064a059 Mon Sep 17 00:00:00 2001 From: hu <187184415@qq.com> Date: Thu, 3 Sep 2026 17:45:05 +0800 Subject: [PATCH] feat(claude): first-token timeout, pre-first-token SSE keepalive and latency logs for the Claude OAuth path Production Claude requests occasionally receive message_start and then nothing for 4-9 minutes (mostly effort xhigh with ~150k context). With the global first_token_timeout_seconds at 0 these attempts held concurrency slots until the client gave up (45 stuck 499s / 215 slot-minutes in 24h), and downstream gateways saw zero bytes, so they timed out and retried, multiplying the load on the account. - ClaudeConfig gains first_token_timeout_seconds (default 120, 0 = follow global, clamped to 600) and stream_keepalive_enabled (default true); Store publishes both, admin GET/PUT round-trips them, Settings UI adds a paired row under the ClaudeCode card. - The /v1/messages attempt loop uses the Claude timeout for Claude OAuth accounts; a native attempt that times out before any visible frame is classified as a first-token timeout outcome (retryable, penalized) instead of a generic stream break. - Claude OAuth streaming attempts activate the existing SSE keepalive so a ": keepalive" comment is written every 15s while waiting for the first visible frame. - Log first-token timeouts, pre-first-token client disconnects and slow (>=60s) first tokens with account/model/effort/wait. --- admin/claude_config.go | 17 ++++ admin/claude_config_test.go | 54 ++++++++++ auth/claude_fingerprint_mode.go | 87 ++++++++++++++++ auth/claude_first_token_timeout_test.go | 81 +++++++++++++++ auth/store.go | 3 + frontend/src/locales/en.json | 4 + frontend/src/locales/zh-TW.json | 4 + frontend/src/locales/zh.json | 4 + frontend/src/pages/Settings.tsx | 34 ++++++- frontend/src/types.ts | 2 + proxy/claude_first_token_timeout.go | 73 ++++++++++++++ proxy/claude_first_token_timeout_test.go | 120 +++++++++++++++++++++++ proxy/handler_anthropic.go | 11 ++- 13 files changed, 490 insertions(+), 4 deletions(-) create mode 100644 auth/claude_first_token_timeout_test.go create mode 100644 proxy/claude_first_token_timeout.go create mode 100644 proxy/claude_first_token_timeout_test.go diff --git a/admin/claude_config.go b/admin/claude_config.go index 9cb0ff05..8f61bede 100644 --- a/admin/claude_config.go +++ b/admin/claude_config.go @@ -22,6 +22,10 @@ type claudeGlobalConfigDTO struct { auth.ClaudeSecurityConfig CLIVersionSyncEnabled *bool `json:"cli_version_sync_enabled"` CLIVersionSyncIntervalHours int `json:"cli_version_sync_interval_hours"` + // FirstTokenTimeoutSeconds:Claude 路径首字超时秒数(缺失=默认 120,0=跟随全局)。 + FirstTokenTimeoutSeconds *int `json:"first_token_timeout_seconds"` + // StreamKeepaliveEnabled:Claude 流式首字前是否发 SSE 保活注释(缺失=开启)。 + StreamKeepaliveEnabled *bool `json:"stream_keepalive_enabled"` // 以下三项只读;PUT 忽略。 SyncedCLIVersion string `json:"synced_cli_version"` BuiltinCLIVersion string `json:"builtin_cli_version"` @@ -39,6 +43,8 @@ func (h *Handler) GetClaudeConfig(c *gin.Context) { ClaudeSecurityConfig: security, CLIVersionSyncEnabled: boolPtr(h.store.ClaudeCLIVersionSyncEnabled()), CLIVersionSyncIntervalHours: h.store.ClaudeCLIVersionSyncIntervalHours(), + FirstTokenTimeoutSeconds: claudeIntPtr(h.store.ClaudeFirstTokenTimeoutSeconds()), + StreamKeepaliveEnabled: boolPtr(h.store.ClaudeStreamKeepaliveEnabled()), SyncedCLIVersion: auth.ClaudeSyncedCLIVersion(), BuiltinCLIVersion: auth.BuiltinClaudeCLIVersion, EffectiveCLIVersion: auth.EffectiveClaudeCLIVersion(), @@ -80,6 +86,8 @@ func (h *Handler) UpdateClaudeConfig(c *gin.Context) { security := auth.NormalizeClaudeSecurityConfig(req.ClaudeSecurityConfig) syncEnabled := req.CLIVersionSyncEnabled == nil || *req.CLIVersionSyncEnabled syncInterval := auth.NormalizeClaudeCLIVersionSyncIntervalHours(req.CLIVersionSyncIntervalHours) + firstTokenTimeout := auth.NormalizeClaudeFirstTokenTimeoutSeconds(req.FirstTokenTimeoutSeconds) + streamKeepalive := req.StreamKeepaliveEnabled == nil || *req.StreamKeepaliveEnabled cfg := auth.ClaudeConfig{ FingerprintMode: mode, @@ -89,6 +97,8 @@ func (h *Handler) UpdateClaudeConfig(c *gin.Context) { ClaudeSecurityConfig: security, CLIVersionSyncEnabled: boolPtr(syncEnabled), CLIVersionSyncIntervalHours: syncInterval, + FirstTokenTimeoutSeconds: claudeIntPtr(firstTokenTimeout), + StreamKeepaliveEnabled: boolPtr(streamKeepalive), } raw, err := json.Marshal(cfg) if err != nil { @@ -107,6 +117,8 @@ func (h *Handler) UpdateClaudeConfig(c *gin.Context) { h.store.SetClaudeClientPolicy(clientPolicy) h.store.SetClaudeSecurityConfig(security) h.store.SetClaudeCLIVersionSync(syncEnabled, syncInterval) + h.store.SetClaudeFirstTokenTimeoutSeconds(firstTokenTimeout) + h.store.SetClaudeStreamKeepaliveEnabled(streamKeepalive) c.JSON(http.StatusOK, gin.H{ "message": "已保存 ClaudeCode 全局配置", @@ -126,12 +138,17 @@ func (h *Handler) UpdateClaudeConfig(c *gin.Context) { "max_tool_schema_bytes": security.MaxToolSchemaBytes, "cli_version_sync_enabled": syncEnabled, "cli_version_sync_interval_hours": syncInterval, + "first_token_timeout_seconds": firstTokenTimeout, + "stream_keepalive_enabled": streamKeepalive, }) } // boolPtr 返回指向给定 bool 值的指针,便于构造「显式布尔字段」的 JSON DTO。 func boolPtr(v bool) *bool { return &v } +// claudeIntPtr 返回指向给定 int 值的指针,用于「缺失与显式 0 有别」的 JSON DTO 字段。 +func claudeIntPtr(v int) *int { return &v } + // claudeCLIVersionSyncResponse 在同步结果之上附加一个可选的 warning 字段: // 抓取+持久化成功、但指纹回写部分失败时,仍以 200 响应并携带 warning, // 而不是把整次同步判为失败。 diff --git a/admin/claude_config_test.go b/admin/claude_config_test.go index 0846c14c..93219122 100644 --- a/admin/claude_config_test.go +++ b/admin/claude_config_test.go @@ -220,3 +220,57 @@ func TestClaudeConfigSyncCLIVersion_FetchFailureReturns502(t *testing.T) { t.Fatalf("status = %d, want 502: %s", recorder.Code, recorder.Body.String()) } } + +func TestClaudeConfigFirstTokenTimeoutAndKeepaliveRoundTrip(t *testing.T) { + db := newTestAdminDB(t) + store := auth.NewStore(db, nil, nil) + defer store.Stop() + h := &Handler{store: store, db: db} + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + h.GetClaudeConfig(c) + if got := gjson.GetBytes(recorder.Body.Bytes(), "first_token_timeout_seconds").Int(); got != int64(auth.DefaultClaudeFirstTokenTimeoutSeconds) { + t.Fatalf("default first_token_timeout_seconds = %d, want %d", got, auth.DefaultClaudeFirstTokenTimeoutSeconds) + } + if got := gjson.GetBytes(recorder.Body.Bytes(), "stream_keepalive_enabled"); !got.Exists() || !got.Bool() { + t.Fatalf("stream_keepalive_enabled must default to true, got %s", got.Raw) + } + + recorder = httptest.NewRecorder() + c, _ = gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest("PUT", "/api/admin/settings/claude-config", strings.NewReader(`{"first_token_timeout_seconds":90,"stream_keepalive_enabled":false}`)) + h.UpdateClaudeConfig(c) + if recorder.Code != 200 { + t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String()) + } + if got := gjson.GetBytes(recorder.Body.Bytes(), "first_token_timeout_seconds").Int(); got != 90 { + t.Fatalf("response first_token_timeout_seconds = %d, want 90", got) + } + if store.ClaudeFirstTokenTimeoutSeconds() != 90 || store.ClaudeStreamKeepaliveEnabled() { + t.Fatalf("runtime store not updated: timeout=%d keepalive=%v", store.ClaudeFirstTokenTimeoutSeconds(), store.ClaudeStreamKeepaliveEnabled()) + } + settings, err := db.GetSystemSettings(context.Background()) + if err != nil { + t.Fatal(err) + } + raw := settings.ClaudeConfig + persisted := auth.ParseClaudeConfig(raw) + if persisted.FirstTokenTimeoutSecondsValue() != 90 || persisted.StreamKeepaliveEnabledValue() { + t.Fatalf("persisted config = %s", raw) + } + + // Explicit 0 must persist as 0 (follow global), not be re-defaulted to 120. + recorder = httptest.NewRecorder() + c, _ = gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest("PUT", "/api/admin/settings/claude-config", strings.NewReader(`{"first_token_timeout_seconds":0}`)) + h.UpdateClaudeConfig(c) + if store.ClaudeFirstTokenTimeoutSeconds() != 0 { + t.Fatalf("explicit 0 must disable the Claude timeout, got %d", store.ClaudeFirstTokenTimeoutSeconds()) + } + settings, _ = db.GetSystemSettings(context.Background()) + raw = settings.ClaudeConfig + if auth.ParseClaudeConfig(raw).FirstTokenTimeoutSecondsValue() != 0 { + t.Fatalf("persisted explicit 0 was re-defaulted: %s", raw) + } +} diff --git a/auth/claude_fingerprint_mode.go b/auth/claude_fingerprint_mode.go index 376080eb..ac57ed81 100644 --- a/auth/claude_fingerprint_mode.go +++ b/auth/claude_fingerprint_mode.go @@ -4,6 +4,7 @@ import ( "encoding/json" "strings" "sync/atomic" + "time" ) // Claude Code 出站请求的指纹收敛模式(账号级;空值 = 跟随全局默认): @@ -190,6 +191,83 @@ func (c ClaudeConfig) CLIVersionSyncEnabledValue() bool { return c.CLIVersionSyncEnabled == nil || *c.CLIVersionSyncEnabled } +// DefaultClaudeFirstTokenTimeoutSeconds 是 Claude OAuth 路径首字超时的默认值: +// 长推理(effort xhigh、~150k 上下文)正常也会在 1~2 分钟内吐出首个 thinking delta, +// 超过这个时间基本是上游卡死,继续等只会让并发位被僵尸请求占住。 +const DefaultClaudeFirstTokenTimeoutSeconds = 120 + +// MaxClaudeFirstTokenTimeoutSeconds 与全局 first_token_timeout_seconds 的上限保持一致。 +const MaxClaudeFirstTokenTimeoutSeconds = 600 + +// NormalizeClaudeFirstTokenTimeoutSeconds 把配置值钳到 [0,600];nil(老配置缺失)取默认 120, +// 负数视为 0(跟随全局)。 +func NormalizeClaudeFirstTokenTimeoutSeconds(seconds *int) int { + if seconds == nil { + return DefaultClaudeFirstTokenTimeoutSeconds + } + if *seconds <= 0 { + return 0 + } + if *seconds > MaxClaudeFirstTokenTimeoutSeconds { + return MaxClaudeFirstTokenTimeoutSeconds + } + return *seconds +} + +// FirstTokenTimeoutSecondsValue 返回归一化后的 Claude 首字超时秒数(0=跟随全局)。 +func (c ClaudeConfig) FirstTokenTimeoutSecondsValue() int { + return NormalizeClaudeFirstTokenTimeoutSeconds(c.FirstTokenTimeoutSeconds) +} + +// StreamKeepaliveEnabledValue 把缺失字段解释为开启。 +func (c ClaudeConfig) StreamKeepaliveEnabledValue() bool { + return c.StreamKeepaliveEnabled == nil || *c.StreamKeepaliveEnabled +} + +// SetClaudeFirstTokenTimeoutSeconds 发布 Claude 路径首字超时(0=跟随全局)。 +func (s *Store) SetClaudeFirstTokenTimeoutSeconds(seconds int) { + if s == nil { + return + } + if seconds < 0 { + seconds = 0 + } + if seconds > MaxClaudeFirstTokenTimeoutSeconds { + seconds = MaxClaudeFirstTokenTimeoutSeconds + } + s.claudeFirstTokenTimeoutSec.Store(int64(seconds)) + s.claudeFirstTokenTimeoutSet.Store(true) +} + +// ClaudeFirstTokenTimeoutSeconds 返回 Claude 路径首字超时秒数;从未设置时取默认 120。 +func (s *Store) ClaudeFirstTokenTimeoutSeconds() int { + if s == nil { + return DefaultClaudeFirstTokenTimeoutSeconds + } + if !s.claudeFirstTokenTimeoutSet.Load() { + return DefaultClaudeFirstTokenTimeoutSeconds + } + return int(s.claudeFirstTokenTimeoutSec.Load()) +} + +// ClaudeFirstTokenTimeout 返回 Claude 路径首字超时时长;0 表示跟随全局设置。 +func (s *Store) ClaudeFirstTokenTimeout() time.Duration { + return time.Duration(s.ClaudeFirstTokenTimeoutSeconds()) * time.Second +} + +// SetClaudeStreamKeepaliveEnabled 发布 Claude 流式首字前 SSE 保活开关。 +func (s *Store) SetClaudeStreamKeepaliveEnabled(enabled bool) { + if s == nil { + return + } + s.claudeStreamKeepaliveDisabled.Store(!enabled) +} + +// ClaudeStreamKeepaliveEnabled 报告 Claude 流式首字前 SSE 保活是否开启(零值=开启)。 +func (s *Store) ClaudeStreamKeepaliveEnabled() bool { + return s != nil && !s.claudeStreamKeepaliveDisabled.Load() +} + // NormalizeClaudeCLIVersionSyncIntervalHours 钳到 [1,720],0/负数视为默认 12。 func NormalizeClaudeCLIVersionSyncIntervalHours(hours int) int { if hours <= 0 { @@ -248,6 +326,11 @@ type ClaudeConfig struct { SessionWindowLimit int64 `json:"session_window_limit"` // 默认并发会话窗口数(0=跟随全局 maxConcurrency) CLIVersionSyncEnabled *bool `json:"cli_version_sync_enabled,omitempty"` // 缺失=true CLIVersionSyncIntervalHours int `json:"cli_version_sync_interval_hours,omitempty"` // 0=12,钳 [1,720] + // FirstTokenTimeoutSeconds 是 Claude OAuth 路径专用的首字超时(秒)。缺失=默认 + // DefaultClaudeFirstTokenTimeoutSeconds;显式 0=跟随全局 first_token_timeout_seconds。 + FirstTokenTimeoutSeconds *int `json:"first_token_timeout_seconds,omitempty"` + // StreamKeepaliveEnabled 控制 Claude 流式请求在首字前是否向下游发 SSE 保活注释。缺失=开启。 + StreamKeepaliveEnabled *bool `json:"stream_keepalive_enabled,omitempty"` ClaudeClientPolicy ClaudeSecurityConfig } @@ -354,6 +437,8 @@ func ParseClaudeConfig(raw string) ClaudeConfig { cfg.SessionWindowLimit = 0 } cfg.CLIVersionSyncIntervalHours = NormalizeClaudeCLIVersionSyncIntervalHours(cfg.CLIVersionSyncIntervalHours) + normalizedTimeout := NormalizeClaudeFirstTokenTimeoutSeconds(cfg.FirstTokenTimeoutSeconds) + cfg.FirstTokenTimeoutSeconds = &normalizedTimeout if clientPolicy, err := NormalizeClaudeClientPolicy(cfg.ClaudeClientPolicy); err == nil { cfg.ClaudeClientPolicy = clientPolicy } else { @@ -370,6 +455,8 @@ func applyClaudeConfigToStore(s *Store, raw string) { s.SetClaudeDefaultTimezone(cfg.DefaultTimezone) s.SetClaudeSessionWindowLimit(cfg.SessionWindowLimit) s.SetClaudeCLIVersionSync(cfg.CLIVersionSyncEnabledValue(), cfg.CLIVersionSyncIntervalHours) + s.SetClaudeFirstTokenTimeoutSeconds(cfg.FirstTokenTimeoutSecondsValue()) + s.SetClaudeStreamKeepaliveEnabled(cfg.StreamKeepaliveEnabledValue()) s.SetClaudeClientPolicy(cfg.ClaudeClientPolicy) s.SetClaudeSecurityConfig(cfg.SecurityConfig()) } diff --git a/auth/claude_first_token_timeout_test.go b/auth/claude_first_token_timeout_test.go new file mode 100644 index 00000000..ce852bb8 --- /dev/null +++ b/auth/claude_first_token_timeout_test.go @@ -0,0 +1,81 @@ +package auth + +import ( + "testing" + "time" +) + +func TestParseClaudeConfig_FirstTokenTimeoutDefaultsWhenMissing(t *testing.T) { + cfg := ParseClaudeConfig(`{"fingerprint_mode":"preserve"}`) + if cfg.FirstTokenTimeoutSecondsValue() != DefaultClaudeFirstTokenTimeoutSeconds { + t.Fatalf("missing field must default to %d, got %d", DefaultClaudeFirstTokenTimeoutSeconds, cfg.FirstTokenTimeoutSecondsValue()) + } + if !cfg.StreamKeepaliveEnabledValue() { + t.Fatal("missing stream_keepalive_enabled must default to true") + } +} + +func TestParseClaudeConfig_FirstTokenTimeoutExplicitZeroFollowsGlobal(t *testing.T) { + cfg := ParseClaudeConfig(`{"first_token_timeout_seconds":0,"stream_keepalive_enabled":false}`) + if cfg.FirstTokenTimeoutSecondsValue() != 0 { + t.Fatalf("explicit 0 must stay 0 (follow global), got %d", cfg.FirstTokenTimeoutSecondsValue()) + } + if cfg.StreamKeepaliveEnabledValue() { + t.Fatal("explicit false must stay false") + } +} + +func TestNormalizeClaudeFirstTokenTimeoutSeconds(t *testing.T) { + cases := map[string]struct { + in *int + want int + }{ + "nil": {nil, DefaultClaudeFirstTokenTimeoutSeconds}, + "negative": {intPtrForTest(-5), 0}, + "zero": {intPtrForTest(0), 0}, + "normal": {intPtrForTest(90), 90}, + "too big": {intPtrForTest(99999), MaxClaudeFirstTokenTimeoutSeconds}, + } + for name, tc := range cases { + if got := NormalizeClaudeFirstTokenTimeoutSeconds(tc.in); got != tc.want { + t.Fatalf("%s: got %d, want %d", name, got, tc.want) + } + } +} + +func TestStoreClaudeFirstTokenTimeoutRoundTrip(t *testing.T) { + store := NewStore(nil, nil, nil) + defer store.Stop() + if got := store.ClaudeFirstTokenTimeout(); got != time.Duration(DefaultClaudeFirstTokenTimeoutSeconds)*time.Second { + t.Fatalf("fresh store must use the default, got %s", got) + } + store.SetClaudeFirstTokenTimeoutSeconds(45) + if got := store.ClaudeFirstTokenTimeout(); got != 45*time.Second { + t.Fatalf("got %s, want 45s", got) + } + store.SetClaudeFirstTokenTimeoutSeconds(0) + if got := store.ClaudeFirstTokenTimeout(); got != 0 { + t.Fatalf("0 must disable the Claude-specific timeout, got %s", got) + } + if !store.ClaudeStreamKeepaliveEnabled() { + t.Fatal("fresh store must enable pre-first-token keepalive") + } + store.SetClaudeStreamKeepaliveEnabled(false) + if store.ClaudeStreamKeepaliveEnabled() { + t.Fatal("keepalive switch must persist false") + } +} + +func TestApplyClaudeConfigToStore_FirstTokenTimeout(t *testing.T) { + store := NewStore(nil, nil, nil) + defer store.Stop() + applyClaudeConfigToStore(store, `{"first_token_timeout_seconds":75,"stream_keepalive_enabled":false}`) + if got := store.ClaudeFirstTokenTimeout(); got != 75*time.Second { + t.Fatalf("got %s, want 75s", got) + } + if store.ClaudeStreamKeepaliveEnabled() { + t.Fatal("stream keepalive must be applied from config") + } +} + +func intPtrForTest(v int) *int { return &v } diff --git a/auth/store.go b/auth/store.go index 4c49737e..dbcdb4f9 100644 --- a/auth/store.go +++ b/auth/store.go @@ -3337,6 +3337,9 @@ type Store struct { claudeSessionWindowLimit int64 // Claude 账号默认并发会话窗口数(0=用全局 maxConcurrency) claudeCLIVersionSyncDisabled atomic.Bool // Claude CLI 版本自动同步是否关闭(零值=开启) claudeCLIVersionSyncIntervalH atomic.Int64 // Claude CLI 版本同步间隔小时(0=默认 12) + claudeFirstTokenTimeoutSec atomic.Int64 // Claude 路径首字超时秒(0=跟随全局) + claudeFirstTokenTimeoutSet atomic.Bool // 首字超时是否被显式设置过(否则取默认 120) + claudeStreamKeepaliveDisabled atomic.Bool // Claude 流式首字前 SSE 保活是否关闭(零值=开启) grokAffinityMode atomic.Value // string: "follow" / "bounded" / "off" / "strict"("follow"=跟随全局) grokProbeEnabled atomic.Bool // 定期探测 Grok 账号状态是否开启(默认关) grokProbeIntervalMin atomic.Int64 // 定期探测间隔(分钟,默认 30,下限 grokProbeMinIntervalMinutes) diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 1121669d..494d6d62 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -4341,6 +4341,10 @@ "claudeCliVersionAutoSyncDesc": "When on, syncs on the configured interval; when off, only the built-in version is applied at startup.", "claudeCliVersionSyncInterval": "Sync interval (hours)", "claudeCliVersionSyncIntervalDesc": "Wait time between automatic syncs (hours, range 1-720).", + "claudeFirstTokenTimeout": "First-token timeout (s)", + "claudeFirstTokenTimeoutDesc": "Claude OAuth only: if upstream produces no visible content (text/thinking delta) within this many seconds, the attempt is cancelled, its slot released, and the request retried once on another account. Default 120; 0 = follow the global first-token timeout.", + "claudeStreamKeepalive": "SSE keepalive before first token", + "claudeStreamKeepaliveDesc": "While a streaming request waits for its first content, write an SSE comment line downstream every 15s so gateways/clients can tell \"upstream is thinking\" from \"connection is dead\" and stop retrying on timeout. Note: once a keepalive is written the HTTP status is committed as 200; later failures arrive as SSE error events.", "claudeCliVersionBuiltin": "built-in", "claudeSessionWindow": "Session window (concurrency)", "claudeSessionWindowDesc": "Default max concurrency for Claude accounts; empty or 0 = follow global.", diff --git a/frontend/src/locales/zh-TW.json b/frontend/src/locales/zh-TW.json index 59fada7a..09d036b2 100644 --- a/frontend/src/locales/zh-TW.json +++ b/frontend/src/locales/zh-TW.json @@ -199,6 +199,10 @@ "claudeCliVersionAutoSyncDesc": "開啟後按間隔自動同步;關閉後僅在啟動時用內建版本回寫指紋。", "claudeCliVersionSyncInterval": "同步間隔(小時)", "claudeCliVersionSyncIntervalDesc": "兩次自動同步之間的等待時長(小時,範圍 1-720)。", + "claudeFirstTokenTimeout": "首字逾時(秒)", + "claudeFirstTokenTimeoutDesc": "Claude OAuth 路徑專用:上游在此秒數內未吐出首個可見內容(文字/思考增量)即取消該次請求、釋放並發位並換號重試一次。預設 120,0 = 跟隨全域「首字逾時」設定。", + "claudeStreamKeepalive": "首字前 SSE 保活", + "claudeStreamKeepaliveDesc": "串流請求在首個內容到達前每 15 秒向下游寫一行 SSE 註解,讓下游閘道/客戶端能區分「上游在思考」與「連線已斷」,避免其逾時重試放大並發佔用。注意:保活寫出後 HTTP 狀態已提交為 200,之後的失敗會以 SSE error 事件回傳。", "claudeCliVersionBuiltin": "內建", "claudeSessionWindow": "並發會話視窗數", "claudeSessionWindowDesc": "Claude 帳號預設最大並發;留空或 0=跟隨全域並發。", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index b6e65f9a..a21c1f52 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -4341,6 +4341,10 @@ "claudeCliVersionAutoSyncDesc": "开启后按间隔自动同步;关闭后仅在启动时用内置版本回写指纹。", "claudeCliVersionSyncInterval": "同步间隔(小时)", "claudeCliVersionSyncIntervalDesc": "两次自动同步之间的等待时长(小时,范围 1-720)。", + "claudeFirstTokenTimeout": "首字超时(秒)", + "claudeFirstTokenTimeoutDesc": "Claude OAuth 路径专用:上游在此秒数内未吐出首个可见内容(文本/思考增量)即取消该次请求、释放并发位并换号重试一次。默认 120,0 = 跟随全局「首字超时」设置。", + "claudeStreamKeepalive": "首字前 SSE 保活", + "claudeStreamKeepaliveDesc": "流式请求在首个内容到达前每 15 秒向下游写一行 SSE 注释,让下游网关/客户端能区分「上游在思考」与「连接已断」,避免其超时重试放大并发占用。注意:保活写出后 HTTP 状态已提交为 200,之后的失败会以 SSE error 事件返回。", "claudeCliVersionBuiltin": "内置", "claudeSessionWindow": "并发会话窗口数", "claudeSessionWindowDesc": "Claude 账号默认最大并发;留空或 0=跟随全局并发。", diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 4e611bcb..1ca915e2 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -720,6 +720,8 @@ function ClaudeCodeSettingsCard() { const [maxToolSchemaBytes, setMaxToolSchemaBytes] = useState('0') const [cliVersionSyncEnabled, setCliVersionSyncEnabled] = useState(true) const [cliVersionSyncIntervalHours, setCliVersionSyncIntervalHours] = useState(12) + const [firstTokenTimeoutSeconds, setFirstTokenTimeoutSeconds] = useState(120) + const [streamKeepaliveEnabled, setStreamKeepaliveEnabled] = useState(true) const [syncedCliVersion, setSyncedCliVersion] = useState('') const [effectiveCliVersion, setEffectiveCliVersion] = useState('') const [syncingCliVersion, setSyncingCliVersion] = useState(false) @@ -749,6 +751,8 @@ function ClaudeCodeSettingsCard() { setMaxToolSchemaBytes(String(cfg.max_tool_schema_bytes ?? 0)) setCliVersionSyncEnabled(cfg.cli_version_sync_enabled ?? true) setCliVersionSyncIntervalHours(cfg.cli_version_sync_interval_hours || 12) + setFirstTokenTimeoutSeconds(cfg.first_token_timeout_seconds ?? 120) + setStreamKeepaliveEnabled(cfg.stream_keepalive_enabled ?? true) setSyncedCliVersion(cfg.synced_cli_version ?? '') setEffectiveCliVersion(cfg.effective_cli_version ?? cfg.builtin_cli_version ?? '') }) @@ -787,6 +791,8 @@ function ClaudeCodeSettingsCard() { max_tool_schema_bytes: Number.isFinite(maxToolSchemaValue) && maxToolSchemaValue >= 0 ? Math.floor(maxToolSchemaValue) : 0, cli_version_sync_enabled: cliVersionSyncEnabled, cli_version_sync_interval_hours: cliVersionSyncIntervalHours, + first_token_timeout_seconds: firstTokenTimeoutSeconds, + stream_keepalive_enabled: streamKeepaliveEnabled, }) showToast(t('settings.claudeSaved'), 'success') } catch (error) { @@ -794,7 +800,7 @@ function ClaudeCodeSettingsCard() { } finally { setSaving(false) } - }, [allowInferenceGeo, allowSafetyIdentifier, allowServiceTier, allowSpeed, allowedBetaHeaders, cliVersionSyncEnabled, cliVersionSyncIntervalHours, clientPlatform, clientVersion, fingerprintMode, maxOutputTokens, maxToolCount, maxToolSchemaBytes, sessionWindow, showToast, t, timezone, versionPolicy]) + }, [allowInferenceGeo, allowSafetyIdentifier, allowServiceTier, allowSpeed, allowedBetaHeaders, cliVersionSyncEnabled, cliVersionSyncIntervalHours, clientPlatform, clientVersion, fingerprintMode, firstTokenTimeoutSeconds, maxOutputTokens, maxToolCount, maxToolSchemaBytes, sessionWindow, showToast, streamKeepaliveEnabled, t, timezone, versionPolicy]) const handleSyncClaudeCliVersion = useCallback(async () => { setSyncingCliVersion(true) @@ -934,6 +940,32 @@ function ClaudeCodeSettingsCard() { + {/* 首字超时 + 首字前保活成对横排:两者都只作用于 Claude OAuth 路径 */} +