Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions admin/claude_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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(),
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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 全局配置",
Expand All @@ -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,
// 而不是把整次同步判为失败。
Expand Down
54 changes: 54 additions & 0 deletions admin/claude_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
87 changes: 87 additions & 0 deletions auth/claude_fingerprint_mode.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"strings"
"sync/atomic"
"time"
)

// Claude Code 出站请求的指纹收敛模式(账号级;空值 = 跟随全局默认):
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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())
}
81 changes: 81 additions & 0 deletions auth/claude_first_token_timeout_test.go
Original file line number Diff line number Diff line change
@@ -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 }
3 changes: 3 additions & 0 deletions auth/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -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=跟隨全域並發。",
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -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=跟随全局并发。",
Expand Down
Loading
Loading