From ad2cd97618791bd93ffa04b9497da3613b9ae796 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Mon, 30 Mar 2026 16:23:38 +0800 Subject: [PATCH 01/59] fix: resolve refresh token race condition causing false invalid_grant errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When multiple goroutines/workers concurrently refresh the same OAuth token, the first succeeds but invalidates the old refresh_token (rotation). Subsequent attempts using the stale token get invalid_grant, which was incorrectly treated as non-retryable, permanently marking the account as ERROR. Three complementary fixes: 1. Race-aware recovery: after invalid_grant, re-read DB to check if another worker already refreshed (refresh_token changed) — return success instead of error 2. In-process mutex (sync.Map of per-account locks): prevents concurrent refreshes within the same process, complementing the Redis distributed lock 3. Increase default lock TTL from 30s to 60s to reduce TTL-expiry races Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/internal/service/oauth_refresh_api.go | 73 +++++- .../service/oauth_refresh_api_test.go | 219 ++++++++++++++++++ 2 files changed, 286 insertions(+), 6 deletions(-) diff --git a/backend/internal/service/oauth_refresh_api.go b/backend/internal/service/oauth_refresh_api.go index 5dbba63858..fdc0ce40f5 100644 --- a/backend/internal/service/oauth_refresh_api.go +++ b/backend/internal/service/oauth_refresh_api.go @@ -5,6 +5,8 @@ import ( "fmt" "log/slog" "strconv" + "strings" + "sync" "time" ) @@ -17,7 +19,7 @@ type OAuthRefreshExecutor interface { CacheKey(account *Account) string } -const refreshLockTTL = 30 * time.Second +const defaultRefreshLockTTL = 60 * time.Second // OAuthRefreshResult 统一刷新结果 type OAuthRefreshResult struct { @@ -28,20 +30,34 @@ type OAuthRefreshResult struct { } // OAuthRefreshAPI 统一的 OAuth Token 刷新入口 -// 封装分布式锁、DB 重读、已刷新检查等通用逻辑 +// 封装分布式锁、进程内互斥锁、DB 重读、已刷新检查、竞争恢复等通用逻辑 type OAuthRefreshAPI struct { accountRepo AccountRepository - tokenCache GeminiTokenCache // 可选,nil = 无锁 + tokenCache GeminiTokenCache // 可选,nil = 无分布式锁 + lockTTL time.Duration + localLocks sync.Map // key: cacheKey string -> value: *sync.Mutex } // NewOAuthRefreshAPI 创建统一刷新 API -func NewOAuthRefreshAPI(accountRepo AccountRepository, tokenCache GeminiTokenCache) *OAuthRefreshAPI { +// 可选传入 lockTTL 覆盖默认的 60s 分布式锁 TTL +func NewOAuthRefreshAPI(accountRepo AccountRepository, tokenCache GeminiTokenCache, lockTTL ...time.Duration) *OAuthRefreshAPI { + ttl := defaultRefreshLockTTL + if len(lockTTL) > 0 && lockTTL[0] > 0 { + ttl = lockTTL[0] + } return &OAuthRefreshAPI{ accountRepo: accountRepo, tokenCache: tokenCache, + lockTTL: ttl, } } +// getLocalLock 返回指定 cacheKey 的进程内互斥锁 +func (api *OAuthRefreshAPI) getLocalLock(cacheKey string) *sync.Mutex { + val, _ := api.localLocks.LoadOrStore(cacheKey, &sync.Mutex{}) + return val.(*sync.Mutex) +} + // RefreshIfNeeded 在分布式锁保护下按需刷新 OAuth token // // 流程: @@ -59,12 +75,17 @@ func (api *OAuthRefreshAPI) RefreshIfNeeded( ) (*OAuthRefreshResult, error) { cacheKey := executor.CacheKey(account) + // 0. 获取进程内互斥锁(防止同一进程内的并发刷新竞争) + localMu := api.getLocalLock(cacheKey) + localMu.Lock() + defer localMu.Unlock() + // 1. 获取分布式锁 lockAcquired := false if api.tokenCache != nil { - acquired, lockErr := api.tokenCache.AcquireRefreshLock(ctx, cacheKey, refreshLockTTL) + acquired, lockErr := api.tokenCache.AcquireRefreshLock(ctx, cacheKey, api.lockTTL) if lockErr != nil { - // Redis 错误,降级为无锁刷新 + // Redis 错误,降级为无锁刷新(进程内互斥锁仍生效) slog.Warn("oauth_refresh_lock_failed_degraded", "account_id", account.ID, "cache_key", cacheKey, @@ -102,6 +123,19 @@ func (api *OAuthRefreshAPI) RefreshIfNeeded( // 4. 执行平台特定刷新逻辑 newCredentials, refreshErr := executor.Refresh(ctx, freshAccount) if refreshErr != nil { + // 竞争恢复:invalid_grant 可能是另一个 worker 已消费了旧 refresh_token + // 重新读取 DB,如果 refresh_token 已更新则说明是竞争,返回成功 + if isInvalidGrantError(refreshErr) { + if recoveredAccount, recovered := api.tryRecoverFromRefreshRace(ctx, freshAccount); recovered { + slog.Info("oauth_refresh_race_recovered", + "account_id", freshAccount.ID, + "platform", freshAccount.Platform, + ) + return &OAuthRefreshResult{ + Account: recoveredAccount, + }, nil + } + } return nil, refreshErr } @@ -126,6 +160,33 @@ func (api *OAuthRefreshAPI) RefreshIfNeeded( }, nil } +// isInvalidGrantError 检查错误是否为 invalid_grant +func isInvalidGrantError(err error) bool { + return err != nil && strings.Contains(strings.ToLower(err.Error()), "invalid_grant") +} + +// tryRecoverFromRefreshRace 在 invalid_grant 错误后尝试竞争恢复 +// 重新读取 DB,如果 refresh_token 已改变(说明另一个 worker 成功刷新),则返回更新后的 account +func (api *OAuthRefreshAPI) tryRecoverFromRefreshRace(ctx context.Context, usedAccount *Account) (*Account, bool) { + if api.accountRepo == nil { + return nil, false + } + reReadAccount, err := api.accountRepo.GetByID(ctx, usedAccount.ID) + if err != nil || reReadAccount == nil { + return nil, false + } + usedRT := usedAccount.GetCredential("refresh_token") + currentRT := reReadAccount.GetCredential("refresh_token") + if usedRT == "" || currentRT == "" { + return nil, false + } + // refresh_token 不同 → 另一个 worker 已成功刷新 + if usedRT != currentRT { + return reReadAccount, true + } + return nil, false +} + // MergeCredentials 将旧 credentials 中不存在于新 map 的字段保留到新 map 中 func MergeCredentials(oldCreds, newCreds map[string]any) map[string]any { if newCreds == nil { diff --git a/backend/internal/service/oauth_refresh_api_test.go b/backend/internal/service/oauth_refresh_api_test.go index c3b38ddf6e..4a60723b8b 100644 --- a/backend/internal/service/oauth_refresh_api_test.go +++ b/backend/internal/service/oauth_refresh_api_test.go @@ -5,6 +5,7 @@ package service import ( "context" "errors" + "sync" "testing" "time" @@ -385,6 +386,224 @@ func TestBuildClaudeAccountCredentials_Minimal(t *testing.T) { require.False(t, hasScope, "scope should not be set when empty") } +// refreshAPIAccountRepoWithRace supports returning a different account on subsequent GetByID calls +// to simulate race conditions where another worker has refreshed the token. +type refreshAPIAccountRepoWithRace struct { + refreshAPIAccountRepo + raceAccount *Account // returned on 2nd+ GetByID call + getByIDCalls int +} + +func (r *refreshAPIAccountRepoWithRace) GetByID(_ context.Context, _ int64) (*Account, error) { + r.getByIDCalls++ + if r.getByIDCalls > 1 && r.raceAccount != nil { + return r.raceAccount, nil + } + if r.getByIDErr != nil { + return nil, r.getByIDErr + } + return r.account, nil +} + +// ========== Race recovery tests ========== + +func TestRefreshIfNeeded_InvalidGrantRaceRecovered(t *testing.T) { + // Account with old refresh token + account := &Account{ + ID: 10, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Credentials: map[string]any{"refresh_token": "old-rt", "access_token": "old-at"}, + } + // After race, DB has new refresh token from another worker + racedAccount := &Account{ + ID: 10, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Credentials: map[string]any{"refresh_token": "new-rt", "access_token": "new-at"}, + } + repo := &refreshAPIAccountRepoWithRace{ + refreshAPIAccountRepo: refreshAPIAccountRepo{account: account}, + raceAccount: racedAccount, + } + cache := &refreshAPICacheStub{lockResult: true} + executor := &refreshAPIExecutorStub{ + needsRefresh: true, + err: errors.New("invalid_grant: refresh token not found or invalid"), + } + + api := NewOAuthRefreshAPI(repo, cache) + result, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute) + + require.NoError(t, err, "race-recovered invalid_grant should not return error") + require.False(t, result.Refreshed) + require.False(t, result.LockHeld) + require.NotNil(t, result.Account) + require.Equal(t, "new-rt", result.Account.GetCredential("refresh_token")) + require.Equal(t, 0, repo.updateCalls) // no DB update needed, another worker did it +} + +func TestRefreshIfNeeded_InvalidGrantGenuine(t *testing.T) { + // Account with revoked refresh token - DB still has the same token + account := &Account{ + ID: 11, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Credentials: map[string]any{"refresh_token": "revoked-rt", "access_token": "old-at"}, + } + repo := &refreshAPIAccountRepoWithRace{ + refreshAPIAccountRepo: refreshAPIAccountRepo{account: account}, + raceAccount: account, // same refresh_token on re-read + } + cache := &refreshAPICacheStub{lockResult: true} + executor := &refreshAPIExecutorStub{ + needsRefresh: true, + err: errors.New("invalid_grant: refresh token revoked"), + } + + api := NewOAuthRefreshAPI(repo, cache) + result, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute) + + require.Error(t, err, "genuine invalid_grant should propagate error") + require.Nil(t, result) + require.Contains(t, err.Error(), "invalid_grant") +} + +func TestRefreshIfNeeded_InvalidGrantDBRereadFailsOnRecovery(t *testing.T) { + account := &Account{ + ID: 12, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Credentials: map[string]any{"refresh_token": "old-rt"}, + } + repo := &refreshAPIAccountRepoWithRace{ + refreshAPIAccountRepo: refreshAPIAccountRepo{account: account}, + raceAccount: nil, // GetByID returns nil on recovery attempt + } + cache := &refreshAPICacheStub{lockResult: true} + executor := &refreshAPIExecutorStub{ + needsRefresh: true, + err: errors.New("invalid_grant"), + } + + api := NewOAuthRefreshAPI(repo, cache) + result, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute) + + require.Error(t, err, "should propagate error when recovery DB re-read fails") + require.Nil(t, result) +} + +func TestRefreshIfNeeded_LocalMutexSerializesConcurrent(t *testing.T) { + // Test that two goroutines for the same account are serialized by the local mutex. + // The first goroutine refreshes successfully; the second sees NeedsRefresh=false. + refreshed := &Account{ + ID: 20, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Credentials: map[string]any{"refresh_token": "new-rt", "access_token": "new-at"}, + } + callCount := 0 + repo := &refreshAPIAccountRepo{account: &Account{ + ID: 20, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Credentials: map[string]any{"refresh_token": "old-rt"}, + }} + + // After first refresh, NeedsRefresh should return false + // We simulate this by using an executor that decrements needsRefresh after first call + var mu sync.Mutex + dynamicExecutor := &dynamicRefreshExecutor{ + canRefresh: true, + cacheKey: "test:mutex:anthropic", + refreshFunc: func(_ context.Context, _ *Account) (map[string]any, error) { + mu.Lock() + callCount++ + mu.Unlock() + time.Sleep(50 * time.Millisecond) // slow refresh + return map[string]any{"access_token": "new-at"}, nil + }, + needsRefreshFunc: func() bool { + mu.Lock() + defer mu.Unlock() + return callCount == 0 // only first call needs refresh + }, + } + + _ = refreshed + + api := NewOAuthRefreshAPI(repo, nil) // no distributed lock, only local mutex + + var wg sync.WaitGroup + results := make([]*OAuthRefreshResult, 2) + errs := make([]error, 2) + + for i := 0; i < 2; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + results[idx], errs[idx] = api.RefreshIfNeeded(context.Background(), repo.account, dynamicExecutor, 3*time.Minute) + }(i) + } + wg.Wait() + + require.NoError(t, errs[0]) + require.NoError(t, errs[1]) + + // Only one goroutine should have actually called Refresh + mu.Lock() + require.Equal(t, 1, callCount, "only one refresh call should have been made") + mu.Unlock() +} + +// dynamicRefreshExecutor is a test helper with function-based NeedsRefresh and Refresh. +type dynamicRefreshExecutor struct { + canRefresh bool + cacheKey string + needsRefreshFunc func() bool + refreshFunc func(context.Context, *Account) (map[string]any, error) +} + +func (e *dynamicRefreshExecutor) CanRefresh(_ *Account) bool { return e.canRefresh } + +func (e *dynamicRefreshExecutor) NeedsRefresh(_ *Account, _ time.Duration) bool { + return e.needsRefreshFunc() +} + +func (e *dynamicRefreshExecutor) Refresh(ctx context.Context, account *Account) (map[string]any, error) { + return e.refreshFunc(ctx, account) +} + +func (e *dynamicRefreshExecutor) CacheKey(_ *Account) string { + return e.cacheKey +} + +// ========== NewOAuthRefreshAPI TTL tests ========== + +func TestNewOAuthRefreshAPI_DefaultTTL(t *testing.T) { + api := NewOAuthRefreshAPI(nil, nil) + require.Equal(t, defaultRefreshLockTTL, api.lockTTL) +} + +func TestNewOAuthRefreshAPI_CustomTTL(t *testing.T) { + api := NewOAuthRefreshAPI(nil, nil, 90*time.Second) + require.Equal(t, 90*time.Second, api.lockTTL) +} + +func TestNewOAuthRefreshAPI_ZeroTTLUsesDefault(t *testing.T) { + api := NewOAuthRefreshAPI(nil, nil, 0) + require.Equal(t, defaultRefreshLockTTL, api.lockTTL) +} + +// ========== isInvalidGrantError tests ========== + +func TestIsInvalidGrantError(t *testing.T) { + require.True(t, isInvalidGrantError(errors.New("invalid_grant: token revoked"))) + require.True(t, isInvalidGrantError(errors.New("INVALID_GRANT"))) + require.False(t, isInvalidGrantError(errors.New("invalid_client"))) + require.False(t, isInvalidGrantError(nil)) +} + // ========== BackgroundRefreshPolicy tests ========== func TestBackgroundRefreshPolicy_DefaultSkips(t *testing.T) { From 49e99e9d519a55cbe6d3bd94b810978dd64ce4b8 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Mon, 30 Mar 2026 16:44:15 +0800 Subject: [PATCH 02/59] fix: resolve errcheck lint for sync.Map type assertion Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/internal/service/oauth_refresh_api.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/oauth_refresh_api.go b/backend/internal/service/oauth_refresh_api.go index fdc0ce40f5..571e9ecdb7 100644 --- a/backend/internal/service/oauth_refresh_api.go +++ b/backend/internal/service/oauth_refresh_api.go @@ -54,8 +54,13 @@ func NewOAuthRefreshAPI(accountRepo AccountRepository, tokenCache GeminiTokenCac // getLocalLock 返回指定 cacheKey 的进程内互斥锁 func (api *OAuthRefreshAPI) getLocalLock(cacheKey string) *sync.Mutex { - val, _ := api.localLocks.LoadOrStore(cacheKey, &sync.Mutex{}) - return val.(*sync.Mutex) + actual, _ := api.localLocks.LoadOrStore(cacheKey, &sync.Mutex{}) + mu, ok := actual.(*sync.Mutex) + if !ok { + mu = &sync.Mutex{} + api.localLocks.Store(cacheKey, mu) + } + return mu } // RefreshIfNeeded 在分布式锁保护下按需刷新 OAuth token From 6b646b61273e8cc0b6ec93b8d179e8539e2651e5 Mon Sep 17 00:00:00 2001 From: qingyuzhang Date: Mon, 30 Mar 2026 22:29:26 +0800 Subject: [PATCH 03/59] fix(openai): fail over passthrough 429 and 529 --- .../service/openai_gateway_service.go | 58 +++++- .../service/openai_oauth_passthrough_test.go | 190 ++++++++++++++---- 2 files changed, 206 insertions(+), 42 deletions(-) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 0a95961512..d0ae5a2f64 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -2430,7 +2430,11 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 400 { - // 透传模式不做 failover(避免改变原始上游语义),按上游原样返回错误响应。 + // 透传模式默认保持原样代理;但 429/529 属于网关必须兜底的 + // 上游容量类错误,应先触发多账号 failover 以维持基础 SLA。 + if shouldFailoverOpenAIPassthroughResponse(resp.StatusCode) { + return nil, s.handleFailoverErrorResponsePassthrough(ctx, resp, c, account, body) + } return nil, s.handleErrorResponsePassthrough(ctx, resp, c, account, body) } @@ -2613,6 +2617,58 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( return req, nil } +func shouldFailoverOpenAIPassthroughResponse(statusCode int) bool { + switch statusCode { + case http.StatusTooManyRequests, 529: + return true + default: + return false + } +} + +func (s *OpenAIGatewayService) handleFailoverErrorResponsePassthrough( + ctx context.Context, + resp *http.Response, + c *gin.Context, + account *Account, + requestBody []byte, +) error { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + + upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body)) + upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + upstreamDetail := "" + if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { + maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes + if maxBytes <= 0 { + maxBytes = 2048 + } + upstreamDetail = truncateString(string(body), maxBytes) + } + setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail) + logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body) + if s.rateLimitService != nil { + _ = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body) + } + appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ + Platform: account.Platform, + AccountID: account.ID, + AccountName: account.Name, + UpstreamStatusCode: resp.StatusCode, + UpstreamRequestID: resp.Header.Get("x-request-id"), + Passthrough: true, + Kind: "failover", + Message: upstreamMsg, + Detail: upstreamDetail, + UpstreamResponseBody: upstreamDetail, + }) + return &UpstreamFailoverError{ + StatusCode: resp.StatusCode, + ResponseBody: body, + ResponseHeaders: resp.Header.Clone(), + } +} + func (s *OpenAIGatewayService) handleErrorResponsePassthrough( ctx context.Context, resp *http.Response, diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 97fa218d92..69c9de42e3 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -48,6 +48,22 @@ func (u *httpUpstreamRecorder) DoWithTLS(req *http.Request, proxyURL string, acc return u.Do(req, proxyURL, accountID, accountConcurrency) } +type openAIPassthroughFailoverRepo struct { + stubOpenAIAccountRepo + rateLimitCalls []time.Time + overloadCalls []time.Time +} + +func (r *openAIPassthroughFailoverRepo) SetRateLimited(_ context.Context, _ int64, resetAt time.Time) error { + r.rateLimitCalls = append(r.rateLimitCalls, resetAt) + return nil +} + +func (r *openAIPassthroughFailoverRepo) SetOverloaded(_ context.Context, _ int64, until time.Time) error { + r.overloadCalls = append(r.overloadCalls, until) + return nil +} + var structuredLogCaptureMu sync.Mutex type inMemoryLogSink struct { @@ -527,6 +543,8 @@ func TestOpenAIGatewayService_OAuthPassthrough_UpstreamErrorIncludesPassthroughF _, err := svc.Forward(context.Background(), c, account, originalBody) require.Error(t, err) + require.True(t, c.Writer.Written(), "非 429/529 的 passthrough 错误应继续原样写回客户端") + require.Equal(t, http.StatusBadRequest, rec.Code) // should append an upstream error event with passthrough=true v, ok := c.Get(OpsUpstreamErrorsKey) @@ -535,55 +553,145 @@ func TestOpenAIGatewayService_OAuthPassthrough_UpstreamErrorIncludesPassthroughF require.True(t, ok) require.NotEmpty(t, arr) require.True(t, arr[len(arr)-1].Passthrough) + require.Equal(t, "http_error", arr[len(arr)-1].Kind) } -func TestOpenAIGatewayService_OAuthPassthrough_429PersistsRateLimit(t *testing.T) { +func TestOpenAIGatewayService_OpenAIPassthrough_429And529TriggerFailover(t *testing.T) { gin.SetMode(gin.TestMode) - - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) - c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0") - originalBody := []byte(`{"model":"gpt-5.2","stream":false,"instructions":"local-test-instructions","input":[{"type":"text","text":"hi"}]}`) - resetAt := time.Now().Add(7 * 24 * time.Hour).Unix() - resp := &http.Response{ - StatusCode: http.StatusTooManyRequests, - Header: http.Header{ - "Content-Type": []string{"application/json"}, - "x-request-id": []string{"rid-rate-limit"}, + + newAccount := func(accountType string) *Account { + account := &Account{ + ID: 123, + Name: "acc", + Platform: PlatformOpenAI, + Type: accountType, + Concurrency: 1, + Extra: map[string]any{"openai_passthrough": true}, + Status: StatusActive, + Schedulable: true, + RateMultiplier: f64p(1), + } + switch accountType { + case AccountTypeOAuth: + account.Credentials = map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"} + case AccountTypeAPIKey: + account.Credentials = map[string]any{"api_key": "sk-test"} + } + return account + } + + testCases := []struct { + name string + accountType string + statusCode int + body string + assertRepo func(t *testing.T, repo *openAIPassthroughFailoverRepo, start time.Time) + }{ + { + name: "oauth_429_rate_limit", + accountType: AccountTypeOAuth, + statusCode: http.StatusTooManyRequests, + body: func() string { + resetAt := time.Now().Add(7 * 24 * time.Hour).Unix() + return fmt.Sprintf(`{"error":{"message":"The usage limit has been reached","type":"usage_limit_reached","resets_at":%d}}`, resetAt) + }(), + assertRepo: func(t *testing.T, repo *openAIPassthroughFailoverRepo, _ time.Time) { + require.Len(t, repo.rateLimitCalls, 1) + require.Empty(t, repo.overloadCalls) + require.True(t, time.Until(repo.rateLimitCalls[0]) > 24*time.Hour) + }, + }, + { + name: "oauth_529_overload", + accountType: AccountTypeOAuth, + statusCode: 529, + body: `{"error":{"message":"server overloaded","type":"server_error"}}`, + assertRepo: func(t *testing.T, repo *openAIPassthroughFailoverRepo, start time.Time) { + require.Empty(t, repo.rateLimitCalls) + require.Len(t, repo.overloadCalls, 1) + require.WithinDuration(t, start.Add(10*time.Minute), repo.overloadCalls[0], 5*time.Second) + }, + }, + { + name: "apikey_429_rate_limit", + accountType: AccountTypeAPIKey, + statusCode: http.StatusTooManyRequests, + body: func() string { + resetAt := time.Now().Add(7 * 24 * time.Hour).Unix() + return fmt.Sprintf(`{"error":{"message":"The usage limit has been reached","type":"usage_limit_reached","resets_at":%d}}`, resetAt) + }(), + assertRepo: func(t *testing.T, repo *openAIPassthroughFailoverRepo, _ time.Time) { + require.Len(t, repo.rateLimitCalls, 1) + require.Empty(t, repo.overloadCalls) + require.True(t, time.Until(repo.rateLimitCalls[0]) > 24*time.Hour) + }, + }, + { + name: "apikey_529_overload", + accountType: AccountTypeAPIKey, + statusCode: 529, + body: `{"error":{"message":"server overloaded","type":"server_error"}}`, + assertRepo: func(t *testing.T, repo *openAIPassthroughFailoverRepo, start time.Time) { + require.Empty(t, repo.rateLimitCalls) + require.Len(t, repo.overloadCalls, 1) + require.WithinDuration(t, start.Add(10*time.Minute), repo.overloadCalls[0], 5*time.Second) + }, }, - Body: io.NopCloser(strings.NewReader(fmt.Sprintf(`{"error":{"message":"The usage limit has been reached","type":"usage_limit_reached","resets_at":%d}}`, resetAt))), - } - upstream := &httpUpstreamRecorder{resp: resp} - repo := &openAIWSRateLimitSignalRepo{} - rateSvc := &RateLimitService{accountRepo: repo} - - svc := &OpenAIGatewayService{ - cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}}, - httpUpstream: upstream, - rateLimitService: rateSvc, } - account := &Account{ - ID: 123, - Name: "acc", - Platform: PlatformOpenAI, - Type: AccountTypeOAuth, - Concurrency: 1, - Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, - Extra: map[string]any{"openai_passthrough": true}, - Status: StatusActive, - Schedulable: true, - RateMultiplier: f64p(1), - } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0") - _, err := svc.Forward(context.Background(), c, account, originalBody) - require.Error(t, err) - require.Equal(t, http.StatusTooManyRequests, rec.Code) - require.Contains(t, rec.Body.String(), "usage_limit_reached") - require.Len(t, repo.rateLimitCalls, 1) - require.WithinDuration(t, time.Unix(resetAt, 0), repo.rateLimitCalls[0], 2*time.Second) + resp := &http.Response{ + StatusCode: tc.statusCode, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "x-request-id": []string{"rid-failover"}, + }, + Body: io.NopCloser(strings.NewReader(tc.body)), + } + upstream := &httpUpstreamRecorder{resp: resp} + repo := &openAIPassthroughFailoverRepo{} + rateSvc := &RateLimitService{ + accountRepo: repo, + cfg: &config.Config{ + RateLimit: config.RateLimitConfig{OverloadCooldownMinutes: 10}, + }, + } + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}}, + httpUpstream: upstream, + rateLimitService: rateSvc, + } + + account := newAccount(tc.accountType) + start := time.Now() + _, err := svc.Forward(context.Background(), c, account, originalBody) + require.Error(t, err) + + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Equal(t, tc.statusCode, failoverErr.StatusCode) + require.False(t, c.Writer.Written(), "429/529 passthrough 应返回 failover 错误给上层换号,而不是直接向客户端写响应") + + v, ok := c.Get(OpsUpstreamErrorsKey) + require.True(t, ok) + arr, ok := v.([]*OpsUpstreamErrorEvent) + require.True(t, ok) + require.NotEmpty(t, arr) + require.True(t, arr[len(arr)-1].Passthrough) + require.Equal(t, "failover", arr[len(arr)-1].Kind) + require.Equal(t, tc.statusCode, arr[len(arr)-1].UpstreamStatusCode) + + tc.assertRepo(t, repo, start) + }) + } } func TestOpenAIGatewayService_OAuthPassthrough_NonCodexUAFallbackToCodexUA(t *testing.T) { From a61d58716fb7a15a2bbedc5e7d8260f5b7bf0974 Mon Sep 17 00:00:00 2001 From: weak-fox <827367480@qq.com> Date: Tue, 31 Mar 2026 00:00:46 +0800 Subject: [PATCH 04/59] fix(admin): exclude rate-limited accounts from active filter --- backend/internal/repository/account_repo.go | 8 ++++++++ .../repository/account_repo_integration_test.go | 16 ++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index d45e8a1297..28aca32bd9 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -468,6 +468,14 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati } if status != "" { switch status { + case service.StatusActive: + q = q.Where( + dbaccount.StatusEQ(status), + dbaccount.Or( + dbaccount.RateLimitResetAtIsNil(), + dbaccount.RateLimitResetAtLTE(time.Now()), + ), + ) case "rate_limited": q = q.Where(dbaccount.RateLimitResetAtGT(time.Now())) case "temp_unschedulable": diff --git a/backend/internal/repository/account_repo_integration_test.go b/backend/internal/repository/account_repo_integration_test.go index 8da30c92a8..f3e3f7452f 100644 --- a/backend/internal/repository/account_repo_integration_test.go +++ b/backend/internal/repository/account_repo_integration_test.go @@ -255,6 +255,22 @@ func (s *AccountRepoSuite) TestListWithFilters() { s.Require().Equal(service.StatusDisabled, accounts[0].Status) }, }, + { + name: "filter_by_status_active_excludes_rate_limited", + setup: func(client *dbent.Client) { + mustCreateAccount(s.T(), client, &service.Account{Name: "active-normal", Status: service.StatusActive}) + rateLimited := mustCreateAccount(s.T(), client, &service.Account{Name: "active-rate-limited", Status: service.StatusActive}) + err := client.Account.UpdateOneID(rateLimited.ID). + SetRateLimitResetAt(time.Now().Add(10 * time.Minute)). + Exec(context.Background()) + s.Require().NoError(err) + }, + status: service.StatusActive, + wantCount: 1, + validate: func(accounts []service.Account) { + s.Require().Equal("active-normal", accounts[0].Name) + }, + }, { name: "filter_by_search", setup: func(client *dbent.Client) { From c8cfad7c00fb598d395eb4490cbc89eb6a66b314 Mon Sep 17 00:00:00 2001 From: YanzheL Date: Wed, 1 Apr 2026 00:33:16 +0800 Subject: [PATCH 05/59] fix(antigravity): preserve google search with function tools --- .../pkg/antigravity/request_transformer.go | 24 ++++++++++--------- .../antigravity/request_transformer_test.go | 23 ++++++++++++++++++ 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index 1b45e507fe..d13a84983f 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -730,13 +730,14 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration { }) } - if len(funcDecls) == 0 { - if !hasWebSearch { - return nil - } - - // Web Search 工具映射 - return []GeminiToolDeclaration{{ + var declarations []GeminiToolDeclaration + if len(funcDecls) > 0 { + declarations = append(declarations, GeminiToolDeclaration{ + FunctionDeclarations: funcDecls, + }) + } + if hasWebSearch { + declarations = append(declarations, GeminiToolDeclaration{ GoogleSearch: &GeminiGoogleSearch{ EnhancedContent: &GeminiEnhancedContent{ ImageSearch: &GeminiImageSearch{ @@ -744,10 +745,11 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration { }, }, }, - }} + }) + } + if len(declarations) == 0 { + return nil } - return []GeminiToolDeclaration{{ - FunctionDeclarations: funcDecls, - }} + return declarations } diff --git a/backend/internal/pkg/antigravity/request_transformer_test.go b/backend/internal/pkg/antigravity/request_transformer_test.go index 9e46295a8d..ccbd3e7885 100644 --- a/backend/internal/pkg/antigravity/request_transformer_test.go +++ b/backend/internal/pkg/antigravity/request_transformer_test.go @@ -263,6 +263,29 @@ func TestBuildTools_CustomTypeTools(t *testing.T) { } } +func TestBuildTools_PreservesWebSearchAlongsideFunctions(t *testing.T) { + tools := []ClaudeTool{ + { + Name: "get_weather", + Description: "Get weather information", + InputSchema: map[string]any{"type": "object"}, + }, + { + Type: "web_search_20250305", + Name: "web_search", + }, + } + + result := buildTools(tools) + require.Len(t, result, 2) + require.Len(t, result[0].FunctionDeclarations, 1) + require.Equal(t, "get_weather", result[0].FunctionDeclarations[0].Name) + require.NotNil(t, result[1].GoogleSearch) + require.NotNil(t, result[1].GoogleSearch.EnhancedContent) + require.NotNil(t, result[1].GoogleSearch.EnhancedContent.ImageSearch) + require.Equal(t, 5, result[1].GoogleSearch.EnhancedContent.ImageSearch.MaxResultCount) +} + func TestBuildGenerationConfig_ThinkingDynamicBudget(t *testing.T) { tests := []struct { name string From 0ebe0ce58551be2d30b22f3f86b9a20cb0b9737d Mon Sep 17 00:00:00 2001 From: YanzheL Date: Wed, 1 Apr 2026 00:33:39 +0800 Subject: [PATCH 06/59] fix(gemini): preserve google search in Claude compat tools --- .../service/gemini_messages_compat_service.go | 37 ++++++++++++++++--- .../gemini_messages_compat_service_test.go | 29 +++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 5b1abc119f..45aa359947 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -3169,12 +3169,17 @@ func convertClaudeToolsToGeminiTools(tools any) []any { return nil } + hasWebSearch := false funcDecls := make([]any, 0, len(arr)) for _, t := range arr { tm, ok := t.(map[string]any) if !ok { continue } + if isClaudeWebSearchToolMap(tm) { + hasWebSearch = true + continue + } var name, desc string var params any @@ -3218,13 +3223,35 @@ func convertClaudeToolsToGeminiTools(tools any) []any { }) } - if len(funcDecls) == 0 { + out := make([]any, 0, 2) + if len(funcDecls) > 0 { + out = append(out, map[string]any{ + "functionDeclarations": funcDecls, + }) + } + if hasWebSearch { + out = append(out, map[string]any{ + "googleSearch": map[string]any{}, + }) + } + if len(out) == 0 { return nil } - return []any{ - map[string]any{ - "functionDeclarations": funcDecls, - }, + return out +} + +func isClaudeWebSearchToolMap(tool map[string]any) bool { + toolType, _ := tool["type"].(string) + if strings.HasPrefix(toolType, "web_search") || toolType == "google_search" { + return true + } + + name, _ := tool["name"].(string) + switch strings.TrimSpace(name) { + case "web_search", "google_search", "web_search_20250305": + return true + default: + return false } } diff --git a/backend/internal/service/gemini_messages_compat_service_test.go b/backend/internal/service/gemini_messages_compat_service_test.go index f659f0e60a..0d132b480f 100644 --- a/backend/internal/service/gemini_messages_compat_service_test.go +++ b/backend/internal/service/gemini_messages_compat_service_test.go @@ -164,6 +164,35 @@ func TestConvertClaudeToolsToGeminiTools_CustomType(t *testing.T) { } } +func TestConvertClaudeToolsToGeminiTools_PreservesWebSearchAlongsideFunctions(t *testing.T) { + tools := []any{ + map[string]any{ + "name": "get_weather", + "description": "Get weather info", + "input_schema": map[string]any{"type": "object"}, + }, + map[string]any{ + "type": "web_search_20250305", + "name": "web_search", + }, + } + + result := convertClaudeToolsToGeminiTools(tools) + require.Len(t, result, 2) + + functionDecl, ok := result[0].(map[string]any) + require.True(t, ok) + funcDecls, ok := functionDecl["functionDeclarations"].([]any) + require.True(t, ok) + require.Len(t, funcDecls, 1) + + searchDecl, ok := result[1].(map[string]any) + require.True(t, ok) + googleSearch, ok := searchDecl["googleSearch"].(map[string]any) + require.True(t, ok) + require.Empty(t, googleSearch) +} + func TestGeminiHandleNativeNonStreamingResponse_DebugDisabledDoesNotEmitHeaderLogs(t *testing.T) { gin.SetMode(gin.TestMode) logSink, restore := captureStructuredLog(t) From dd5978f2224ce6646f4cdb5e3693f7059f26ab90 Mon Sep 17 00:00:00 2001 From: YanzheL Date: Wed, 1 Apr 2026 00:45:56 +0800 Subject: [PATCH 07/59] fix(gemini): normalize ai studio google search tools --- .../service/gemini_messages_compat_service.go | 46 +++++++++++++++++- .../gemini_messages_compat_service_test.go | 47 +++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 45aa359947..a61274d9b8 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -612,7 +612,8 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex fullURL += "?alt=sse" } - upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(geminiReq)) + restGeminiReq := normalizeGeminiRequestForAIStudio(geminiReq) + upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(restGeminiReq)) if err != nil { return nil, "", err } @@ -685,7 +686,8 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex fullURL += "?alt=sse" } - upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(geminiReq)) + restGeminiReq := normalizeGeminiRequestForAIStudio(geminiReq) + upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(restGeminiReq)) if err != nil { return nil, "", err } @@ -3240,6 +3242,46 @@ func convertClaudeToolsToGeminiTools(tools any) []any { return out } +func normalizeGeminiRequestForAIStudio(body []byte) []byte { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return body + } + + tools, ok := payload["tools"].([]any) + if !ok || len(tools) == 0 { + return body + } + + modified := false + for _, rawTool := range tools { + tool, ok := rawTool.(map[string]any) + if !ok { + continue + } + googleSearch, ok := tool["googleSearch"] + if !ok { + continue + } + if _, exists := tool["google_search"]; exists { + continue + } + tool["google_search"] = googleSearch + delete(tool, "googleSearch") + modified = true + } + + if !modified { + return body + } + + normalized, err := json.Marshal(payload) + if err != nil { + return body + } + return normalized +} + func isClaudeWebSearchToolMap(tool map[string]any) bool { toolType, _ := tool["type"].(string) if strings.HasPrefix(toolType, "web_search") || toolType == "google_search" { diff --git a/backend/internal/service/gemini_messages_compat_service_test.go b/backend/internal/service/gemini_messages_compat_service_test.go index 0d132b480f..c2adf45ded 100644 --- a/backend/internal/service/gemini_messages_compat_service_test.go +++ b/backend/internal/service/gemini_messages_compat_service_test.go @@ -261,6 +261,53 @@ func TestGeminiMessagesCompatServiceForward_PreservesRequestedModelAndMappedUpst require.Contains(t, httpStub.lastReq.URL.String(), "/models/claude-sonnet-4-20250514:") } +func TestGeminiMessagesCompatServiceForward_NormalizesWebSearchToolForAIStudio(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + + httpStub := &geminiCompatHTTPUpstreamStub{ + response: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"x-request-id": []string{"gemini-req-2"}}, + Body: io.NopCloser(strings.NewReader(`{"candidates":[{"content":{"parts":[{"text":"hello"}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}`)), + }, + } + svc := &GeminiMessagesCompatService{httpUpstream: httpStub, cfg: &config.Config{}} + account := &Account{ + ID: 1, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-key", + }, + } + body := []byte(`{"model":"claude-sonnet-4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"tools":[{"name":"get_weather","description":"Get weather info","input_schema":{"type":"object"}},{"type":"web_search_20250305","name":"web_search"}]}`) + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, httpStub.lastReq) + + postedBody, err := io.ReadAll(httpStub.lastReq.Body) + require.NoError(t, err) + + var posted map[string]any + require.NoError(t, json.Unmarshal(postedBody, &posted)) + tools, ok := posted["tools"].([]any) + require.True(t, ok) + require.Len(t, tools, 2) + + searchTool, ok := tools[1].(map[string]any) + require.True(t, ok) + _, hasSnake := searchTool["google_search"] + _, hasCamel := searchTool["googleSearch"] + require.True(t, hasSnake) + require.False(t, hasCamel) + _, hasFuncDecl := searchTool["functionDeclarations"] + require.False(t, hasFuncDecl) +} + func TestConvertClaudeMessagesToGeminiGenerateContent_AddsThoughtSignatureForToolUse(t *testing.T) { claudeReq := map[string]any{ "model": "claude-haiku-4-5-20251001", From d978ac97f112321a446e41cb063cd7afd808f51d Mon Sep 17 00:00:00 2001 From: YanzheL Date: Wed, 1 Apr 2026 00:46:14 +0800 Subject: [PATCH 08/59] test(antigravity): cover mixed web search transforms --- .../antigravity/request_transformer_test.go | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/backend/internal/pkg/antigravity/request_transformer_test.go b/backend/internal/pkg/antigravity/request_transformer_test.go index ccbd3e7885..6fae5b7c56 100644 --- a/backend/internal/pkg/antigravity/request_transformer_test.go +++ b/backend/internal/pkg/antigravity/request_transformer_test.go @@ -423,3 +423,36 @@ func TestTransformClaudeToGeminiWithOptions_PreservesBillingHeaderSystemBlock(t }) } } + +func TestTransformClaudeToGeminiWithOptions_PreservesWebSearchAlongsideFunctions(t *testing.T) { + claudeReq := &ClaudeRequest{ + Model: "claude-3-5-sonnet-latest", + Messages: []ClaudeMessage{ + { + Role: "user", + Content: json.RawMessage(`[{"type":"text","text":"hello"}]`), + }, + }, + Tools: []ClaudeTool{ + { + Name: "get_weather", + Description: "Get weather information", + InputSchema: map[string]any{"type": "object"}, + }, + { + Type: "web_search_20250305", + Name: "web_search", + }, + }, + } + + body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "gemini-2.5-flash", DefaultTransformOptions()) + require.NoError(t, err) + + var req V1InternalRequest + require.NoError(t, json.Unmarshal(body, &req)) + require.Len(t, req.Request.Tools, 2) + require.Len(t, req.Request.Tools[0].FunctionDeclarations, 1) + require.Equal(t, "get_weather", req.Request.Tools[0].FunctionDeclarations[0].Name) + require.NotNil(t, req.Request.Tools[1].GoogleSearch) +} From 936fce68d0b814909016ac8fef84e8c35a36a9ca Mon Sep 17 00:00:00 2001 From: YanzheL Date: Wed, 1 Apr 2026 00:46:16 +0800 Subject: [PATCH 09/59] fix(apicompat): skip empty base64 image URLs --- .../chatcompletions_responses_test.go | 44 +++++++++++++++++++ .../apicompat/chatcompletions_to_responses.go | 18 +++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index f54a4a027f..464c26efab 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -181,6 +181,50 @@ func TestChatCompletionsToResponses_ImageURL(t *testing.T) { assert.Equal(t, "data:image/png;base64,abc123", parts[1].ImageURL) } +func TestChatCompletionsToResponses_EmptyBase64ImageURLSkipped(t *testing.T) { + content := `[{"type":"text","text":"Describe this"},{"type":"image_url","image_url":{"url":"data:image/png;base64,"}}]` + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + Messages: []ChatMessage{ + {Role: "user", Content: json.RawMessage(content)}, + }, + } + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + require.Len(t, items, 1) + + var parts []ResponsesContentPart + require.NoError(t, json.Unmarshal(items[0].Content, &parts)) + require.Len(t, parts, 1) + assert.Equal(t, "input_text", parts[0].Type) + assert.Equal(t, "Describe this", parts[0].Text) +} + +func TestChatCompletionsToResponses_WhitespaceOnlyBase64ImageURLSkipped(t *testing.T) { + content := `[{"type":"text","text":"Describe this"},{"type":"image_url","image_url":{"url":"data:image/png;base64, "}}]` + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + Messages: []ChatMessage{ + {Role: "user", Content: json.RawMessage(content)}, + }, + } + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + require.Len(t, items, 1) + + var parts []ResponsesContentPart + require.NoError(t, json.Unmarshal(items[0].Content, &parts)) + require.Len(t, parts, 1) + assert.Equal(t, "input_text", parts[0].Type) + assert.Equal(t, "Describe this", parts[0].Text) +} + func TestChatCompletionsToResponses_SystemArrayContent(t *testing.T) { req := &ChatCompletionsRequest{ Model: "gpt-4o", diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index 6cdd012a49..dc157a6d3c 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -339,7 +339,7 @@ func convertChatContentPartsToResponses(parts []ChatContentPart) []ResponsesCont }) } case "image_url": - if p.ImageURL != nil && p.ImageURL.URL != "" { + if p.ImageURL != nil && p.ImageURL.URL != "" && !isEmptyBase64DataURI(p.ImageURL.URL) { responseParts = append(responseParts, ResponsesContentPart{ Type: "input_image", ImageURL: p.ImageURL.URL, @@ -350,6 +350,22 @@ func convertChatContentPartsToResponses(parts []ChatContentPart) []ResponsesCont return responseParts } +func isEmptyBase64DataURI(raw string) bool { + if !strings.HasPrefix(raw, "data:") { + return false + } + rest := strings.TrimPrefix(raw, "data:") + semicolonIdx := strings.Index(rest, ";") + if semicolonIdx < 0 { + return false + } + rest = rest[semicolonIdx+1:] + if !strings.HasPrefix(rest, "base64,") { + return false + } + return strings.TrimSpace(strings.TrimPrefix(rest, "base64,")) == "" +} + func flattenChatContentParts(parts []ChatContentPart) string { var textParts []string for _, p := range parts { From f00351c1060e915e815a4f3ee0e69bfce3908254 Mon Sep 17 00:00:00 2001 From: YanzheL Date: Wed, 1 Apr 2026 00:46:38 +0800 Subject: [PATCH 10/59] fix(openai): sanitize empty base64 input images --- .../service/openai_gateway_service.go | 130 ++++++++++++++++++ .../openai_gateway_service_hotpath_test.go | 59 ++++++++ 2 files changed, 189 insertions(+) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 0a95961512..ff0a8968e9 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -1931,6 +1931,11 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } + if sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody) { + bodyModified = true + disablePatch() + } + // Re-serialize body only if modified if bodyModified { serializedByPatch := false @@ -2358,6 +2363,14 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( reqStream = gjson.GetBytes(body, "stream").Bool() } + sanitizedBody, sanitized, err := sanitizeEmptyBase64InputImagesInOpenAIBody(body) + if err != nil { + return nil, err + } + if sanitized { + body = sanitizedBody + } + logger.LegacyPrintf("service.openai_gateway", "[OpenAI 自动透传] 命中自动透传分支: account=%d name=%s type=%s model=%s stream=%v", account.ID, @@ -4691,6 +4704,123 @@ func normalizeOpenAIServiceTier(raw string) *string { } } +func sanitizeEmptyBase64InputImagesInOpenAIBody(body []byte) ([]byte, bool, error) { + if len(body) == 0 || !bytes.Contains(body, []byte(`"image_url"`)) || !bytes.Contains(body, []byte(`base64,`)) { + return body, false, nil + } + + var reqBody map[string]any + if err := json.Unmarshal(body, &reqBody); err != nil { + return body, false, fmt.Errorf("sanitize request body: %w", err) + } + if !sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody) { + return body, false, nil + } + normalized, err := json.Marshal(reqBody) + if err != nil { + return body, false, fmt.Errorf("serialize sanitized request body: %w", err) + } + return normalized, true, nil +} + +func sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody map[string]any) bool { + if reqBody == nil { + return false + } + input, ok := reqBody["input"] + if !ok { + return false + } + normalizedInput, changed := sanitizeEmptyBase64InputImagesInOpenAIInput(input) + if !changed { + return false + } + reqBody["input"] = normalizedInput + return true +} + +func sanitizeEmptyBase64InputImagesInOpenAIInput(input any) (any, bool) { + items, ok := input.([]any) + if !ok { + return input, false + } + + normalizedItems := make([]any, 0, len(items)) + changed := false + for _, item := range items { + itemMap, ok := item.(map[string]any) + if !ok { + normalizedItems = append(normalizedItems, item) + continue + } + if shouldDropEmptyBase64InputImagePart(itemMap) { + changed = true + continue + } + content, ok := itemMap["content"] + if !ok { + normalizedItems = append(normalizedItems, itemMap) + continue + } + parts, ok := content.([]any) + if !ok { + normalizedItems = append(normalizedItems, itemMap) + continue + } + + normalizedParts := make([]any, 0, len(parts)) + itemChanged := false + for _, part := range parts { + if shouldDropEmptyBase64InputImagePart(part) { + changed = true + itemChanged = true + continue + } + normalizedParts = append(normalizedParts, part) + } + if itemChanged { + if len(normalizedParts) == 0 { + continue + } + itemMap["content"] = normalizedParts + } + normalizedItems = append(normalizedItems, itemMap) + } + if !changed { + return input, false + } + return normalizedItems, true +} + +func shouldDropEmptyBase64InputImagePart(part any) bool { + partMap, ok := part.(map[string]any) + if !ok { + return false + } + typeValue, _ := partMap["type"].(string) + if strings.TrimSpace(typeValue) != "input_image" { + return false + } + imageURL, _ := partMap["image_url"].(string) + return isEmptyBase64DataURI(imageURL) +} + +func isEmptyBase64DataURI(raw string) bool { + if !strings.HasPrefix(raw, "data:") { + return false + } + rest := strings.TrimPrefix(raw, "data:") + semicolonIdx := strings.Index(rest, ";") + if semicolonIdx < 0 { + return false + } + rest = rest[semicolonIdx+1:] + if !strings.HasPrefix(rest, "base64,") { + return false + } + return strings.TrimSpace(strings.TrimPrefix(rest, "base64,")) == "" +} + func getOpenAIRequestBodyMap(c *gin.Context, body []byte) (map[string]any, error) { if c != nil { if cached, ok := c.Get(OpenAIParsedRequestBodyKey); ok { diff --git a/backend/internal/service/openai_gateway_service_hotpath_test.go b/backend/internal/service/openai_gateway_service_hotpath_test.go index f73c06c5e1..234dee00cf 100644 --- a/backend/internal/service/openai_gateway_service_hotpath_test.go +++ b/backend/internal/service/openai_gateway_service_hotpath_test.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "net/http/httptest" "testing" @@ -139,3 +140,61 @@ func TestGetOpenAIRequestBodyMap_WriteBackContextCache(t *testing.T) { require.True(t, ok) require.Equal(t, got, cachedMap) } + +func TestSanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(t *testing.T) { + var reqBody map[string]any + require.NoError(t, json.Unmarshal([]byte(`{ + "model":"gpt-5.4", + "input":[ + {"role":"user","content":[ + {"type":"input_text","text":"Describe this"}, + {"type":"input_image","image_url":"data:image/png;base64, "}, + {"type":"input_image","image_url":"data:image/png;base64,abc123"} + ]}, + {"role":"user","content":[ + {"type":"input_image","image_url":"data:image/png;base64,"} + ]}, + {"type":"input_image","image_url":"data:image/png;base64,"}, + {"type":"input_image","image_url":"data:image/png;base64,top-level-valid"} + ] + }`), &reqBody)) + + require.True(t, sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody)) + + normalized, err := json.Marshal(reqBody) + require.NoError(t, err) + require.JSONEq(t, `{ + "model":"gpt-5.4", + "input":[ + {"role":"user","content":[ + {"type":"input_text","text":"Describe this"}, + {"type":"input_image","image_url":"data:image/png;base64,abc123"} + ]}, + {"type":"input_image","image_url":"data:image/png;base64,top-level-valid"} + ] + }`, string(normalized)) +} + +func TestSanitizeEmptyBase64InputImagesInOpenAIBody(t *testing.T) { + body, changed, err := sanitizeEmptyBase64InputImagesInOpenAIBody([]byte(`{ + "model":"gpt-5.4", + "stream":true, + "input":[ + {"role":"user","content":[ + {"type":"input_text","text":"Describe this"}, + {"type":"input_image","image_url":"data:image/png;base64,"} + ]} + ] + }`)) + require.NoError(t, err) + require.True(t, changed) + require.JSONEq(t, `{ + "model":"gpt-5.4", + "stream":true, + "input":[ + {"role":"user","content":[ + {"type":"input_text","text":"Describe this"} + ]} + ] + }`, string(body)) +} From c5aac1251d5496ea21f2b1bc09bcf7cee5eea687 Mon Sep 17 00:00:00 2001 From: YanzheL Date: Thu, 2 Apr 2026 00:11:06 +0800 Subject: [PATCH 11/59] fix(gateway): add content-based session hash fallback for non-Codex clients When no explicit session signals (session_id, conversation_id, prompt_cache_key) are provided, derive a stable session seed from the request body content (model + tools + system prompt + first user message) to enable sticky routing and prompt caching for non-Codex clients using the Chat Completions API. This mirrors the content-based fallback already present in GatewayService. GenerateSessionHash, adapted for the OpenAI gateway's request formats (both Chat Completions messages and Responses API input). JSON fragments are canonicalized via normalizeCompatSeedJSON to ensure semantically identical requests produce the same seed regardless of whitespace or key ordering. Closes #1421 --- .../service/openai_content_session_seed.go | 107 ++++++++++++++++++ .../service/openai_gateway_service.go | 4 + 2 files changed, 111 insertions(+) create mode 100644 backend/internal/service/openai_content_session_seed.go diff --git a/backend/internal/service/openai_content_session_seed.go b/backend/internal/service/openai_content_session_seed.go new file mode 100644 index 0000000000..cb8fcb8411 --- /dev/null +++ b/backend/internal/service/openai_content_session_seed.go @@ -0,0 +1,107 @@ +package service + +import ( + "encoding/json" + "strings" + + "github.com/tidwall/gjson" +) + +// contentSessionSeedPrefix prevents collisions between content-derived seeds +// and explicit session IDs (e.g. "sess-xxx" or "compat_cc_xxx"). +const contentSessionSeedPrefix = "compat_cs_" + +// deriveOpenAIContentSessionSeed builds a stable session seed from an +// OpenAI-format request body. Only fields constant across conversation turns +// are included: model, tools/functions definitions, system/developer prompts, +// instructions (Responses API), and the first user message. +// Supports both Chat Completions (messages) and Responses API (input). +func deriveOpenAIContentSessionSeed(body []byte) string { + if len(body) == 0 { + return "" + } + + var b strings.Builder + + if model := gjson.GetBytes(body, "model").String(); model != "" { + b.WriteString("model=") + b.WriteString(model) + } + + if tools := gjson.GetBytes(body, "tools"); tools.Exists() && tools.IsArray() && tools.Raw != "[]" { + b.WriteString("|tools=") + b.WriteString(normalizeCompatSeedJSON(json.RawMessage(tools.Raw))) + } + + if funcs := gjson.GetBytes(body, "functions"); funcs.Exists() && funcs.IsArray() && funcs.Raw != "[]" { + b.WriteString("|functions=") + b.WriteString(normalizeCompatSeedJSON(json.RawMessage(funcs.Raw))) + } + + if instr := gjson.GetBytes(body, "instructions").String(); instr != "" { + b.WriteString("|instructions=") + b.WriteString(instr) + } + + firstUserCaptured := false + + msgs := gjson.GetBytes(body, "messages") + if msgs.Exists() && msgs.IsArray() { + msgs.ForEach(func(_, msg gjson.Result) bool { + role := msg.Get("role").String() + switch role { + case "system", "developer": + b.WriteString("|system=") + if c := msg.Get("content"); c.Exists() { + b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) + } + case "user": + if !firstUserCaptured { + b.WriteString("|first_user=") + if c := msg.Get("content"); c.Exists() { + b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) + } + firstUserCaptured = true + } + } + return true + }) + } else if inp := gjson.GetBytes(body, "input"); inp.Exists() { + if inp.Type == gjson.String { + b.WriteString("|input=") + b.WriteString(inp.String()) + } else if inp.IsArray() { + inp.ForEach(func(_, item gjson.Result) bool { + role := item.Get("role").String() + switch role { + case "system", "developer": + b.WriteString("|system=") + if c := item.Get("content"); c.Exists() { + b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) + } + case "user": + if !firstUserCaptured { + b.WriteString("|first_user=") + if c := item.Get("content"); c.Exists() { + b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) + } + firstUserCaptured = true + } + } + if !firstUserCaptured && item.Get("type").String() == "input_text" { + b.WriteString("|first_user=") + if text := item.Get("text").String(); text != "" { + b.WriteString(text) + } + firstUserCaptured = true + } + return true + }) + } + } + + if b.Len() == 0 { + return "" + } + return contentSessionSeedPrefix + b.String() +} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 0a95961512..b9f42cd75c 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -1044,6 +1044,7 @@ func (s *OpenAIGatewayService) ExtractSessionID(c *gin.Context, body []byte) str // 1. Header: session_id // 2. Header: conversation_id // 3. Body: prompt_cache_key (opencode) +// 4. Body: content-based fallback (model + system + tools + first user message) func (s *OpenAIGatewayService) GenerateSessionHash(c *gin.Context, body []byte) string { if c == nil { return "" @@ -1056,6 +1057,9 @@ func (s *OpenAIGatewayService) GenerateSessionHash(c *gin.Context, body []byte) if sessionID == "" && len(body) > 0 { sessionID = strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String()) } + if sessionID == "" && len(body) > 0 { + sessionID = deriveOpenAIContentSessionSeed(body) + } if sessionID == "" { return "" } From 4fb16030016d4450b6551184982073b718687bcc Mon Sep 17 00:00:00 2001 From: YanzheL Date: Thu, 2 Apr 2026 00:11:17 +0800 Subject: [PATCH 12/59] test(gateway): add tests for content-based session hash fallback - 20 unit tests for deriveOpenAIContentSessionSeed covering: - Empty/nil inputs, model-only, stable across turns - Different model/system/first-user produce different seeds - Tools, functions, developer role, structured content - Responses API: input string, input array, instructions, input_text typed items - JSON canonicalization (whitespace/key-order insensitive) - Prefix presence, empty tools ignored, messages preferred over input - 3 integration tests for GenerateSessionHash content fallback: - Content fallback produces stable hash - Explicit signals override content fallback - Empty body still returns empty hash --- .../openai_content_session_seed_test.go | 218 ++++++++++++++++++ .../service/openai_gateway_service_test.go | 54 +++++ 2 files changed, 272 insertions(+) create mode 100644 backend/internal/service/openai_content_session_seed_test.go diff --git a/backend/internal/service/openai_content_session_seed_test.go b/backend/internal/service/openai_content_session_seed_test.go new file mode 100644 index 0000000000..65a0bf1808 --- /dev/null +++ b/backend/internal/service/openai_content_session_seed_test.go @@ -0,0 +1,218 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDeriveOpenAIContentSessionSeed_EmptyInputs(t *testing.T) { + require.Empty(t, deriveOpenAIContentSessionSeed(nil)) + require.Empty(t, deriveOpenAIContentSessionSeed([]byte{})) + require.Empty(t, deriveOpenAIContentSessionSeed([]byte(`{}`))) +} + +func TestDeriveOpenAIContentSessionSeed_ModelOnly(t *testing.T) { + seed := deriveOpenAIContentSessionSeed([]byte(`{"model":"gpt-5.4"}`)) + require.Contains(t, seed, contentSessionSeedPrefix) + require.Contains(t, seed, "model=gpt-5.4") +} + +func TestDeriveOpenAIContentSessionSeed_ChatCompletions_StableAcrossTurns(t *testing.T) { + turn1 := []byte(`{ + "model": "gpt-5.4", + "messages": [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"} + ] + }`) + turn2 := []byte(`{ + "model": "gpt-5.4", + "messages": [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + {"role": "user", "content": "How are you?"} + ] + }`) + s1 := deriveOpenAIContentSessionSeed(turn1) + s2 := deriveOpenAIContentSessionSeed(turn2) + require.Equal(t, s1, s2, "seed should be stable across later turns") + require.NotEmpty(t, s1) +} + +func TestDeriveOpenAIContentSessionSeed_ChatCompletions_DifferentFirstUserDiffers(t *testing.T) { + req1 := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"Question A"}]}`) + req2 := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"Question B"}]}`) + s1 := deriveOpenAIContentSessionSeed(req1) + s2 := deriveOpenAIContentSessionSeed(req2) + require.NotEqual(t, s1, s2) +} + +func TestDeriveOpenAIContentSessionSeed_ChatCompletions_DifferentSystemDiffers(t *testing.T) { + req1 := []byte(`{"model":"gpt-5.4","messages":[{"role":"system","content":"A"},{"role":"user","content":"Hi"}]}`) + req2 := []byte(`{"model":"gpt-5.4","messages":[{"role":"system","content":"B"},{"role":"user","content":"Hi"}]}`) + s1 := deriveOpenAIContentSessionSeed(req1) + s2 := deriveOpenAIContentSessionSeed(req2) + require.NotEqual(t, s1, s2) +} + +func TestDeriveOpenAIContentSessionSeed_ChatCompletions_DifferentModelDiffers(t *testing.T) { + req1 := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"Hi"}]}`) + req2 := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"Hi"}]}`) + s1 := deriveOpenAIContentSessionSeed(req1) + s2 := deriveOpenAIContentSessionSeed(req2) + require.NotEqual(t, s1, s2) +} + +func TestDeriveOpenAIContentSessionSeed_ChatCompletions_WithTools(t *testing.T) { + withTools := []byte(`{ + "model": "gpt-5.4", + "tools": [{"type":"function","function":{"name":"get_weather"}}], + "messages": [{"role": "user", "content": "Hello"}] + }`) + withoutTools := []byte(`{ + "model": "gpt-5.4", + "messages": [{"role": "user", "content": "Hello"}] + }`) + s1 := deriveOpenAIContentSessionSeed(withTools) + s2 := deriveOpenAIContentSessionSeed(withoutTools) + require.NotEqual(t, s1, s2, "tools should affect the seed") + require.Contains(t, s1, "|tools=") +} + +func TestDeriveOpenAIContentSessionSeed_ChatCompletions_WithFunctions(t *testing.T) { + body := []byte(`{ + "model": "gpt-5.4", + "functions": [{"name":"get_weather","parameters":{}}], + "messages": [{"role": "user", "content": "Hello"}] + }`) + seed := deriveOpenAIContentSessionSeed(body) + require.Contains(t, seed, "|functions=") +} + +func TestDeriveOpenAIContentSessionSeed_ChatCompletions_DeveloperRole(t *testing.T) { + body := []byte(`{ + "model": "gpt-5.4", + "messages": [ + {"role": "developer", "content": "You are helpful."}, + {"role": "user", "content": "Hello"} + ] + }`) + seed := deriveOpenAIContentSessionSeed(body) + require.Contains(t, seed, "|system=") + require.Contains(t, seed, "|first_user=") +} + +func TestDeriveOpenAIContentSessionSeed_ChatCompletions_StructuredContent(t *testing.T) { + body := []byte(`{ + "model": "gpt-5.4", + "messages": [ + {"role": "user", "content": [{"type":"text","text":"Hello"}]} + ] + }`) + seed := deriveOpenAIContentSessionSeed(body) + require.NotEmpty(t, seed) + require.Contains(t, seed, "|first_user=") +} + +func TestDeriveOpenAIContentSessionSeed_ResponsesAPI_InputString(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","input":"Hello, how are you?"}`) + seed := deriveOpenAIContentSessionSeed(body) + require.Contains(t, seed, "|input=Hello, how are you?") +} + +func TestDeriveOpenAIContentSessionSeed_ResponsesAPI_InputArray(t *testing.T) { + body := []byte(`{ + "model": "gpt-5.4", + "input": [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"} + ] + }`) + seed := deriveOpenAIContentSessionSeed(body) + require.Contains(t, seed, "|system=") + require.Contains(t, seed, "|first_user=") +} + +func TestDeriveOpenAIContentSessionSeed_ResponsesAPI_WithInstructions(t *testing.T) { + body := []byte(`{ + "model": "gpt-5.4", + "instructions": "You are a coding assistant.", + "input": "Write a hello world" + }`) + seed := deriveOpenAIContentSessionSeed(body) + require.Contains(t, seed, "|instructions=You are a coding assistant.") + require.Contains(t, seed, "|input=Write a hello world") +} + +func TestDeriveOpenAIContentSessionSeed_Deterministic(t *testing.T) { + body := []byte(`{ + "model": "gpt-5.4", + "messages": [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"} + ] + }`) + s1 := deriveOpenAIContentSessionSeed(body) + s2 := deriveOpenAIContentSessionSeed(body) + require.Equal(t, s1, s2, "seed must be deterministic") +} + +func TestDeriveOpenAIContentSessionSeed_PrefixPresent(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"Hi"}]}`) + seed := deriveOpenAIContentSessionSeed(body) + require.True(t, len(seed) > len(contentSessionSeedPrefix)) + require.Equal(t, contentSessionSeedPrefix, seed[:len(contentSessionSeedPrefix)]) +} + +func TestDeriveOpenAIContentSessionSeed_EmptyToolsIgnored(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","tools":[],"messages":[{"role":"user","content":"Hi"}]}`) + seed := deriveOpenAIContentSessionSeed(body) + require.NotContains(t, seed, "|tools=") +} + +func TestDeriveOpenAIContentSessionSeed_MessagesPreferredOverInput(t *testing.T) { + body := []byte(`{ + "model": "gpt-5.4", + "messages": [{"role": "user", "content": "from messages"}], + "input": "from input" + }`) + seed := deriveOpenAIContentSessionSeed(body) + require.Contains(t, seed, "|first_user=") + require.NotContains(t, seed, "|input=") +} + +func TestDeriveOpenAIContentSessionSeed_JSONCanonicalisation(t *testing.T) { + compact := []byte(`{"model":"gpt-5.4","tools":[{"type":"function","function":{"name":"get_weather","description":"Get weather"}}],"messages":[{"role":"user","content":"Hi"}]}`) + spaced := []byte(`{ + "model": "gpt-5.4", + "tools": [ + { "type" : "function", "function": { "description": "Get weather", "name": "get_weather" } } + ], + "messages": [ { "role": "user", "content": "Hi" } ] + }`) + s1 := deriveOpenAIContentSessionSeed(compact) + s2 := deriveOpenAIContentSessionSeed(spaced) + require.Equal(t, s1, s2, "different formatting of identical JSON should produce the same seed") +} + +func TestDeriveOpenAIContentSessionSeed_ResponsesAPI_InputTextTypedItem(t *testing.T) { + body := []byte(`{ + "model": "gpt-5.4", + "input": [{"type": "input_text", "text": "Hello world"}] + }`) + seed := deriveOpenAIContentSessionSeed(body) + require.Contains(t, seed, "|first_user=") + require.Contains(t, seed, "Hello world") +} + +func TestDeriveOpenAIContentSessionSeed_ResponsesAPI_TypedMessageItem(t *testing.T) { + body := []byte(`{ + "model": "gpt-5.4", + "input": [{"type": "message", "role": "user", "content": "Hello from typed message"}] + }`) + seed := deriveOpenAIContentSessionSeed(body) + require.Contains(t, seed, "|first_user=") + require.Contains(t, seed, "Hello from typed message") +} diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 9e2f33f22a..71b7acf102 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -237,6 +237,60 @@ func TestOpenAIGatewayService_GenerateSessionHashWithFallback(t *testing.T) { require.Equal(t, "", empty) } +func TestOpenAIGatewayService_GenerateSessionHash_ContentFallback(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/chat/completions", nil) + + svc := &OpenAIGatewayService{} + + body := []byte(`{"model":"gpt-5.4","messages":[{"role":"system","content":"You are helpful."},{"role":"user","content":"Hello"}]}`) + + hash := svc.GenerateSessionHash(c, body) + require.NotEmpty(t, hash, "content-based fallback should produce a hash") + + hash2 := svc.GenerateSessionHash(c, body) + require.Equal(t, hash, hash2, "same content should produce same hash") + + bodyExtended := []byte(`{"model":"gpt-5.4","messages":[{"role":"system","content":"You are helpful."},{"role":"user","content":"Hello"},{"role":"assistant","content":"Hi!"},{"role":"user","content":"How are you?"}]}`) + hashExtended := svc.GenerateSessionHash(c, bodyExtended) + require.Equal(t, hash, hashExtended, "hash should be stable across later turns") + + bodyDifferent := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"Different question"}]}`) + hashDifferent := svc.GenerateSessionHash(c, bodyDifferent) + require.NotEqual(t, hash, hashDifferent, "different content should produce different hash") +} + +func TestOpenAIGatewayService_GenerateSessionHash_ExplicitSignalWinsOverContent(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/chat/completions", nil) + + svc := &OpenAIGatewayService{} + body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"Hello"}]}`) + + contentHash := svc.GenerateSessionHash(c, body) + require.NotEmpty(t, contentHash) + + c.Request.Header.Set("session_id", "explicit-session") + explicitHash := svc.GenerateSessionHash(c, body) + require.NotEmpty(t, explicitHash) + require.NotEqual(t, contentHash, explicitHash, "explicit session_id should override content fallback") +} + +func TestOpenAIGatewayService_GenerateSessionHash_EmptyBodyStillEmpty(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/chat/completions", nil) + + svc := &OpenAIGatewayService{} + require.Empty(t, svc.GenerateSessionHash(c, []byte(`{}`))) + require.Empty(t, svc.GenerateSessionHash(c, nil)) +} + func (c stubConcurrencyCache) GetAccountWaitingCount(ctx context.Context, accountID int64) (int, error) { if c.waitCounts != nil { if count, ok := c.waitCounts[accountID]; ok { From cf9efefd963bb8d6daff210b50d2a0db98b3d5d2 Mon Sep 17 00:00:00 2001 From: YanzheL Date: Thu, 2 Apr 2026 01:03:22 +0800 Subject: [PATCH 13/59] fix(lint): satisfy errcheck for strings.Builder.WriteString calls --- .../service/openai_content_session_seed.go | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/backend/internal/service/openai_content_session_seed.go b/backend/internal/service/openai_content_session_seed.go index cb8fcb8411..7c2ba25140 100644 --- a/backend/internal/service/openai_content_session_seed.go +++ b/backend/internal/service/openai_content_session_seed.go @@ -24,23 +24,23 @@ func deriveOpenAIContentSessionSeed(body []byte) string { var b strings.Builder if model := gjson.GetBytes(body, "model").String(); model != "" { - b.WriteString("model=") - b.WriteString(model) + _, _ = b.WriteString("model=") + _, _ = b.WriteString(model) } if tools := gjson.GetBytes(body, "tools"); tools.Exists() && tools.IsArray() && tools.Raw != "[]" { - b.WriteString("|tools=") - b.WriteString(normalizeCompatSeedJSON(json.RawMessage(tools.Raw))) + _, _ = b.WriteString("|tools=") + _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(tools.Raw))) } if funcs := gjson.GetBytes(body, "functions"); funcs.Exists() && funcs.IsArray() && funcs.Raw != "[]" { - b.WriteString("|functions=") - b.WriteString(normalizeCompatSeedJSON(json.RawMessage(funcs.Raw))) + _, _ = b.WriteString("|functions=") + _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(funcs.Raw))) } if instr := gjson.GetBytes(body, "instructions").String(); instr != "" { - b.WriteString("|instructions=") - b.WriteString(instr) + _, _ = b.WriteString("|instructions=") + _, _ = b.WriteString(instr) } firstUserCaptured := false @@ -51,15 +51,15 @@ func deriveOpenAIContentSessionSeed(body []byte) string { role := msg.Get("role").String() switch role { case "system", "developer": - b.WriteString("|system=") + _, _ = b.WriteString("|system=") if c := msg.Get("content"); c.Exists() { - b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) + _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) } case "user": if !firstUserCaptured { - b.WriteString("|first_user=") + _, _ = b.WriteString("|first_user=") if c := msg.Get("content"); c.Exists() { - b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) + _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) } firstUserCaptured = true } @@ -68,30 +68,30 @@ func deriveOpenAIContentSessionSeed(body []byte) string { }) } else if inp := gjson.GetBytes(body, "input"); inp.Exists() { if inp.Type == gjson.String { - b.WriteString("|input=") - b.WriteString(inp.String()) + _, _ = b.WriteString("|input=") + _, _ = b.WriteString(inp.String()) } else if inp.IsArray() { inp.ForEach(func(_, item gjson.Result) bool { role := item.Get("role").String() switch role { case "system", "developer": - b.WriteString("|system=") + _, _ = b.WriteString("|system=") if c := item.Get("content"); c.Exists() { - b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) + _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) } case "user": if !firstUserCaptured { - b.WriteString("|first_user=") + _, _ = b.WriteString("|first_user=") if c := item.Get("content"); c.Exists() { - b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) + _, _ = b.WriteString(normalizeCompatSeedJSON(json.RawMessage(c.Raw))) } firstUserCaptured = true } } if !firstUserCaptured && item.Get("type").String() == "input_text" { - b.WriteString("|first_user=") + _, _ = b.WriteString("|first_user=") if text := item.Get("text").String(); text != "" { - b.WriteString(text) + _, _ = b.WriteString(text) } firstUserCaptured = true } From 58df2f0bdc44fcef0c68eae0a3fcafeb7aeb9281 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:12:15 +0000 Subject: [PATCH 14/59] chore: sync VERSION to 0.1.108 [skip ci] --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index c72cf6b05f..68a31b00ed 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.107 +0.1.108 From f47c865555521b0e13b1eb8672cfbde7a02402ae Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 5 Apr 2026 22:26:55 +0800 Subject: [PATCH 15/59] chore: update readme --- README.md | 11 +++++++++++ README_CN.md | 12 ++++++++++++ README_JA.md | 10 ++++++++++ assets/partners/logos/ctok.png | Bin 0 -> 252080 bytes assets/partners/logos/poixe.png | Bin 0 -> 39559 bytes 5 files changed, 33 insertions(+) create mode 100644 assets/partners/logos/ctok.png create mode 100644 assets/partners/logos/poixe.png diff --git a/README.md b/README.md index 99753e4569..21084c3d23 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,21 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot pincc PinCC is the official relay service built on Sub2API, offering stable access to Claude Code, Codex, Gemini and other popular models — ready to use, no deployment or maintenance required. + PackyCode Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using this link and enter the "sub2api" promo code during first recharge to get 10% off. + + +PoixeAi +Thanks to Poixe Ai for sponsoring this project! Poixe AI provides reliable LLM API services. You can leverage the platform's API endpoints to seamlessly build AI-powered products. Additionally, you can become a vendor by providing AI API resources to the platform and earn revenue. Register through the exclusive sub2api referral link and receive a bonus of $5 USD on your first top-up. + + + +CTok +Thanks to CTok.ai for sponsoring this project! CTok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click here to register! + ## Ecosystem diff --git a/README_CN.md b/README_CN.md index 8b6feaba0d..86f110d3bc 100644 --- a/README_CN.md +++ b/README_CN.md @@ -51,10 +51,22 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 pincc PinCC 是基于 Sub2API 搭建的官方中转服务,提供 Claude Code、Codex、Gemini 等主流模型的稳定中转,开箱即用,免去自建部署与运维烦恼。 + PackyCode 感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用此链接注册并在充值时填写"sub2api"优惠码,首次充值可以享受9折优惠! + + +PoixeAI +感谢 Poixe AI 赞助了本项目!Poixe AI 提供可靠的 AI 模型接口服务,您可以使用平台提供的 LLM API 接口轻松构建 AI 产品,同时也可以成为供应商,为平台提供大模型资源以赚取收益。通过 此链接 专属链接注册,充值额外赠送 $5 美金 + + + +CTok +感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击这里注册! + + ## 生态项目 diff --git a/README_JA.md b/README_JA.md index 1266bd845c..dbb23d8160 100644 --- a/README_JA.md +++ b/README_JA.md @@ -56,6 +56,16 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを PackyCode PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:このリンクで登録し、チャージ時に「sub2api」クーポンを入力すると 10% オフになります。 + + +PoixeAi +Poixe AI のご支援に感謝します!Poixe AI は信頼性の高い LLM API サービスを提供しています。プラットフォームの API エンドポイントを活用して、AI 搭載プロダクトをシームレスに構築できます。また、ベンダーとして AI API リソースをプラットフォームに提供し、収益を得ることも可能です。専用の sub2api 紹介リンクから登録すると、初回チャージ時に $5 USD のボーナスがもらえます。 + + + +CTok +CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code の専用プランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex もサポートしています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシスト プログラミングを真の生産性向上ツールにします。こちらから登録! + ## エコシステム diff --git a/assets/partners/logos/ctok.png b/assets/partners/logos/ctok.png new file mode 100644 index 0000000000000000000000000000000000000000..cf6fcf170670c8537603cf5d32dcbce46d5528cc GIT binary patch literal 252080 zcmbTd1yCK`)+UU*1PJa2ch}$?+zAJVUXD?r?diAc3P*s*eML)%4LNm0<$!H(6~%)tcA>Tc)wj)sB~5_NYpHnjn}P?&%% ztn7sWXTLiD6jo-!04*LRb|ptiu%(r(r!)Aor?Q5rr;VwA89-EoLdae4oq!$K#hAj~ z&ek3x=q?QS7hS>k@jtiO02Kd%xY!5-#Q$MPp{=A!A?e@@rVwD|U@>K9=cM2hU^U@0 z<>fXtH5Xu};AH3IW@G1LY;t_J6ec7tqY~KR8EMXWM@U7uFj@lDYthqss7Q%QBcwuZ0zFTtl{8b``^A&{cn*eIC)vwDd?4q zO|9(zQG(%rngRwIyMToO?|$QDVdrAuVAo*h5ai((WM^k$|L6E$P$dU5D|3(kEmS~| zN8o>fzQ>K3v5WElE7;6b(A>e<&iGwrD?4KgFq@;j1%To|h7^=^uyt^LXZ$W5*T1At zQWBK2hqxHqn}X$l!hm(t-*dQA|F$Rgu!gFzsrP zV3*vdeUaN$mn+dPepW5Hq>5moEJkUkR#xmJh^sd3+%r-0Z$^QU7^IlYY{0^87uJ}wy%X=!U1Bbe!lE5JxSrR-Z`s10BERczsqyY5#x)m z;nVJ^uzbF&uXGngTNaCwbzhJ

$ixVXLHa#6n@*cB?*B*%YV1VHL+B0C|yaJqZiL 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 v3|P(&T^5Z3rRrR4GN13=xu{WNeTr6dBS$ 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 literal 0 HcmV?d00001 From 339d906e547e34fd7536586275205cb355817729 Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 5 Apr 2026 22:31:01 +0800 Subject: [PATCH 16/59] chore: update readme --- README.md | 4 +++- README_CN.md | 4 +++- README_JA.md | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 21084c3d23..50611a6d0e 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,9 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot - **Admin Dashboard** - Web interface for monitoring and management - **External System Integration** - Embed external systems (e.g. payment, ticketing) via iframe to extend the admin dashboard -## Don't Want to Self-Host? +## ❤️ Sponsors + +> [Want to appear here?](mailto:support@pincc.ai) diff --git a/README_CN.md b/README_CN.md index 86f110d3bc..797f106b99 100644 --- a/README_CN.md +++ b/README_CN.md @@ -44,7 +44,9 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 - **管理后台** - Web 界面进行监控和管理 - **外部系统集成** - 支持通过 iframe 嵌入外部系统(如支付、工单等),扩展管理后台功能 -## 不想自建?试试官方中转 +## ❤️ 赞助商 + +> [想出现在这里?](mailto:support@pincc.ai)
diff --git a/README_JA.md b/README_JA.md index dbb23d8160..b7820554d7 100644 --- a/README_JA.md +++ b/README_JA.md @@ -45,7 +45,9 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを - **管理ダッシュボード** - 監視・管理のための Web インターフェース - **外部システム連携** - 外部システム(決済、チケット管理など)を iframe 経由で管理ダッシュボードに埋め込み可能 -## セルフホストが不要な方へ +## ❤️ スポンサー + +> [こちらに掲載しませんか?](mailto:support@pincc.ai)
From 9151d34d4078a772010f11396e21b4f02fc38c42 Mon Sep 17 00:00:00 2001 From: erio Date: Sun, 5 Apr 2026 22:05:13 +0800 Subject: [PATCH 17/59] refactor(channel): split long functions, extract shared validation, move billing validation to service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split Update (98→25 lines), buildCache (54→20 lines), Create (51→25 lines) into focused sub-functions: applyUpdateInput, checkGroupConflicts, fetchChannelData, populateChannelCache, storeErrorCache, getOldGroupIDs, invalidateAuthCacheForGroups - Extract validateChannelConfig to eliminate duplicated validation calls between Create and Update - Move validatePricingBillingMode from handler to service layer for proper separation of concerns - Add error logging to IsModelRestricted (was silently swallowing errors) - Add 12 new tests: ToUsageFields, billing mode validation, antigravity wildcard mapping isolation, Create/Update mapping conflict integration --- .../internal/handler/admin/channel_handler.go | 65 ---- .../handler/admin/channel_handler_test.go | 100 ------ backend/internal/service/channel_service.go | 337 +++++++++++------- .../internal/service/channel_service_test.go | 204 +++++++++++ 4 files changed, 419 insertions(+), 287 deletions(-) diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index 563a27cea4..b503e5c36e 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -1,8 +1,6 @@ package admin import ( - "errors" - "fmt" "strconv" "strings" @@ -235,61 +233,6 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe return result } -// validatePricingBillingMode 校验计费配置 -func validatePricingBillingMode(pricing []service.ChannelModelPricing) error { - for _, p := range pricing { - // 按次/图片模式必须配置默认价格或区间 - if p.BillingMode == service.BillingModePerRequest || p.BillingMode == service.BillingModeImage { - if p.PerRequestPrice == nil && len(p.Intervals) == 0 { - return errors.New("per-request price or intervals required for per_request/image billing mode") - } - } - // 校验价格不能为负 - if err := validatePriceNotNegative("input_price", p.InputPrice); err != nil { - return err - } - if err := validatePriceNotNegative("output_price", p.OutputPrice); err != nil { - return err - } - if err := validatePriceNotNegative("cache_write_price", p.CacheWritePrice); err != nil { - return err - } - if err := validatePriceNotNegative("cache_read_price", p.CacheReadPrice); err != nil { - return err - } - if err := validatePriceNotNegative("image_output_price", p.ImageOutputPrice); err != nil { - return err - } - if err := validatePriceNotNegative("per_request_price", p.PerRequestPrice); err != nil { - return err - } - // 校验 interval:至少有一个价格字段非空 - for _, iv := range p.Intervals { - if iv.InputPrice == nil && iv.OutputPrice == nil && - iv.CacheWritePrice == nil && iv.CacheReadPrice == nil && - iv.PerRequestPrice == nil { - return fmt.Errorf("interval [%d, %s] has no price fields set for model %v", - iv.MinTokens, formatMaxTokens(iv.MaxTokens), p.Models) - } - } - } - return nil -} - -func validatePriceNotNegative(field string, val *float64) error { - if val != nil && *val < 0 { - return fmt.Errorf("%s must be >= 0", field) - } - return nil -} - -func formatMaxTokens(max *int) string { - if max == nil { - return "∞" - } - return fmt.Sprintf("%d", *max) -} - // --- Handlers --- // List handles listing channels with pagination @@ -343,10 +286,6 @@ func (h *ChannelHandler) Create(c *gin.Context) { } pricing := pricingRequestToService(req.ModelPricing) - if err := validatePricingBillingMode(pricing); err != nil { - response.ErrorFrom(c, infraerrors.BadRequest("VALIDATION_ERROR", err.Error())) - return - } channel, err := h.channelService.Create(c.Request.Context(), &service.CreateChannelInput{ Name: req.Name, @@ -391,10 +330,6 @@ func (h *ChannelHandler) Update(c *gin.Context) { } if req.ModelPricing != nil { pricing := pricingRequestToService(*req.ModelPricing) - if err := validatePricingBillingMode(pricing); err != nil { - response.ErrorFrom(c, infraerrors.BadRequest("VALIDATION_ERROR", err.Error())) - return - } input.ModelPricing = &pricing } diff --git a/backend/internal/handler/admin/channel_handler_test.go b/backend/internal/handler/admin/channel_handler_test.go index 6f6ea5260e..2f4b4440d5 100644 --- a/backend/internal/handler/admin/channel_handler_test.go +++ b/backend/internal/handler/admin/channel_handler_test.go @@ -400,103 +400,3 @@ func TestPricingRequestToService_NilPriceFields(t *testing.T) { require.Nil(t, r.ImageOutputPrice) require.Nil(t, r.PerRequestPrice) } - -// --------------------------------------------------------------------------- -// 3. validatePricingBillingMode -// --------------------------------------------------------------------------- - -func TestValidatePricingBillingMode(t *testing.T) { - tests := []struct { - name string - pricing []service.ChannelModelPricing - wantErr bool - }{ - { - name: "token mode - valid", - pricing: []service.ChannelModelPricing{ - {BillingMode: service.BillingModeToken}, - }, - wantErr: false, - }, - { - name: "per_request with price - valid", - pricing: []service.ChannelModelPricing{ - { - BillingMode: service.BillingModePerRequest, - PerRequestPrice: float64Ptr(0.5), - }, - }, - wantErr: false, - }, - { - name: "per_request with intervals - valid", - pricing: []service.ChannelModelPricing{ - { - BillingMode: service.BillingModePerRequest, - Intervals: []service.PricingInterval{ - {MinTokens: 0, MaxTokens: intPtr(1000), PerRequestPrice: float64Ptr(0.1)}, - }, - }, - }, - wantErr: false, - }, - { - name: "per_request no price no intervals - invalid", - pricing: []service.ChannelModelPricing{ - {BillingMode: service.BillingModePerRequest}, - }, - wantErr: true, - }, - { - name: "image with price - valid", - pricing: []service.ChannelModelPricing{ - { - BillingMode: service.BillingModeImage, - PerRequestPrice: float64Ptr(0.2), - }, - }, - wantErr: false, - }, - { - name: "image no price no intervals - invalid", - pricing: []service.ChannelModelPricing{ - {BillingMode: service.BillingModeImage}, - }, - wantErr: true, - }, - { - name: "empty list - valid", - pricing: []service.ChannelModelPricing{}, - wantErr: false, - }, - { - name: "mixed modes with invalid image - invalid", - pricing: []service.ChannelModelPricing{ - { - BillingMode: service.BillingModeToken, - InputPrice: float64Ptr(0.01), - }, - { - BillingMode: service.BillingModePerRequest, - PerRequestPrice: float64Ptr(0.5), - }, - { - BillingMode: service.BillingModeImage, - }, - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validatePricingBillingMode(tt.pricing) - if tt.wantErr { - require.Error(t, err) - require.Contains(t, err.Error(), "per-request price or intervals required") - } else { - require.NoError(t, err) - } - }) - } -} diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 7b96084d99..9667cb9844 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -248,40 +248,58 @@ func expandMappingToCache(cache *channelCache, ch *Channel, gid int64, platform } } +// storeErrorCache 存入短 TTL 空缓存,防止 DB 错误后紧密重试。 +// 通过回退 loadedAt 使剩余 TTL = channelErrorTTL。 +func (s *ChannelService) storeErrorCache() { + errorCache := newEmptyChannelCache() + errorCache.loadedAt = time.Now().Add(-(channelCacheTTL - channelErrorTTL)) + s.cache.Store(errorCache) +} + // buildCache 从数据库构建渠道缓存。 // 使用独立 context 避免请求取消导致空值被长期缓存。 func (s *ChannelService) buildCache(ctx context.Context) (*channelCache, error) { - // 断开请求取消链,避免客户端断连导致空值被长期缓存 dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), channelCacheDBTimeout) defer cancel() - channels, err := s.repo.ListAll(dbCtx) + channels, groupPlatforms, err := s.fetchChannelData(dbCtx) if err != nil { - // error-TTL:失败时存入短 TTL 空缓存,防止紧密重试 + return nil, err + } + + cache := populateChannelCache(channels, groupPlatforms) + s.cache.Store(cache) + return cache, nil +} + +// fetchChannelData 从数据库加载渠道列表和分组平台映射。 +func (s *ChannelService) fetchChannelData(ctx context.Context) ([]Channel, map[int64]string, error) { + channels, err := s.repo.ListAll(ctx) + if err != nil { slog.Warn("failed to build channel cache", "error", err) - errorCache := newEmptyChannelCache() - errorCache.loadedAt = time.Now().Add(-(channelCacheTTL - channelErrorTTL)) // 使剩余 TTL = errorTTL - s.cache.Store(errorCache) - return nil, fmt.Errorf("list all channels: %w", err) + s.storeErrorCache() + return nil, nil, fmt.Errorf("list all channels: %w", err) } - // 收集所有 groupID,批量查询 platform var allGroupIDs []int64 for i := range channels { allGroupIDs = append(allGroupIDs, channels[i].GroupIDs...) } + groupPlatforms := make(map[int64]string) if len(allGroupIDs) > 0 { - groupPlatforms, err = s.repo.GetGroupPlatforms(dbCtx, allGroupIDs) + groupPlatforms, err = s.repo.GetGroupPlatforms(ctx, allGroupIDs) if err != nil { slog.Warn("failed to load group platforms for channel cache", "error", err) - errorCache := newEmptyChannelCache() - errorCache.loadedAt = time.Now().Add(-(channelCacheTTL - channelErrorTTL)) - s.cache.Store(errorCache) - return nil, fmt.Errorf("get group platforms: %w", err) + s.storeErrorCache() + return nil, nil, fmt.Errorf("get group platforms: %w", err) } } + return channels, groupPlatforms, nil +} +// populateChannelCache 将渠道列表和分组平台映射填充到缓存快照中。 +func populateChannelCache(channels []Channel, groupPlatforms map[int64]string) *channelCache { cache := newEmptyChannelCache() cache.groupPlatform = groupPlatforms cache.byID = make(map[int64]*Channel, len(channels)) @@ -290,7 +308,6 @@ func (s *ChannelService) buildCache(ctx context.Context) (*channelCache, error) for i := range channels { ch := &channels[i] cache.byID[ch.ID] = ch - for _, gid := range ch.GroupIDs { cache.channelByGroupID[gid] = ch platform := groupPlatforms[gid] @@ -298,11 +315,7 @@ func (s *ChannelService) buildCache(ctx context.Context) (*channelCache, error) expandMappingToCache(cache, ch, gid, platform) } } - - // 通配符条目保持配置顺序(最先匹配到优先) - - s.cache.Store(cache) - return cache, nil + return cache } // invalidateCache 使缓存失效,让下次读取时自然重建 @@ -466,7 +479,10 @@ func (s *ChannelService) ResolveChannelMapping(ctx context.Context, groupID int6 // 返回 true 表示模型被限制(不在允许列表中)。 // 如果渠道未启用模型限制或分组无渠道关联,返回 false。 func (s *ChannelService) IsModelRestricted(ctx context.Context, groupID int64, model string) bool { - lk, _ := s.lookupGroupChannel(ctx, groupID) + lk, err := s.lookupGroupChannel(ctx, groupID) + if err != nil { + slog.Warn("failed to load channel cache for model restriction check", "group_id", groupID, "error", err) + } if lk == nil { return false } @@ -537,6 +553,91 @@ func ReplaceModelInBody(body []byte, newModel string) []byte { return newBody } +// validateChannelConfig 校验渠道的定价和映射配置(冲突检测 + 区间校验 + 计费模式校验)。 +// Create 和 Update 共用此函数,避免重复。 +func validateChannelConfig(pricing []ChannelModelPricing, mapping map[string]map[string]string) error { + if err := validateNoConflictingModels(pricing); err != nil { + return err + } + if err := validatePricingIntervals(pricing); err != nil { + return err + } + if err := validateNoConflictingMappings(mapping); err != nil { + return err + } + return validatePricingBillingMode(pricing) +} + +// validatePricingBillingMode 校验计费模式配置:按次/图片模式必须配价格或区间,所有价格字段不能为负,区间至少有一个价格字段。 +func validatePricingBillingMode(pricing []ChannelModelPricing) error { + for _, p := range pricing { + if err := checkBillingModeRequirements(p); err != nil { + return err + } + if err := checkPricesNotNegative(p); err != nil { + return err + } + if err := checkIntervalsHavePrices(p); err != nil { + return err + } + } + return nil +} + +func checkBillingModeRequirements(p ChannelModelPricing) error { + if p.BillingMode == BillingModePerRequest || p.BillingMode == BillingModeImage { + if p.PerRequestPrice == nil && len(p.Intervals) == 0 { + return infraerrors.BadRequest( + "BILLING_MODE_MISSING_PRICE", + "per-request price or intervals required for per_request/image billing mode", + ) + } + } + return nil +} + +func checkPricesNotNegative(p ChannelModelPricing) error { + checks := []struct { + field string + val *float64 + }{ + {"input_price", p.InputPrice}, + {"output_price", p.OutputPrice}, + {"cache_write_price", p.CacheWritePrice}, + {"cache_read_price", p.CacheReadPrice}, + {"image_output_price", p.ImageOutputPrice}, + {"per_request_price", p.PerRequestPrice}, + } + for _, c := range checks { + if c.val != nil && *c.val < 0 { + return infraerrors.BadRequest("NEGATIVE_PRICE", fmt.Sprintf("%s must be >= 0", c.field)) + } + } + return nil +} + +func checkIntervalsHavePrices(p ChannelModelPricing) error { + for _, iv := range p.Intervals { + if iv.InputPrice == nil && iv.OutputPrice == nil && + iv.CacheWritePrice == nil && iv.CacheReadPrice == nil && + iv.PerRequestPrice == nil { + return infraerrors.BadRequest( + "INTERVAL_MISSING_PRICE", + fmt.Sprintf("interval [%d, %s] has no price fields set for model %v", + iv.MinTokens, formatMaxTokens(iv.MaxTokens), p.Models), + ) + } + } + return nil +} + +func formatMaxTokens(max *int) string { + if max == nil { + return "∞" + } + return fmt.Sprintf("%d", *max) +} + // --- CRUD --- // Create 创建渠道 @@ -549,15 +650,8 @@ func (s *ChannelService) Create(ctx context.Context, input *CreateChannelInput) return nil, ErrChannelExists } - // 检查分组冲突 - if len(input.GroupIDs) > 0 { - conflicting, err := s.repo.GetGroupsInOtherChannels(ctx, 0, input.GroupIDs) - if err != nil { - return nil, fmt.Errorf("check group conflicts: %w", err) - } - if len(conflicting) > 0 { - return nil, ErrGroupAlreadyInChannel - } + if err := s.checkGroupConflicts(ctx, 0, input.GroupIDs); err != nil { + return nil, err } channel := &Channel{ @@ -574,13 +668,7 @@ func (s *ChannelService) Create(ctx context.Context, input *CreateChannelInput) channel.BillingModelSource = BillingModelSourceChannelMapped } - if err := validateNoConflictingModels(channel.ModelPricing); err != nil { - return nil, err - } - if err := validatePricingIntervals(channel.ModelPricing); err != nil { - return nil, err - } - if err := validateNoConflictingMappings(channel.ModelMapping); err != nil { + if err := validateChannelConfig(channel.ModelPricing, channel.ModelMapping); err != nil { return nil, err } @@ -604,102 +692,112 @@ func (s *ChannelService) Update(ctx context.Context, id int64, input *UpdateChan return nil, fmt.Errorf("get channel: %w", err) } - if input.Name != "" && input.Name != channel.Name { - exists, err := s.repo.ExistsByNameExcluding(ctx, input.Name, id) - if err != nil { - return nil, fmt.Errorf("check channel exists: %w", err) - } - if exists { - return nil, ErrChannelExists - } - channel.Name = input.Name - } - - if input.Description != nil { - channel.Description = *input.Description - } - - if input.Status != "" { - channel.Status = input.Status - } - - if input.RestrictModels != nil { - channel.RestrictModels = *input.RestrictModels - } - - // 检查分组冲突 - if input.GroupIDs != nil { - conflicting, err := s.repo.GetGroupsInOtherChannels(ctx, id, *input.GroupIDs) - if err != nil { - return nil, fmt.Errorf("check group conflicts: %w", err) - } - if len(conflicting) > 0 { - return nil, ErrGroupAlreadyInChannel - } - channel.GroupIDs = *input.GroupIDs - } - - if input.ModelPricing != nil { - channel.ModelPricing = *input.ModelPricing - } - - if input.ModelMapping != nil { - channel.ModelMapping = input.ModelMapping - } - - if input.BillingModelSource != "" { - channel.BillingModelSource = input.BillingModelSource - } - - if err := validateNoConflictingModels(channel.ModelPricing); err != nil { - return nil, err - } - if err := validatePricingIntervals(channel.ModelPricing); err != nil { - return nil, err - } - if err := validateNoConflictingMappings(channel.ModelMapping); err != nil { + if err := s.applyUpdateInput(ctx, channel, input); err != nil { return nil, err } - // 先获取旧分组,Update 后旧分组关联已删除,无法再查到 - var oldGroupIDs []int64 - if s.authCacheInvalidator != nil { - var err2 error - oldGroupIDs, err2 = s.repo.GetGroupIDs(ctx, id) - if err2 != nil { - slog.Warn("failed to get old group IDs for cache invalidation", "channel_id", id, "error", err2) - } + if err := validateChannelConfig(channel.ModelPricing, channel.ModelMapping); err != nil { + return nil, err } + oldGroupIDs := s.getOldGroupIDs(ctx, id) + if err := s.repo.Update(ctx, channel); err != nil { return nil, fmt.Errorf("update channel: %w", err) } s.invalidateCache() - - // 失效新旧分组的 auth 缓存 - if s.authCacheInvalidator != nil { - seen := make(map[int64]struct{}, len(oldGroupIDs)+len(channel.GroupIDs)) - for _, gid := range oldGroupIDs { - if _, ok := seen[gid]; !ok { - seen[gid] = struct{}{} - s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, gid) - } - } - for _, gid := range channel.GroupIDs { - if _, ok := seen[gid]; !ok { - seen[gid] = struct{}{} - s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, gid) - } - } - } + s.invalidateAuthCacheForGroups(ctx, oldGroupIDs, channel.GroupIDs) return s.repo.GetByID(ctx, id) } +// applyUpdateInput 将更新请求的字段应用到渠道实体上。 +func (s *ChannelService) applyUpdateInput(ctx context.Context, channel *Channel, input *UpdateChannelInput) error { + if input.Name != "" && input.Name != channel.Name { + exists, err := s.repo.ExistsByNameExcluding(ctx, input.Name, channel.ID) + if err != nil { + return fmt.Errorf("check channel exists: %w", err) + } + if exists { + return ErrChannelExists + } + channel.Name = input.Name + } + if input.Description != nil { + channel.Description = *input.Description + } + if input.Status != "" { + channel.Status = input.Status + } + if input.RestrictModels != nil { + channel.RestrictModels = *input.RestrictModels + } + if input.GroupIDs != nil { + if err := s.checkGroupConflicts(ctx, channel.ID, *input.GroupIDs); err != nil { + return err + } + channel.GroupIDs = *input.GroupIDs + } + if input.ModelPricing != nil { + channel.ModelPricing = *input.ModelPricing + } + if input.ModelMapping != nil { + channel.ModelMapping = input.ModelMapping + } + if input.BillingModelSource != "" { + channel.BillingModelSource = input.BillingModelSource + } + return nil +} + +// checkGroupConflicts 检查待关联的分组是否已属于其他渠道。 +// channelID 为当前渠道 ID(Create 时传 0)。 +func (s *ChannelService) checkGroupConflicts(ctx context.Context, channelID int64, groupIDs []int64) error { + if len(groupIDs) == 0 { + return nil + } + conflicting, err := s.repo.GetGroupsInOtherChannels(ctx, channelID, groupIDs) + if err != nil { + return fmt.Errorf("check group conflicts: %w", err) + } + if len(conflicting) > 0 { + return ErrGroupAlreadyInChannel + } + return nil +} + +// getOldGroupIDs 获取渠道更新前的关联分组 ID(用于失效 auth 缓存)。 +func (s *ChannelService) getOldGroupIDs(ctx context.Context, channelID int64) []int64 { + if s.authCacheInvalidator == nil { + return nil + } + oldGroupIDs, err := s.repo.GetGroupIDs(ctx, channelID) + if err != nil { + slog.Warn("failed to get old group IDs for cache invalidation", "channel_id", channelID, "error", err) + } + return oldGroupIDs +} + +// invalidateAuthCacheForGroups 对新旧分组去重后逐个失效 auth 缓存。 +func (s *ChannelService) invalidateAuthCacheForGroups(ctx context.Context, groupIDSets ...[]int64) { + if s.authCacheInvalidator == nil { + return + } + seen := make(map[int64]struct{}) + for _, ids := range groupIDSets { + for _, gid := range ids { + if _, ok := seen[gid]; ok { + continue + } + seen[gid] = struct{}{} + s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, gid) + } + } +} + // Delete 删除渠道 func (s *ChannelService) Delete(ctx context.Context, id int64) error { - // 先获取关联分组用于失效缓存 groupIDs, err := s.repo.GetGroupIDs(ctx, id) if err != nil { slog.Warn("failed to get group IDs before delete", "channel_id", id, "error", err) @@ -710,12 +808,7 @@ func (s *ChannelService) Delete(ctx context.Context, id int64) error { } s.invalidateCache() - - if s.authCacheInvalidator != nil { - for _, gid := range groupIDs { - s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, gid) - } - } + s.invalidateAuthCacheForGroups(ctx, groupIDs) return nil } diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index 3a01fd80c1..e1345618c9 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -2199,3 +2199,207 @@ func TestGetChannelModelPricing_NonAntigravityUnaffected(t *testing.T) { require.Equal(t, int64(601), result.ID) require.InDelta(t, 5e-6, *result.InputPrice, 1e-12) } + +// --------------------------------------------------------------------------- +// 10. ToUsageFields +// --------------------------------------------------------------------------- + +func TestToUsageFields_NoMapping(t *testing.T) { + r := ChannelMappingResult{ + MappedModel: "claude-opus-4", + ChannelID: 1, + Mapped: false, + BillingModelSource: BillingModelSourceRequested, + } + fields := r.ToUsageFields("claude-opus-4", "claude-opus-4") + require.Equal(t, int64(1), fields.ChannelID) + require.Equal(t, "claude-opus-4", fields.OriginalModel) + require.Equal(t, "claude-opus-4", fields.ChannelMappedModel) + require.Equal(t, BillingModelSourceRequested, fields.BillingModelSource) + require.Empty(t, fields.ModelMappingChain) +} + +func TestToUsageFields_WithChannelMapping(t *testing.T) { + r := ChannelMappingResult{ + MappedModel: "claude-sonnet-4-20250514", + ChannelID: 2, + Mapped: true, + BillingModelSource: BillingModelSourceChannelMapped, + } + fields := r.ToUsageFields("claude-sonnet-4", "claude-sonnet-4-20250514") + require.Equal(t, int64(2), fields.ChannelID) + require.Equal(t, "claude-sonnet-4", fields.OriginalModel) + require.Equal(t, "claude-sonnet-4-20250514", fields.ChannelMappedModel) + require.Equal(t, "claude-sonnet-4→claude-sonnet-4-20250514", fields.ModelMappingChain) +} + +func TestToUsageFields_WithUpstreamDifference(t *testing.T) { + r := ChannelMappingResult{ + MappedModel: "claude-sonnet-4", + ChannelID: 3, + Mapped: true, + BillingModelSource: BillingModelSourceUpstream, + } + fields := r.ToUsageFields("my-alias", "claude-sonnet-4-20250514") + require.Equal(t, "my-alias", fields.OriginalModel) + require.Equal(t, "claude-sonnet-4", fields.ChannelMappedModel) + require.Equal(t, "my-alias→claude-sonnet-4→claude-sonnet-4-20250514", fields.ModelMappingChain) +} + +// --------------------------------------------------------------------------- +// 11. validatePricingBillingMode (moved from handler tests) +// --------------------------------------------------------------------------- + +func TestValidatePricingBillingMode(t *testing.T) { + tests := []struct { + name string + pricing []ChannelModelPricing + wantErr bool + errMsg string + }{ + { + name: "token mode - valid", + pricing: []ChannelModelPricing{{BillingMode: BillingModeToken}}, + }, + { + name: "per_request with price - valid", + pricing: []ChannelModelPricing{{ + BillingMode: BillingModePerRequest, + PerRequestPrice: testPtrFloat64(0.5), + }}, + }, + { + name: "per_request with intervals - valid", + pricing: []ChannelModelPricing{{ + BillingMode: BillingModePerRequest, + Intervals: []PricingInterval{{MinTokens: 0, MaxTokens: testPtrInt(1000), PerRequestPrice: testPtrFloat64(0.1)}}, + }}, + }, + { + name: "per_request no price no intervals - invalid", + pricing: []ChannelModelPricing{{BillingMode: BillingModePerRequest}}, + wantErr: true, + errMsg: "per-request price or intervals required", + }, + { + name: "image no price no intervals - invalid", + pricing: []ChannelModelPricing{{BillingMode: BillingModeImage}}, + wantErr: true, + errMsg: "per-request price or intervals required", + }, + { + name: "empty list - valid", + pricing: []ChannelModelPricing{}, + }, + { + name: "negative input_price - invalid", + pricing: []ChannelModelPricing{{ + BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(-0.01), + }}, + wantErr: true, + errMsg: "input_price must be >= 0", + }, + { + name: "interval with no price fields - invalid", + pricing: []ChannelModelPricing{{ + BillingMode: BillingModePerRequest, + PerRequestPrice: testPtrFloat64(0.5), + Intervals: []PricingInterval{{MinTokens: 0, MaxTokens: testPtrInt(1000)}}, + }}, + wantErr: true, + errMsg: "has no price fields set", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePricingBillingMode(tt.pricing) + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +// --------------------------------------------------------------------------- +// 12. Antigravity wildcard mapping isolation +// --------------------------------------------------------------------------- + +func TestResolveChannelMapping_AntigravityDoesNotSeeWildcardMappingFromOtherPlatforms(t *testing.T) { + ch := Channel{ + ID: 1, + Status: StatusActive, + GroupIDs: []int64{10, 20}, + ModelMapping: map[string]map[string]string{ + PlatformAnthropic: {"claude-*": "claude-override"}, + PlatformGemini: {"gemini-*": "gemini-override"}, + }, + } + repo := makeStandardRepo(ch, map[int64]string{10: PlatformAntigravity, 20: PlatformAnthropic}) + svc := newTestChannelService(repo) + + // antigravity 分组不应看到 anthropic/gemini 的通配符映射 + result := svc.ResolveChannelMapping(context.Background(), 10, "claude-opus-4") + require.False(t, result.Mapped) + require.Equal(t, "claude-opus-4", result.MappedModel) + + result = svc.ResolveChannelMapping(context.Background(), 10, "gemini-2.5-pro") + require.False(t, result.Mapped) + require.Equal(t, "gemini-2.5-pro", result.MappedModel) + + // anthropic 分组应该能看到 anthropic 的通配符映射 + result = svc.ResolveChannelMapping(context.Background(), 20, "claude-opus-4") + require.True(t, result.Mapped) + require.Equal(t, "claude-override", result.MappedModel) +} + +// --------------------------------------------------------------------------- +// 13. Create/Update with mapping conflict validation +// --------------------------------------------------------------------------- + +func TestCreate_MappingConflict(t *testing.T) { + repo := &mockChannelRepository{} + svc := newTestChannelService(repo) + + _, err := svc.Create(context.Background(), &CreateChannelInput{ + Name: "test", + ModelMapping: map[string]map[string]string{ + PlatformAnthropic: { + "claude-*": "target-a", + "claude-opus-*": "target-b", + }, + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "MAPPING_PATTERN_CONFLICT") +} + +func TestUpdate_MappingConflict(t *testing.T) { + existingChannel := &Channel{ + ID: 1, + Name: "existing", + Status: StatusActive, + } + repo := &mockChannelRepository{ + getByIDFn: func(_ context.Context, _ int64) (*Channel, error) { + return existingChannel, nil + }, + } + svc := newTestChannelService(repo) + + conflictMapping := map[string]map[string]string{ + PlatformAnthropic: { + "claude-*": "target-a", + "claude-opus-*": "target-b", + }, + } + _, err := svc.Update(context.Background(), 1, &UpdateChannelInput{ + ModelMapping: conflictMapping, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "MAPPING_PATTERN_CONFLICT") +} From 7eecc49c3ab2b4431ee99fa6229cd28e5a3c2dfb Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 7 Apr 2026 11:27:57 +0300 Subject: [PATCH 18/59] fix(openai): do not normalize API token based accounts --- .../service/openai_codex_transform.go | 7 ++++ .../service/openai_gateway_service.go | 6 ++-- .../service/openai_model_mapping_test.go | 36 +++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 21b4874eb3..4ec038e068 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -275,6 +275,13 @@ func normalizeCodexModel(model string) string { return "gpt-5.1" } +func normalizeOpenAIModelForUpstream(account *Account, model string) string { + if account == nil || account.Type == AccountTypeOAuth { + return normalizeCodexModel(model) + } + return strings.TrimSpace(model) +} + func SupportsVerbosity(model string) bool { if !strings.HasPrefix(model, "gpt-") { return true diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index f03f44d52f..af7c48584e 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -1937,9 +1937,11 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } upstreamModel := billingModel - // 针对所有 OpenAI 账号执行 Codex 模型名规范化,确保上游识别一致。 + // OpenAI OAuth 账号走 ChatGPT internal Codex endpoint,需要将模型名规范化为 + // 上游可识别的 Codex/GPT 系列。API Key 账号则应保留原始/映射后的模型名, + // 以兼容自定义 base_url 的 OpenAI-compatible 上游。 if model, ok := reqBody["model"].(string); ok { - upstreamModel = normalizeCodexModel(model) + upstreamModel = normalizeOpenAIModelForUpstream(account, model) if upstreamModel != "" && upstreamModel != model { logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Upstream model resolved: %s -> %s (account: %s, type: %s, isCodexCLI: %v)", model, upstreamModel, account.Name, account.Type, isCodexCLI) diff --git a/backend/internal/service/openai_model_mapping_test.go b/backend/internal/service/openai_model_mapping_test.go index 5ce2602c1c..cda7e3698a 100644 --- a/backend/internal/service/openai_model_mapping_test.go +++ b/backend/internal/service/openai_model_mapping_test.go @@ -99,3 +99,39 @@ func TestNormalizeCodexModel(t *testing.T) { } } } + +func TestNormalizeOpenAIModelForUpstream(t *testing.T) { + tests := []struct { + name string + account *Account + model string + want string + }{ + { + name: "oauth keeps codex normalization behavior", + account: &Account{Type: AccountTypeOAuth}, + model: "gemini-3-flash-preview", + want: "gpt-5.1", + }, + { + name: "apikey preserves custom compatible model", + account: &Account{Type: AccountTypeAPIKey}, + model: "gemini-3-flash-preview", + want: "gemini-3-flash-preview", + }, + { + name: "apikey preserves official non codex model", + account: &Account{Type: AccountTypeAPIKey}, + model: "gpt-4.1", + want: "gpt-4.1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeOpenAIModelForUpstream(tt.account, tt.model); got != tt.want { + t.Fatalf("normalizeOpenAIModelForUpstream(...) = %q, want %q", got, tt.want) + } + }) + } +} From 3a07e92b6072bee6068007a58fc7b93fd978be33 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 7 Apr 2026 11:40:41 +0300 Subject: [PATCH 19/59] fix(openai): do not normalize /completion API token based accounts --- backend/internal/service/openai_gateway_chat_completions.go | 2 +- backend/internal/service/openai_gateway_messages.go | 2 +- backend/internal/service/openai_ws_forwarder.go | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index be076cc082..d77af66f7a 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -46,7 +46,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( // 2. Resolve model mapping early so compat prompt_cache_key injection can // derive a stable seed from the final upstream model family. billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) - upstreamModel := normalizeCodexModel(billingModel) + upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) promptCacheKey = strings.TrimSpace(promptCacheKey) compatPromptCacheInjected := false diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index dd416269f4..6f53928b7d 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -62,7 +62,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( // 3. Model mapping billingModel := resolveOpenAIForwardModel(account, normalizedModel, defaultMappedModel) - upstreamModel := normalizeCodexModel(billingModel) + upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) responsesReq.Model = upstreamModel logger.L().Debug("openai messages: model mapping applied", diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index 6d45baab36..83849bf35c 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -2515,7 +2515,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } normalized = next } - upstreamModel := normalizeCodexModel(account.GetMappedModel(originalModel)) + upstreamModel := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)) if upstreamModel != originalModel { next, setErr := applyPayloadMutation(normalized, "model", upstreamModel) if setErr != nil { @@ -2773,7 +2773,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( mappedModel := "" var mappedModelBytes []byte if originalModel != "" { - mappedModel = normalizeCodexModel(account.GetMappedModel(originalModel)) + mappedModel = normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel)) needModelReplace = mappedModel != "" && mappedModel != originalModel if needModelReplace { mappedModelBytes = []byte(mappedModel) From f3aa54b77061299d5c7f6a51f560d13c06b05980 Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 7 Apr 2026 17:06:47 +0800 Subject: [PATCH 20/59] =?UTF-8?q?fix:=20=E9=9D=9EClaude=20Code=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AFsystem=20prompt=E8=BF=81=E7=A7=BB=E8=87=B3mes?= =?UTF-8?q?sages=E4=BB=A5=E7=BB=95=E8=BF=87=E7=AC=AC=E4=B8=89=E6=96=B9?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic近期引入基于system参数内容的第三方应用检测机制,原有的前置追加 Claude Code提示词策略无法通过检测(后续内容仍为非Claude Code格式触发429)。 新策略:对非Claude Code客户端的OAuth/SetupToken账号请求,将system字段 完整替换为Claude Code标识提示词,原始system内容作为user/assistant消息对 注入messages开头,模型仍接收完整指令。 仅影响/v1/messages路径,chat_completions和responses路径保持原有逻辑不变。 真正的Claude Code客户端请求完全不受影响(原样透传)。 --- ...teway_anthropic_apikey_passthrough_test.go | 9 +- .../internal/service/gateway_prompt_test.go | 138 ++++++++++++++++++ backend/internal/service/gateway_service.go | 75 +++++++++- 3 files changed, 219 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 6e19db322d..e7661aad63 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -761,7 +761,14 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock( system := gjson.GetBytes(upstream.lastBody, "system") require.True(t, system.Exists()) - require.Contains(t, system.Raw, "x-anthropic-billing-header keep") + require.Equal(t, claudeCodeSystemPrompt, system.String()) + + // 原始 system prompt 应迁移至 messages 中 + messages := gjson.GetBytes(upstream.lastBody, "messages") + require.True(t, messages.IsArray()) + firstMsg := messages.Array()[0] + require.Equal(t, "user", firstMsg.Get("role").String()) + require.Contains(t, firstMsg.Get("content.0.text").String(), "x-anthropic-billing-header keep") }) } } diff --git a/backend/internal/service/gateway_prompt_test.go b/backend/internal/service/gateway_prompt_test.go index 356536b063..d0f5a8c02b 100644 --- a/backend/internal/service/gateway_prompt_test.go +++ b/backend/internal/service/gateway_prompt_test.go @@ -278,3 +278,141 @@ func TestInjectClaudeCodePrompt(t *testing.T) { }) } } + +func TestRewriteSystemForNonClaudeCode(t *testing.T) { + tests := []struct { + name string + body string + system any + wantSystemStr string // system 应为纯字符串 + wantMessagesLen int // messages 数组长度 + wantFirstMsgRole string // 第一条消息的 role + wantFirstMsgText string // 第一条消息的 content[0].text + wantAckMsgText string // 第二条消息的 content[0].text + }{ + { + name: "nil system - no messages injected", + body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`, + system: nil, + wantSystemStr: claudeCodeSystemPrompt, + wantMessagesLen: 1, // 原始 1 条消息,不注入 + }, + { + name: "empty string system - no messages injected", + body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`, + system: "", + wantSystemStr: claudeCodeSystemPrompt, + wantMessagesLen: 1, + }, + { + name: "custom string system - migrated to messages", + body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`, + system: "You are a personal assistant running inside OpenClaw.", + wantSystemStr: claudeCodeSystemPrompt, + wantMessagesLen: 3, // instruction + ack + original + wantFirstMsgRole: "user", + wantFirstMsgText: "[System Instructions]\nYou are a personal assistant running inside OpenClaw.", + wantAckMsgText: "Understood. I will follow these instructions.", + }, + { + name: "system equals Claude Code prompt - no messages injected", + body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`, + system: claudeCodeSystemPrompt, + wantSystemStr: claudeCodeSystemPrompt, + wantMessagesLen: 1, + }, + { + name: "array system with custom blocks - text joined and migrated", + body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`, + system: []any{ + map[string]any{"type": "text", "text": "First instruction"}, + map[string]any{"type": "text", "text": "Second instruction"}, + }, + wantSystemStr: claudeCodeSystemPrompt, + wantMessagesLen: 3, + wantFirstMsgRole: "user", + wantFirstMsgText: "[System Instructions]\nFirst instruction\n\nSecond instruction", + wantAckMsgText: "Understood. I will follow these instructions.", + }, + { + name: "empty array system - no messages injected", + body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`, + system: []any{}, + wantSystemStr: claudeCodeSystemPrompt, + wantMessagesLen: 1, + }, + { + name: "json.RawMessage string system", + body: `{"model":"claude-3","system":"Custom prompt","messages":[{"role":"user","content":"hello"}]}`, + system: json.RawMessage(`"Custom prompt"`), + wantSystemStr: claudeCodeSystemPrompt, + wantMessagesLen: 3, + wantFirstMsgRole: "user", + wantFirstMsgText: "[System Instructions]\nCustom prompt", + wantAckMsgText: "Understood. I will follow these instructions.", + }, + { + name: "json.RawMessage nil system", + body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`, + system: json.RawMessage(nil), + wantSystemStr: claudeCodeSystemPrompt, + wantMessagesLen: 1, + }, + { + name: "multiple original messages preserved", + body: `{"model":"claude-3","messages":[{"role":"user","content":"msg1"},{"role":"assistant","content":"resp1"},{"role":"user","content":"msg2"}]}`, + system: "Be helpful", + wantSystemStr: claudeCodeSystemPrompt, + wantMessagesLen: 5, // 2 injected + 3 original + wantFirstMsgRole: "user", + wantFirstMsgText: "[System Instructions]\nBe helpful", + wantAckMsgText: "Understood. I will follow these instructions.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := rewriteSystemForNonClaudeCode([]byte(tt.body), tt.system) + + var parsed map[string]any + err := json.Unmarshal(result, &parsed) + require.NoError(t, err) + + // system 应为纯字符串 + systemVal, ok := parsed["system"].(string) + require.True(t, ok, "system should be a string, got %T", parsed["system"]) + require.Equal(t, tt.wantSystemStr, systemVal) + + // 检查 messages + messages, ok := parsed["messages"].([]any) + require.True(t, ok, "messages should be an array") + require.Len(t, messages, tt.wantMessagesLen) + + if tt.wantFirstMsgRole != "" && len(messages) >= 2 { + // 检查注入的 instruction 消息 + firstMsg, ok := messages[0].(map[string]any) + require.True(t, ok) + require.Equal(t, tt.wantFirstMsgRole, firstMsg["role"]) + + firstContent, ok := firstMsg["content"].([]any) + require.True(t, ok) + require.Len(t, firstContent, 1) + firstBlock, ok := firstContent[0].(map[string]any) + require.True(t, ok) + require.Equal(t, tt.wantFirstMsgText, firstBlock["text"]) + + // 检查注入的 ack 消息 + ackMsg, ok := messages[1].(map[string]any) + require.True(t, ok) + require.Equal(t, "assistant", ackMsg["role"]) + + ackContent, ok := ackMsg["content"].([]any) + require.True(t, ok) + require.Len(t, ackContent, 1) + ackBlock, ok := ackContent[0].(map[string]any) + require.True(t, ok) + require.Equal(t, tt.wantAckMsgText, ackBlock["text"]) + } + }) + } +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 16bfcd8e5a..f410d69b30 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -3714,6 +3714,77 @@ func injectClaudeCodePrompt(body []byte, system any) []byte { return result } +// rewriteSystemForNonClaudeCode 将非 Claude Code 客户端的 system prompt 迁移至 messages, +// system 字段仅保留 Claude Code 标识提示词。 +// Anthropic 基于 system 参数内容检测第三方应用,仅前置追加 Claude Code 提示词 +// 无法通过检测,因为后续内容仍为非 Claude Code 格式。 +// 策略:将原始 system prompt 提取并注入为 user/assistant 消息对,system 仅保留 Claude Code 标识。 +func rewriteSystemForNonClaudeCode(body []byte, system any) []byte { + system = normalizeSystemParam(system) + + // 1. 提取原始 system prompt 文本 + var originalSystemText string + switch v := system.(type) { + case string: + originalSystemText = strings.TrimSpace(v) + case []any: + var parts []string + for _, item := range v { + if m, ok := item.(map[string]any); ok { + if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" { + parts = append(parts, text) + } + } + } + originalSystemText = strings.Join(parts, "\n\n") + } + + // 2. 将 system 替换为 Claude Code 标准提示词(纯字符串,通过 Anthropic 检测) + out, ok := setJSONValueBytes(body, "system", claudeCodeSystemPrompt) + if !ok { + logger.LegacyPrintf("service.gateway", "Warning: failed to set Claude Code system prompt") + return body + } + + // 3. 将原始 system prompt 作为 user/assistant 消息对注入到 messages 开头 + // 模型仍通过 messages 接收完整指令,保留客户端功能 + ccPromptTrimmed := strings.TrimSpace(claudeCodeSystemPrompt) + if originalSystemText != "" && originalSystemText != ccPromptTrimmed && !hasClaudeCodePrefix(originalSystemText) { + instrMsg, err1 := json.Marshal(map[string]any{ + "role": "user", + "content": []map[string]any{ + {"type": "text", "text": "[System Instructions]\n" + originalSystemText}, + }, + }) + ackMsg, err2 := json.Marshal(map[string]any{ + "role": "assistant", + "content": []map[string]any{ + {"type": "text", "text": "Understood. I will follow these instructions."}, + }, + }) + if err1 != nil || err2 != nil { + logger.LegacyPrintf("service.gateway", "Warning: failed to marshal system-to-messages injection") + return out + } + + // 重建 messages 数组:[instruction, ack, ...originalMessages] + items := [][]byte{instrMsg, ackMsg} + messagesResult := gjson.GetBytes(out, "messages") + if messagesResult.IsArray() { + messagesResult.ForEach(func(_, msg gjson.Result) bool { + items = append(items, []byte(msg.Raw)) + return true + }) + } + + if next, setOk := setJSONRawBytes(out, "messages", buildJSONArrayRaw(items)); setOk { + out = next + } + } + + return out +} + type cacheControlPath struct { path string log string @@ -3905,11 +3976,11 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode if shouldMimicClaudeCode { - // 智能注入 Claude Code 系统提示词(仅 OAuth/SetupToken 账号需要) + // 非 Claude Code 客户端:将 system 替换为 Claude Code 标识,原始 system 迁移至 messages // 条件:1) OAuth/SetupToken 账号 2) 不是 Claude Code 客户端 3) 不是 Haiku 模型 4) system 中还没有 Claude Code 提示词 if !strings.Contains(strings.ToLower(reqModel), "haiku") && !systemIncludesClaudeCodePrompt(parsed.System) { - body = injectClaudeCodePrompt(body, parsed.System) + body = rewriteSystemForNonClaudeCode(body, parsed.System) } normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true} From 08b454423b5e2db1e8adba842551abd9d3f9fc77 Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 7 Apr 2026 17:22:17 +0800 Subject: [PATCH 21/59] chore: renew expired xlsx audit exceptions to 2026-07-06 --- .github/audit-exceptions.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/audit-exceptions.yml b/.github/audit-exceptions.yml index 82cdefe44c..b71422a78a 100644 --- a/.github/audit-exceptions.yml +++ b/.github/audit-exceptions.yml @@ -5,14 +5,14 @@ exceptions: severity: high reason: "Admin export only; switched to dynamic import to reduce exposure (CVE-2023-30533)" mitigation: "Load only on export; restrict export permissions and data scope" - expires_on: "2026-04-05" + expires_on: "2026-07-06" owner: "security@your-domain" - package: xlsx advisory: "GHSA-5pgg-2g8v-p4x9" severity: high reason: "Admin export only; switched to dynamic import to reduce exposure (CVE-2024-22363)" mitigation: "Load only on export; restrict export permissions and data scope" - expires_on: "2026-04-05" + expires_on: "2026-07-06" owner: "security@your-domain" - package: lodash advisory: "GHSA-r5fr-rjxr-66jc" From b2e379cf7ab6a7b57b86f3f5a8f7c9d875821c97 Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 7 Apr 2026 19:30:45 +0800 Subject: [PATCH 22/59] =?UTF-8?q?fix:=20=E9=9D=9E=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=9C=A8=E4=B8=8A=E6=B8=B8=E7=BB=88=E6=80=81?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6output=E4=B8=BA=E7=A9=BA=E6=97=B6=E4=BB=8Edel?= =?UTF-8?q?ta=E4=BA=8B=E4=BB=B6=E9=87=8D=E5=BB=BA=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游API近期更新后,response.completed终态SSE事件的output字段可能为空, 实际内容仅通过response.output_text.delta等增量事件下发。流式路径不受影响, 但chat_completions非流式路径和responses OAuth非流式路径只依赖终态事件的 output,导致返回空响应。 新增BufferedResponseAccumulator累积器,在SSE扫描过程中收集delta事件内容 (文本、function_call、reasoning),当终态output为空时补充重建。 同时修复handleChatBufferedStreamingResponse遗漏response.done事件类型的问题。 --- .../chatcompletions_responses_test.go | 179 ++++++++++++++++++ .../apicompat/responses_to_chatcompletions.go | 117 ++++++++++++ .../openai_gateway_chat_completions.go | 11 +- .../service/openai_gateway_service.go | 39 ++++ 4 files changed, 345 insertions(+), 1 deletion(-) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index f54a4a027f..903d5b3103 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -876,3 +876,182 @@ func TestChatCompletionsStreamRoundTrip(t *testing.T) { assert.Equal(t, "resp_rt", c.ID) } } + +// --------------------------------------------------------------------------- +// BufferedResponseAccumulator tests +// --------------------------------------------------------------------------- + +func TestBufferedResponseAccumulator_TextOnly(t *testing.T) { + acc := NewBufferedResponseAccumulator() + + acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hello"}) + acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: ", world!"}) + + assert.True(t, acc.HasContent()) + + output := acc.BuildOutput() + require.Len(t, output, 1) + assert.Equal(t, "message", output[0].Type) + assert.Equal(t, "assistant", output[0].Role) + require.Len(t, output[0].Content, 1) + assert.Equal(t, "output_text", output[0].Content[0].Type) + assert.Equal(t, "Hello, world!", output[0].Content[0].Text) +} + +func TestBufferedResponseAccumulator_ToolCalls(t *testing.T) { + acc := NewBufferedResponseAccumulator() + + // Add function call at output_index=1 + acc.ProcessEvent(&ResponsesStreamEvent{ + Type: "response.output_item.added", + OutputIndex: 1, + Item: &ResponsesOutput{ + Type: "function_call", + CallID: "call_abc", + Name: "get_weather", + }, + }) + acc.ProcessEvent(&ResponsesStreamEvent{ + Type: "response.function_call_arguments.delta", + OutputIndex: 1, + Delta: `{"city":`, + }) + acc.ProcessEvent(&ResponsesStreamEvent{ + Type: "response.function_call_arguments.delta", + OutputIndex: 1, + Delta: `"NYC"}`, + }) + + assert.True(t, acc.HasContent()) + + output := acc.BuildOutput() + require.Len(t, output, 1) + assert.Equal(t, "function_call", output[0].Type) + assert.Equal(t, "call_abc", output[0].CallID) + assert.Equal(t, "get_weather", output[0].Name) + assert.Equal(t, `{"city":"NYC"}`, output[0].Arguments) +} + +func TestBufferedResponseAccumulator_Reasoning(t *testing.T) { + acc := NewBufferedResponseAccumulator() + + acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.reasoning_summary_text.delta", Delta: "Step 1: "}) + acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.reasoning_summary_text.delta", Delta: "think about it"}) + + assert.True(t, acc.HasContent()) + + output := acc.BuildOutput() + require.Len(t, output, 1) + assert.Equal(t, "reasoning", output[0].Type) + require.Len(t, output[0].Summary, 1) + assert.Equal(t, "summary_text", output[0].Summary[0].Type) + assert.Equal(t, "Step 1: think about it", output[0].Summary[0].Text) +} + +func TestBufferedResponseAccumulator_Mixed(t *testing.T) { + acc := NewBufferedResponseAccumulator() + + // Reasoning first + acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.reasoning_summary_text.delta", Delta: "I thought about it."}) + + // Then text + acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "The answer is 42."}) + + // Then a tool call + acc.ProcessEvent(&ResponsesStreamEvent{ + Type: "response.output_item.added", + OutputIndex: 2, + Item: &ResponsesOutput{ + Type: "function_call", + CallID: "call_1", + Name: "verify", + }, + }) + acc.ProcessEvent(&ResponsesStreamEvent{ + Type: "response.function_call_arguments.delta", + OutputIndex: 2, + Delta: `{}`, + }) + + assert.True(t, acc.HasContent()) + + output := acc.BuildOutput() + // Order: reasoning → message → function_calls + require.Len(t, output, 3) + assert.Equal(t, "reasoning", output[0].Type) + assert.Equal(t, "message", output[1].Type) + assert.Equal(t, "function_call", output[2].Type) + assert.Equal(t, "The answer is 42.", output[1].Content[0].Text) + assert.Equal(t, "verify", output[2].Name) +} + +func TestBufferedResponseAccumulator_SupplementEmptyOutput(t *testing.T) { + acc := NewBufferedResponseAccumulator() + acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hello"}) + + resp := &ResponsesResponse{ + ID: "resp_1", + Status: "completed", + Output: nil, // empty output + Usage: &ResponsesUsage{InputTokens: 10, OutputTokens: 5}, + } + + acc.SupplementResponseOutput(resp) + + require.Len(t, resp.Output, 1) + assert.Equal(t, "message", resp.Output[0].Type) + assert.Equal(t, "Hello", resp.Output[0].Content[0].Text) + // Usage should be untouched + assert.Equal(t, 10, resp.Usage.InputTokens) +} + +func TestBufferedResponseAccumulator_NoSupplementWhenOutputExists(t *testing.T) { + acc := NewBufferedResponseAccumulator() + acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "from deltas"}) + + resp := &ResponsesResponse{ + ID: "resp_2", + Status: "completed", + Output: []ResponsesOutput{ + { + Type: "message", + Content: []ResponsesContentPart{ + {Type: "output_text", Text: "from terminal event"}, + }, + }, + }, + } + + acc.SupplementResponseOutput(resp) + + // Output should NOT be overwritten + require.Len(t, resp.Output, 1) + assert.Equal(t, "from terminal event", resp.Output[0].Content[0].Text) +} + +func TestBufferedResponseAccumulator_EmptyDeltas(t *testing.T) { + acc := NewBufferedResponseAccumulator() + + // Process events with empty delta — should not accumulate + acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: ""}) + acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.created"}) + + assert.False(t, acc.HasContent()) + + resp := &ResponsesResponse{ID: "resp_3", Status: "completed"} + acc.SupplementResponseOutput(resp) + assert.Nil(t, resp.Output) +} + +func TestBufferedResponseAccumulator_IgnoresNonFunctionCallItems(t *testing.T) { + acc := NewBufferedResponseAccumulator() + + // output_item.added with type "message" should be ignored + acc.ProcessEvent(&ResponsesStreamEvent{ + Type: "response.output_item.added", + OutputIndex: 0, + Item: &ResponsesOutput{Type: "message"}, + }) + + assert.False(t, acc.HasContent()) +} diff --git a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go index 688a68ebf8..61b3bf9cde 100644 --- a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go +++ b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "strings" "time" ) @@ -372,3 +373,119 @@ func generateChatCmplID() string { _, _ = rand.Read(b) return "chatcmpl-" + hex.EncodeToString(b) } + +// --------------------------------------------------------------------------- +// BufferedResponseAccumulator: accumulates SSE delta events for non-streaming +// paths where the terminal event may have empty output. +// --------------------------------------------------------------------------- + +type bufferedFuncCall struct { + CallID string + Name string + Args strings.Builder +} + +// BufferedResponseAccumulator collects content from Responses SSE delta events +// so that non-streaming handlers can reconstruct output when the terminal event +// (response.completed / response.done) carries an empty output array. +type BufferedResponseAccumulator struct { + text strings.Builder + reasoning strings.Builder + funcCalls []bufferedFuncCall + outputIndexToFuncIdx map[int]int +} + +// NewBufferedResponseAccumulator returns an initialised accumulator. +func NewBufferedResponseAccumulator() *BufferedResponseAccumulator { + return &BufferedResponseAccumulator{ + outputIndexToFuncIdx: make(map[int]int), + } +} + +// ProcessEvent inspects a single Responses SSE event and accumulates any +// content it carries. Only delta events that contribute to the final output +// are handled; all other event types are silently ignored. +func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent) { + switch event.Type { + case "response.output_text.delta": + if event.Delta != "" { + _, _ = a.text.WriteString(event.Delta) + } + case "response.output_item.added": + if event.Item != nil && event.Item.Type == "function_call" { + idx := len(a.funcCalls) + a.outputIndexToFuncIdx[event.OutputIndex] = idx + a.funcCalls = append(a.funcCalls, bufferedFuncCall{ + CallID: event.Item.CallID, + Name: event.Item.Name, + }) + } + case "response.function_call_arguments.delta": + if event.Delta != "" { + if idx, ok := a.outputIndexToFuncIdx[event.OutputIndex]; ok { + _, _ = a.funcCalls[idx].Args.WriteString(event.Delta) + } + } + case "response.reasoning_summary_text.delta": + if event.Delta != "" { + _, _ = a.reasoning.WriteString(event.Delta) + } + } +} + +// HasContent reports whether any content has been accumulated. +func (a *BufferedResponseAccumulator) HasContent() bool { + return a.text.Len() > 0 || len(a.funcCalls) > 0 || a.reasoning.Len() > 0 +} + +// BuildOutput constructs a []ResponsesOutput from the accumulated delta +// content. The order matches what ResponsesToChatCompletions expects: +// reasoning → message → function_calls. +func (a *BufferedResponseAccumulator) BuildOutput() []ResponsesOutput { + var out []ResponsesOutput + + if a.reasoning.Len() > 0 { + out = append(out, ResponsesOutput{ + Type: "reasoning", + Summary: []ResponsesSummary{{ + Type: "summary_text", + Text: a.reasoning.String(), + }}, + }) + } + + if a.text.Len() > 0 { + out = append(out, ResponsesOutput{ + Type: "message", + Role: "assistant", + Content: []ResponsesContentPart{{ + Type: "output_text", + Text: a.text.String(), + }}, + }) + } + + for i := range a.funcCalls { + out = append(out, ResponsesOutput{ + Type: "function_call", + CallID: a.funcCalls[i].CallID, + Name: a.funcCalls[i].Name, + Arguments: a.funcCalls[i].Args.String(), + }) + } + + return out +} + +// SupplementResponseOutput fills resp.Output from accumulated delta content +// when the terminal event delivered an empty output array. If resp.Output is +// already populated, this is a no-op (preserves backward compatibility). +func (a *BufferedResponseAccumulator) SupplementResponseOutput(resp *ResponsesResponse) { + if resp == nil || len(resp.Output) > 0 { + return + } + if !a.HasContent() { + return + } + resp.Output = a.BuildOutput() +} diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index be076cc082..4f24749b5e 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -244,6 +244,7 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( var finalResponse *apicompat.ResponsesResponse var usage OpenAIUsage + acc := apicompat.NewBufferedResponseAccumulator() for scanner.Scan() { line := scanner.Text() @@ -261,7 +262,11 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( continue } - if (event.Type == "response.completed" || event.Type == "response.incomplete" || event.Type == "response.failed") && + // Accumulate delta content for fallback when terminal output is empty. + acc.ProcessEvent(&event) + + if (event.Type == "response.completed" || event.Type == "response.done" || + event.Type == "response.incomplete" || event.Type == "response.failed") && event.Response != nil { finalResponse = event.Response if event.Response.Usage != nil { @@ -290,6 +295,10 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( return nil, fmt.Errorf("upstream stream ended without terminal event") } + // When the terminal event has an empty output array, reconstruct from + // accumulated delta events so the client receives the full content. + acc.SupplementResponseOutput(finalResponse) + chatResp := apicompat.ResponsesToChatCompletions(finalResponse, originalModel) if s.responseHeaderFilter != nil { diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index f03f44d52f..a0e1d239ad 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -21,6 +21,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" @@ -3901,6 +3902,16 @@ func (s *OpenAIGatewayService) handleOAuthSSEToJSON(resp *http.Response, c *gin. if parsedUsage, parsed := extractOpenAIUsageFromJSONBytes(finalResponse); parsed { *usage = parsedUsage } + // When the terminal event has an empty output array, reconstruct + // output from accumulated delta events so the client gets full content. + // gjson Array() returns empty slice for null, missing, or empty arrays. + if len(gjson.GetBytes(finalResponse, "output").Array()) == 0 { + if outputJSON, reconstructed := reconstructResponseOutputFromSSE(bodyText); reconstructed { + if patched, err := sjson.SetRawBytes(finalResponse, "output", outputJSON); err == nil { + finalResponse = patched + } + } + } body = finalResponse if originalModel != mappedModel { body = s.replaceModelInResponseBody(body, mappedModel, originalModel) @@ -4002,6 +4013,34 @@ func extractCodexFinalResponse(body string) ([]byte, bool) { return nil, false } +// reconstructResponseOutputFromSSE scans raw SSE body text for delta events and +// returns a JSON-encoded output array reconstructed from accumulated deltas. +// Returns (nil, false) if no content was found in deltas. +func reconstructResponseOutputFromSSE(bodyText string) ([]byte, bool) { + acc := apicompat.NewBufferedResponseAccumulator() + lines := strings.Split(bodyText, "\n") + for _, line := range lines { + data, ok := extractOpenAISSEDataLine(line) + if !ok || data == "" || data == "[DONE]" { + continue + } + var event apicompat.ResponsesStreamEvent + if err := json.Unmarshal([]byte(data), &event); err != nil { + continue + } + acc.ProcessEvent(&event) + } + if !acc.HasContent() { + return nil, false + } + output := acc.BuildOutput() + outputJSON, err := json.Marshal(output) + if err != nil { + return nil, false + } + return outputJSON, true +} + func (s *OpenAIGatewayService) parseSSEUsageFromBody(body string) *OpenAIUsage { usage := &OpenAIUsage{} lines := strings.Split(body, "\n") From 7c60ee3c85d53e003607b0dd3bb395a1ab395be9 Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 7 Apr 2026 20:28:14 +0800 Subject: [PATCH 23/59] =?UTF-8?q?feat:=20Beta=E7=AD=96=E7=95=A5=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=8C=89=E6=A8=A1=E5=9E=8B=E5=8C=BA=E5=88=86=E5=A4=84?= =?UTF-8?q?=E7=90=86=EF=BC=88=E6=A8=A1=E5=9E=8B=E7=99=BD=E5=90=8D=E5=8D=95?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/dto/settings.go | 11 +- backend/internal/service/gateway_service.go | 55 +++++-- backend/internal/service/setting_service.go | 12 ++ backend/internal/service/settings_view.go | 11 +- frontend/src/api/admin/settings.ts | 3 + frontend/src/i18n/locales/en.ts | 14 +- frontend/src/i18n/locales/zh.ts | 14 +- frontend/src/views/admin/SettingsView.vue | 159 +++++++++++++++++++- 8 files changed, 255 insertions(+), 24 deletions(-) diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index acc1129cd2..aecbf0c8be 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -157,10 +157,13 @@ type RectifierSettings struct { // BetaPolicyRule Beta 策略规则 DTO type BetaPolicyRule struct { - BetaToken string `json:"beta_token"` - Action string `json:"action"` - Scope string `json:"scope"` - ErrorMessage string `json:"error_message,omitempty"` + BetaToken string `json:"beta_token"` + Action string `json:"action"` + Scope string `json:"scope"` + ErrorMessage string `json:"error_message,omitempty"` + ModelWhitelist []string `json:"model_whitelist,omitempty"` + FallbackAction string `json:"fallback_action,omitempty"` + FallbackErrorMessage string `json:"fallback_error_message,omitempty"` } // BetaPolicySettings Beta 策略配置 DTO diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index f410d69b30..4ed78e9371 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -3946,7 +3946,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A // Beta policy: evaluate once; block check + cache filter set for buildUpstreamRequest. // Always overwrite the cache to prevent stale values from a previous retry with a different account. if account.Platform == PlatformAnthropic && c != nil { - policy := s.evaluateBetaPolicy(ctx, c.GetHeader("anthropic-beta"), account) + policy := s.evaluateBetaPolicy(ctx, c.GetHeader("anthropic-beta"), account, parsed.Model) if policy.blockErr != nil { return nil, policy.blockErr } @@ -5603,7 +5603,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } // Build effective drop set: merge static defaults with dynamic beta policy filter rules - policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account) + policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account, modelID) effectiveDropSet := mergeDropSets(policyFilterSet) effectiveDropWithClaudeCodeSet := mergeDropSets(policyFilterSet, claude.BetaClaudeCode) @@ -5843,7 +5843,7 @@ type betaPolicyResult struct { } // evaluateBetaPolicy loads settings once and evaluates all rules against the given request. -func (s *GatewayService) evaluateBetaPolicy(ctx context.Context, betaHeader string, account *Account) betaPolicyResult { +func (s *GatewayService) evaluateBetaPolicy(ctx context.Context, betaHeader string, account *Account, model string) betaPolicyResult { if s.settingService == nil { return betaPolicyResult{} } @@ -5858,10 +5858,11 @@ func (s *GatewayService) evaluateBetaPolicy(ctx context.Context, betaHeader stri if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) { continue } - switch rule.Action { + effectiveAction, effectiveErrMsg := resolveRuleAction(rule, model) + switch effectiveAction { case BetaPolicyActionBlock: if result.blockErr == nil && betaHeader != "" && containsBetaToken(betaHeader, rule.BetaToken) { - msg := rule.ErrorMessage + msg := effectiveErrMsg if msg == "" { msg = "beta feature " + rule.BetaToken + " is not allowed" } @@ -5903,7 +5904,7 @@ const betaPolicyFilterSetKey = "betaPolicyFilterSet" // In the /v1/messages path, Forward() evaluates the policy first and caches the result; // buildUpstreamRequest reuses it (zero extra DB calls). In the count_tokens path, this // evaluates on demand (one DB call). -func (s *GatewayService) getBetaPolicyFilterSet(ctx context.Context, c *gin.Context, account *Account) map[string]struct{} { +func (s *GatewayService) getBetaPolicyFilterSet(ctx context.Context, c *gin.Context, account *Account, model string) map[string]struct{} { if c != nil { if v, ok := c.Get(betaPolicyFilterSetKey); ok { if fs, ok := v.(map[string]struct{}); ok { @@ -5911,7 +5912,7 @@ func (s *GatewayService) getBetaPolicyFilterSet(ctx context.Context, c *gin.Cont } } } - return s.evaluateBetaPolicy(ctx, "", account).filterSet + return s.evaluateBetaPolicy(ctx, "", account, model).filterSet } // betaPolicyScopeMatches checks whether a rule's scope matches the current account type. @@ -5930,6 +5931,33 @@ func betaPolicyScopeMatches(scope string, isOAuth bool, isBedrock bool) bool { } } +// matchModelWhitelist checks if a model matches any pattern in the whitelist. +// Reuses matchModelPattern from group.go which supports exact and wildcard prefix matching. +func matchModelWhitelist(model string, whitelist []string) bool { + for _, pattern := range whitelist { + if matchModelPattern(pattern, model) { + return true + } + } + return false +} + +// resolveRuleAction determines the effective action and error message for a rule given the request model. +// When ModelWhitelist is empty, the rule's primary Action/ErrorMessage applies unconditionally. +// When non-empty, Action applies to matching models; FallbackAction/FallbackErrorMessage applies to others. +func resolveRuleAction(rule BetaPolicyRule, model string) (action, errorMessage string) { + if len(rule.ModelWhitelist) == 0 { + return rule.Action, rule.ErrorMessage + } + if matchModelWhitelist(model, rule.ModelWhitelist) { + return rule.Action, rule.ErrorMessage + } + if rule.FallbackAction != "" { + return rule.FallbackAction, rule.FallbackErrorMessage + } + return BetaPolicyActionPass, "" // default fallback: pass (fail-open) +} + // droppedBetaSet returns claude.DroppedBetas as a set, with optional extra tokens. func droppedBetaSet(extra ...string) map[string]struct{} { m := make(map[string]struct{}, len(defaultDroppedBetasSet)+len(extra)) @@ -5976,7 +6004,7 @@ func (s *GatewayService) resolveBedrockBetaTokensForRequest( modelID string, ) ([]string, error) { // 1. 对原始 header 中的 beta token 做 block 检查(快速失败) - policy := s.evaluateBetaPolicy(ctx, betaHeader, account) + policy := s.evaluateBetaPolicy(ctx, betaHeader, account, modelID) if policy.blockErr != nil { return nil, policy.blockErr } @@ -5988,7 +6016,7 @@ func (s *GatewayService) resolveBedrockBetaTokensForRequest( // 例如:管理员 block 了 interleaved-thinking,客户端不在 header 中带该 token, // 但请求体中包含 thinking 字段 → autoInjectBedrockBetaTokens 会自动补齐 → // 如果不做此检查,block 规则会被绕过。 - if blockErr := s.checkBetaPolicyBlockForTokens(ctx, betaTokens, account); blockErr != nil { + if blockErr := s.checkBetaPolicyBlockForTokens(ctx, betaTokens, account, modelID); blockErr != nil { return nil, blockErr } @@ -5997,7 +6025,7 @@ func (s *GatewayService) resolveBedrockBetaTokensForRequest( // checkBetaPolicyBlockForTokens 检查 token 列表中是否有被管理员 block 规则命中的 token。 // 用于补充 evaluateBetaPolicy 对 header 的检查,覆盖 body 自动注入的 token。 -func (s *GatewayService) checkBetaPolicyBlockForTokens(ctx context.Context, tokens []string, account *Account) *BetaBlockedError { +func (s *GatewayService) checkBetaPolicyBlockForTokens(ctx context.Context, tokens []string, account *Account, model string) *BetaBlockedError { if s.settingService == nil || len(tokens) == 0 { return nil } @@ -6009,14 +6037,15 @@ func (s *GatewayService) checkBetaPolicyBlockForTokens(ctx context.Context, toke isBedrock := account.IsBedrock() tokenSet := buildBetaTokenSet(tokens) for _, rule := range settings.Rules { - if rule.Action != BetaPolicyActionBlock { + effectiveAction, effectiveErrMsg := resolveRuleAction(rule, model) + if effectiveAction != BetaPolicyActionBlock { continue } if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) { continue } if _, present := tokenSet[rule.BetaToken]; present { - msg := rule.ErrorMessage + msg := effectiveErrMsg if msg == "" { msg = "beta feature " + rule.BetaToken + " is not allowed" } @@ -8474,7 +8503,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con } // Build effective drop set for count_tokens: merge static defaults with dynamic beta policy filter rules - ctEffectiveDropSet := mergeDropSets(s.getBetaPolicyFilterSet(ctx, c, account)) + ctEffectiveDropSet := mergeDropSets(s.getBetaPolicyFilterSet(ctx, c, account, modelID)) // OAuth 账号:处理 anthropic-beta header if tokenType == "oauth" { diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index a85efabd6d..b7145121ad 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -1527,6 +1527,18 @@ func (s *SettingService) SetBetaPolicySettings(ctx context.Context, settings *Be if !validScopes[rule.Scope] { return fmt.Errorf("rule[%d]: invalid scope %q", i, rule.Scope) } + // Validate model_whitelist patterns + for j, pattern := range rule.ModelWhitelist { + trimmed := strings.TrimSpace(pattern) + if trimmed == "" { + return fmt.Errorf("rule[%d]: model_whitelist[%d] cannot be empty", i, j) + } + settings.Rules[i].ModelWhitelist[j] = trimmed + } + // Validate fallback_action + if rule.FallbackAction != "" && !validActions[rule.FallbackAction] { + return fmt.Errorf("rule[%d]: invalid fallback_action %q", i, rule.FallbackAction) + } } data, err := json.Marshal(settings) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 4b64267fec..473d72978c 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -178,10 +178,13 @@ const ( // BetaPolicyRule 单条 Beta 策略规则 type BetaPolicyRule struct { - BetaToken string `json:"beta_token"` // beta token 值 - Action string `json:"action"` // "pass" | "filter" | "block" - Scope string `json:"scope"` // "all" | "oauth" | "apikey" | "bedrock" - ErrorMessage string `json:"error_message,omitempty"` // 自定义错误消息 (action=block 时生效) + BetaToken string `json:"beta_token"` // beta token 值 + Action string `json:"action"` // "pass" | "filter" | "block" + Scope string `json:"scope"` // "all" | "oauth" | "apikey" | "bedrock" + ErrorMessage string `json:"error_message,omitempty"` // 自定义错误消息 (action=block 时生效) + ModelWhitelist []string `json:"model_whitelist,omitempty"` // 模型匹配模式列表(为空=对所有模型生效) + FallbackAction string `json:"fallback_action,omitempty"` // 未匹配白名单的模型的处理方式 + FallbackErrorMessage string `json:"fallback_error_message,omitempty"` // 未匹配白名单时的自定义错误消息 (fallback_action=block 时生效) } // BetaPolicySettings Beta 策略配置 diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 8f9284b70e..013f2dfb10 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -359,6 +359,9 @@ export interface BetaPolicyRule { action: 'pass' | 'filter' | 'block' scope: 'all' | 'oauth' | 'apikey' | 'bedrock' error_message?: string + model_whitelist?: string[] + fallback_action?: 'pass' | 'filter' | 'block' + fallback_error_message?: string } /** diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 30c87d92a8..fc9297fd93 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4587,7 +4587,19 @@ export default { errorMessagePlaceholder: 'Custom error message when blocked', errorMessageHint: 'Leave empty for default message', saved: 'Beta policy settings saved', - saveFailed: 'Failed to save beta policy settings' + saveFailed: 'Failed to save beta policy settings', + modelWhitelist: 'Model Whitelist', + modelWhitelistHint: 'Leave empty to apply to all models. Supports exact match and wildcard prefix (e.g., claude-opus-*)', + modelPatternPlaceholder: 'e.g., claude-opus-* or claude-opus-4-6', + addModelPattern: 'Add model pattern', + removePattern: 'Remove', + fallbackAction: 'Fallback Action', + fallbackActionHint: 'Action for models not matching the whitelist', + fallbackErrorMessagePlaceholder: 'Custom error message when non-whitelisted models are blocked', + quickPresets: 'Quick Presets', + presetOpusOnly: 'Opus only for 1M', + presetOpusOnlyDesc: 'Pass for Opus, filter others', + commonPatterns: 'Common patterns' }, saveSettings: 'Save Settings', saving: 'Saving...', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index d7d920ae0d..57bfefdc82 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4751,7 +4751,19 @@ export default { errorMessagePlaceholder: '拦截时返回的自定义错误消息', errorMessageHint: '留空则使用默认错误消息', saved: 'Beta 策略设置保存成功', - saveFailed: '保存 Beta 策略设置失败' + saveFailed: '保存 Beta 策略设置失败', + modelWhitelist: '模型白名单', + modelWhitelistHint: '留空则对所有模型生效。支持精确匹配和通配符前缀(如 claude-opus-*)', + modelPatternPlaceholder: '例如: claude-opus-* 或 claude-opus-4-6', + addModelPattern: '添加模型规则', + removePattern: '移除', + fallbackAction: '未匹配模型处理方式', + fallbackActionHint: '当请求模型不在白名单中时的处理方式', + fallbackErrorMessagePlaceholder: '未匹配模型被拦截时返回的自定义错误消息', + quickPresets: '快捷预设', + presetOpusOnly: '仅 Opus 允许 1M', + presetOpusOnlyDesc: 'Opus 透传,其他模型过滤', + commonPatterns: '常用模式' }, saveSettings: '保存设置', saving: '保存中...', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 1839d03cc4..9ae40aeb31 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -630,6 +630,108 @@ {{ t('admin.settings.betaPolicy.errorMessageHint') }}

+ + +
+ +
+ +
+
+ + +
+ +

+ {{ t('admin.settings.betaPolicy.modelWhitelistHint') }} +

+ +
+ + +
+ + + +
+ {{ t('admin.settings.betaPolicy.commonPatterns') }}: + +
+
+ + +
+ + +

+ {{ t('admin.settings.betaPolicy.errorMessageHint') }} +

+
+ @@ -2058,6 +2160,9 @@ const betaPolicyForm = reactive({ action: 'pass' | 'filter' | 'block' scope: 'all' | 'oauth' | 'apikey' | 'bedrock' error_message?: string + model_whitelist?: string[] + fallback_action?: 'pass' | 'filter' | 'block' + fallback_error_message?: string }> }) @@ -2716,10 +2821,48 @@ const betaDisplayNames: Record = { 'context-1m-2025-08-07': 'Context 1M' } +// 快捷预设:按 beta_token 定义预设方案 +const betaPresets: Record> = { + 'context-1m-2025-08-07': [ + { + label: t('admin.settings.betaPolicy.presetOpusOnly'), + description: t('admin.settings.betaPolicy.presetOpusOnlyDesc'), + action: 'pass', + model_whitelist: ['claude-opus-4-6'], + fallback_action: 'filter', + }, + ], +} + +// 常用模型模式(具体 ID + 通配符示例) +const commonModelPatterns = ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-opus-*', 'claude-sonnet-*'] + function getBetaDisplayName(token: string): string { return betaDisplayNames[token] || token } +function applyBetaPreset( + rule: (typeof betaPolicyForm.rules)[number], + preset: { action: 'pass' | 'filter' | 'block'; model_whitelist: string[]; fallback_action: 'pass' | 'filter' | 'block' } +) { + rule.action = preset.action + rule.model_whitelist = [...preset.model_whitelist] + rule.fallback_action = preset.fallback_action +} + +function addQuickPattern(rule: (typeof betaPolicyForm.rules)[number], pattern: string) { + if (!rule.model_whitelist) rule.model_whitelist = [] + if (!rule.model_whitelist.includes(pattern)) { + rule.model_whitelist.push(pattern) + } +} + async function loadBetaPolicySettings() { betaPolicyLoading.value = true try { @@ -2735,8 +2878,22 @@ async function loadBetaPolicySettings() { async function saveBetaPolicySettings() { betaPolicySaving.value = true try { + // Clean up empty patterns before saving + const cleanedRules = betaPolicyForm.rules.map(rule => { + const whitelist = rule.model_whitelist?.filter(p => p.trim() !== '') + const hasWhitelist = whitelist && whitelist.length > 0 + return { + beta_token: rule.beta_token, + action: rule.action, + scope: rule.scope, + error_message: rule.error_message, + model_whitelist: hasWhitelist ? whitelist : undefined, + fallback_action: hasWhitelist ? (rule.fallback_action || 'pass') : undefined, + fallback_error_message: hasWhitelist && rule.fallback_action === 'block' ? rule.fallback_error_message : undefined, + } + }) const updated = await adminAPI.settings.updateBetaPolicySettings({ - rules: betaPolicyForm.rules + rules: cleanedRules }) betaPolicyForm.rules = updated.rules appStore.showSuccess(t('admin.settings.betaPolicy.saved')) From 3694811d069e64363bb48010b01c98dcfc12734c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:49:46 +0000 Subject: [PATCH 24/59] chore: sync VERSION to 0.1.109 [skip ci] --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 68a31b00ed..94e74b8ef3 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.108 +0.1.109 From 9e515ea7c4f6eba392201b2f32474153987a50e6 Mon Sep 17 00:00:00 2001 From: Elysia <1628615876@qq.com> Date: Tue, 7 Apr 2026 22:49:14 +0800 Subject: [PATCH 25/59] =?UTF-8?q?fix:=20=E9=9D=9E=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E8=B7=AF=E5=BE=84=E6=89=A9=E5=B1=95SSE?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E8=87=B3=E6=89=80=E6=9C=89=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=20(#1493)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当上游返回SSE格式响应(如sub2api链路)时,API Key账号的非流式路径 未检测SSE,导致终态事件中空output直接透传给客户端。 - 将Content-Type SSE检测从仅OAuth扩展至所有账号类型 - 重命名handleOAuthSSEToJSON为handleSSEToJSON(无OAuth专属逻辑) - 为透传路径新增handlePassthroughSSEToJSON,支持SSE转JSON及空output重建 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../service/openai_gateway_service.go | 75 ++++++++++++++++++- .../service/openai_gateway_service_test.go | 12 +-- 2 files changed, 78 insertions(+), 9 deletions(-) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 65e704084d..5ecb4ebc4c 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -3007,6 +3007,14 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( return nil, err } + // Detect SSE responses from upstream and convert to JSON. + // Some upstreams (e.g. other sub2api instances) may return SSE even when + // stream=false was requested. Without this conversion the client would + // receive raw SSE text or a terminal event with empty output. + if isEventStreamResponse(resp.Header) { + return s.handlePassthroughSSEToJSON(resp, c, body) + } + usage := &OpenAIUsage{} usageParsed := false if len(body) > 0 { @@ -3030,6 +3038,56 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( return usage, nil } +// handlePassthroughSSEToJSON converts an SSE response body into a JSON +// response for the passthrough path. It mirrors handleSSEToJSON but skips +// model replacement (passthrough does not remap models). +func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c *gin.Context, body []byte) (*OpenAIUsage, error) { + bodyText := string(body) + finalResponse, ok := extractCodexFinalResponse(bodyText) + + usage := &OpenAIUsage{} + if ok { + if parsedUsage, parsed := extractOpenAIUsageFromJSONBytes(finalResponse); parsed { + *usage = parsedUsage + } + // When the terminal event has an empty output array, reconstruct + // output from accumulated delta events so the client gets full content. + if len(gjson.GetBytes(finalResponse, "output").Array()) == 0 { + if outputJSON, reconstructed := reconstructResponseOutputFromSSE(bodyText); reconstructed { + if patched, err := sjson.SetRawBytes(finalResponse, "output", outputJSON); err == nil { + finalResponse = patched + } + } + } + body = finalResponse + // Correct tool calls in final response + body = s.correctToolCallsInResponseBody(body) + } else { + terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText) + if terminalOK && terminalType == "response.failed" { + msg := extractOpenAISSEErrorMessage(terminalPayload) + if msg == "" { + msg = "Upstream compact response failed" + } + return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg) + } + usage = s.parseSSEUsageFromBody(bodyText) + } + + writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) + + contentType := "application/json; charset=utf-8" + if !ok { + contentType = resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "text/event-stream" + } + } + c.Data(resp.StatusCode, contentType, body) + + return usage, nil +} + func writeOpenAIPassthroughResponseHeaders(dst http.Header, src http.Header, filter *responseheaders.CompiledHeaderFilter) { if dst == nil || src == nil { return @@ -3858,10 +3916,21 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r return nil, err } + // Detect SSE responses for ALL account types via Content-Type header. + // Some OpenAI-compatible upstreams (including other sub2api instances) + // may return SSE even when stream=false was requested. + if isEventStreamResponse(resp.Header) { + return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel) + } + // For OAuth accounts, also fall back to a body-content heuristic because + // the upstream may omit the Content-Type header while still sending SSE. + // This heuristic is NOT applied to API-key accounts to avoid false + // positives on JSON responses that coincidentally contain "data:" or + // "event:" in their text content. if account.Type == AccountTypeOAuth { bodyLooksLikeSSE := bytes.Contains(body, []byte("data:")) || bytes.Contains(body, []byte("event:")) - if isEventStreamResponse(resp.Header) || bodyLooksLikeSSE { - return s.handleOAuthSSEToJSON(resp, c, body, originalModel, mappedModel) + if bodyLooksLikeSSE { + return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel) } } @@ -3895,7 +3964,7 @@ func isEventStreamResponse(header http.Header) bool { return strings.Contains(contentType, "text/event-stream") } -func (s *OpenAIGatewayService) handleOAuthSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel, mappedModel string) (*OpenAIUsage, error) { +func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel, mappedModel string) (*OpenAIUsage, error) { bodyText := string(body) finalResponse, ok := extractCodexFinalResponse(bodyText) diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 9e2f33f22a..6588096188 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -1797,7 +1797,7 @@ func TestExtractCodexFinalResponse_SampleReplay(t *testing.T) { require.Contains(t, string(finalResp), `"input_tokens":11`) } -func TestHandleOAuthSSEToJSON_CompletedEventReturnsJSON(t *testing.T) { +func TestHandleSSEToJSON_CompletedEventReturnsJSON(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -1814,7 +1814,7 @@ func TestHandleOAuthSSEToJSON_CompletedEventReturnsJSON(t *testing.T) { `data: [DONE]`, }, "\n")) - usage, err := svc.handleOAuthSSEToJSON(resp, c, body, "gpt-4o", "gpt-4o") + usage, err := svc.handleSSEToJSON(resp, c, body, "gpt-4o", "gpt-4o") require.NoError(t, err) require.NotNil(t, usage) require.Equal(t, 7, usage.InputTokens) @@ -1826,7 +1826,7 @@ func TestHandleOAuthSSEToJSON_CompletedEventReturnsJSON(t *testing.T) { require.NotContains(t, rec.Body.String(), "data:") } -func TestHandleOAuthSSEToJSON_NoFinalResponseKeepsSSEBody(t *testing.T) { +func TestHandleSSEToJSON_NoFinalResponseKeepsSSEBody(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -1842,7 +1842,7 @@ func TestHandleOAuthSSEToJSON_NoFinalResponseKeepsSSEBody(t *testing.T) { `data: [DONE]`, }, "\n")) - usage, err := svc.handleOAuthSSEToJSON(resp, c, body, "gpt-4o", "gpt-4o") + usage, err := svc.handleSSEToJSON(resp, c, body, "gpt-4o", "gpt-4o") require.NoError(t, err) require.NotNil(t, usage) require.Equal(t, 0, usage.InputTokens) @@ -1850,7 +1850,7 @@ func TestHandleOAuthSSEToJSON_NoFinalResponseKeepsSSEBody(t *testing.T) { require.Contains(t, rec.Body.String(), `data: {"type":"response.in_progress"`) } -func TestHandleOAuthSSEToJSON_ResponseFailedReturnsProtocolError(t *testing.T) { +func TestHandleSSEToJSON_ResponseFailedReturnsProtocolError(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -1866,7 +1866,7 @@ func TestHandleOAuthSSEToJSON_ResponseFailedReturnsProtocolError(t *testing.T) { `data: [DONE]`, }, "\n")) - usage, err := svc.handleOAuthSSEToJSON(resp, c, body, "gpt-4o", "gpt-4o") + usage, err := svc.handleSSEToJSON(resp, c, body, "gpt-4o", "gpt-4o") require.Nil(t, usage) require.Error(t, err) require.Equal(t, http.StatusBadGateway, rec.Code) From 1c9a2128cf51b4a190a5aef4e172bf095f921997 Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 8 Apr 2026 14:06:06 +0800 Subject: [PATCH 26/59] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=9D=9ECC?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AFOAuth=E4=BC=AA=E8=A3=85=E8=A2=ABAnth?= =?UTF-8?q?ropic=E6=A3=80=E6=B5=8B=E4=B8=BA=E7=AC=AC=E4=B8=89=E6=96=B9?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit f3aa54b 的 rewriteSystemForNonClaudeCode 未能通过 Anthropic 第三方检测, 根因是两个关键信号与真实 Claude Code 不一致: 1. anthropic-beta 头缺少 claude-code-20250219:伪装路径主动将该 beta 加入 drop set 并移除,但 Anthropic 依赖此 beta 识别 Claude Code 请求。 修复:非 haiku 模型的伪装请求强制包含 claude-code beta。 2. system 字段使用 string 格式而非 array+cache_control:真实 Claude Code 始终以 [{type,text,cache_control:{type:"ephemeral"}}] 发送 system, string 格式成为第三方检测信号。 修复:rewriteSystemForNonClaudeCode 改为注入 array 格式。 附带调整:stripSystemCacheControl 按 system 是否被重写动态决定, 重写时保留 CC prompt 的 cache_control,未重写时(haiku/已含CC前缀) 保持原有剥离行为。 --- ...teway_anthropic_apikey_passthrough_test.go | 4 ++- .../internal/service/gateway_prompt_test.go | 35 +++++++++++-------- backend/internal/service/gateway_service.go | 34 +++++++++++++----- 3 files changed, 50 insertions(+), 23 deletions(-) diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index e7661aad63..5be1f73328 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -761,7 +761,9 @@ func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock( system := gjson.GetBytes(upstream.lastBody, "system") require.True(t, system.Exists()) - require.Equal(t, claudeCodeSystemPrompt, system.String()) + require.True(t, system.IsArray(), "system should be an array") + require.Equal(t, claudeCodeSystemPrompt, system.Array()[0].Get("text").String()) + require.Equal(t, "ephemeral", system.Array()[0].Get("cache_control.type").String()) // 原始 system prompt 应迁移至 messages 中 messages := gjson.GetBytes(upstream.lastBody, "messages") diff --git a/backend/internal/service/gateway_prompt_test.go b/backend/internal/service/gateway_prompt_test.go index d0f5a8c02b..e27e18aaa7 100644 --- a/backend/internal/service/gateway_prompt_test.go +++ b/backend/internal/service/gateway_prompt_test.go @@ -284,7 +284,7 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { name string body string system any - wantSystemStr string // system 应为纯字符串 + wantSystemText string // system array 第一个 block 的 text wantMessagesLen int // messages 数组长度 wantFirstMsgRole string // 第一条消息的 role wantFirstMsgText string // 第一条消息的 content[0].text @@ -294,21 +294,21 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { name: "nil system - no messages injected", body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`, system: nil, - wantSystemStr: claudeCodeSystemPrompt, + wantSystemText: claudeCodeSystemPrompt, wantMessagesLen: 1, // 原始 1 条消息,不注入 }, { name: "empty string system - no messages injected", body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`, system: "", - wantSystemStr: claudeCodeSystemPrompt, + wantSystemText: claudeCodeSystemPrompt, wantMessagesLen: 1, }, { name: "custom string system - migrated to messages", body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`, system: "You are a personal assistant running inside OpenClaw.", - wantSystemStr: claudeCodeSystemPrompt, + wantSystemText: claudeCodeSystemPrompt, wantMessagesLen: 3, // instruction + ack + original wantFirstMsgRole: "user", wantFirstMsgText: "[System Instructions]\nYou are a personal assistant running inside OpenClaw.", @@ -318,7 +318,7 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { name: "system equals Claude Code prompt - no messages injected", body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`, system: claudeCodeSystemPrompt, - wantSystemStr: claudeCodeSystemPrompt, + wantSystemText: claudeCodeSystemPrompt, wantMessagesLen: 1, }, { @@ -328,7 +328,7 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { map[string]any{"type": "text", "text": "First instruction"}, map[string]any{"type": "text", "text": "Second instruction"}, }, - wantSystemStr: claudeCodeSystemPrompt, + wantSystemText: claudeCodeSystemPrompt, wantMessagesLen: 3, wantFirstMsgRole: "user", wantFirstMsgText: "[System Instructions]\nFirst instruction\n\nSecond instruction", @@ -338,14 +338,14 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { name: "empty array system - no messages injected", body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`, system: []any{}, - wantSystemStr: claudeCodeSystemPrompt, + wantSystemText: claudeCodeSystemPrompt, wantMessagesLen: 1, }, { name: "json.RawMessage string system", body: `{"model":"claude-3","system":"Custom prompt","messages":[{"role":"user","content":"hello"}]}`, system: json.RawMessage(`"Custom prompt"`), - wantSystemStr: claudeCodeSystemPrompt, + wantSystemText: claudeCodeSystemPrompt, wantMessagesLen: 3, wantFirstMsgRole: "user", wantFirstMsgText: "[System Instructions]\nCustom prompt", @@ -355,14 +355,14 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { name: "json.RawMessage nil system", body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`, system: json.RawMessage(nil), - wantSystemStr: claudeCodeSystemPrompt, + wantSystemText: claudeCodeSystemPrompt, wantMessagesLen: 1, }, { name: "multiple original messages preserved", body: `{"model":"claude-3","messages":[{"role":"user","content":"msg1"},{"role":"assistant","content":"resp1"},{"role":"user","content":"msg2"}]}`, system: "Be helpful", - wantSystemStr: claudeCodeSystemPrompt, + wantSystemText: claudeCodeSystemPrompt, wantMessagesLen: 5, // 2 injected + 3 original wantFirstMsgRole: "user", wantFirstMsgText: "[System Instructions]\nBe helpful", @@ -378,10 +378,17 @@ func TestRewriteSystemForNonClaudeCode(t *testing.T) { err := json.Unmarshal(result, &parsed) require.NoError(t, err) - // system 应为纯字符串 - systemVal, ok := parsed["system"].(string) - require.True(t, ok, "system should be a string, got %T", parsed["system"]) - require.Equal(t, tt.wantSystemStr, systemVal) + // system 应为 array 格式: [{type: "text", text: "...", cache_control: {type: "ephemeral"}}] + systemArr, ok := parsed["system"].([]any) + require.True(t, ok, "system should be an array, got %T", parsed["system"]) + require.Len(t, systemArr, 1, "system array should have exactly 1 block") + systemBlock, ok := systemArr[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "text", systemBlock["type"]) + require.Equal(t, tt.wantSystemText, systemBlock["text"]) + cc, ok := systemBlock["cache_control"].(map[string]any) + require.True(t, ok, "system block should have cache_control") + require.Equal(t, "ephemeral", cc["type"]) // 检查 messages messages, ok := parsed["messages"].([]any) diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 4ed78e9371..fbbebc212e 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -3739,8 +3739,17 @@ func rewriteSystemForNonClaudeCode(body []byte, system any) []byte { originalSystemText = strings.Join(parts, "\n\n") } - // 2. 将 system 替换为 Claude Code 标准提示词(纯字符串,通过 Anthropic 检测) - out, ok := setJSONValueBytes(body, "system", claudeCodeSystemPrompt) + // 2. 将 system 替换为 Claude Code 标准提示词(array 格式,与真实 Claude Code 一致) + // 真实 Claude Code 始终以 [{type: "text", text: "...", cache_control: {type: "ephemeral"}}] 发送 system。 + // 使用 string 格式会被 Anthropic 检测为第三方应用。 + claudeCodeSystemBlock := []map[string]any{ + { + "type": "text", + "text": claudeCodeSystemPrompt, + "cache_control": map[string]string{"type": "ephemeral"}, + }, + } + out, ok := setJSONValueBytes(body, "system", claudeCodeSystemBlock) if !ok { logger.LegacyPrintf("service.gateway", "Warning: failed to set Claude Code system prompt") return body @@ -3978,12 +3987,17 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if shouldMimicClaudeCode { // 非 Claude Code 客户端:将 system 替换为 Claude Code 标识,原始 system 迁移至 messages // 条件:1) OAuth/SetupToken 账号 2) 不是 Claude Code 客户端 3) 不是 Haiku 模型 4) system 中还没有 Claude Code 提示词 + systemRewritten := false if !strings.Contains(strings.ToLower(reqModel), "haiku") && !systemIncludesClaudeCodePrompt(parsed.System) { body = rewriteSystemForNonClaudeCode(body, parsed.System) + systemRewritten = true } - normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true} + // system 被重写时保留 CC prompt 的 cache_control: ephemeral(匹配真实 Claude Code 行为); + // 未重写时(haiku / 已含 CC 前缀)剥离客户端 cache_control,与原有行为一致。 + // 两种情况下 enforceCacheControlLimit 都会兜底处理上限。 + normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten} if s.identityService != nil { fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header) if err == nil && fp != nil { @@ -5605,7 +5619,6 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // Build effective drop set: merge static defaults with dynamic beta policy filter rules policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account, modelID) effectiveDropSet := mergeDropSets(policyFilterSet) - effectiveDropWithClaudeCodeSet := mergeDropSets(policyFilterSet, claude.BetaClaudeCode) // 处理 anthropic-beta header(OAuth 账号需要包含 oauth beta) if tokenType == "oauth" { @@ -5616,11 +5629,16 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex applyClaudeCodeMimicHeaders(req, reqStream) incomingBeta := getHeaderRaw(req.Header, "anthropic-beta") - // Match real Claude CLI traffic (per mitmproxy reports): - // messages requests typically use only oauth + interleaved-thinking. - // Also drop claude-code beta if a downstream client added it. + // Claude Code OAuth credentials are scoped to Claude Code. + // Non-haiku models MUST include claude-code beta for Anthropic to recognize + // this as a legitimate Claude Code request; without it, the request is + // rejected as third-party ("out of extra usage"). + // Haiku models are exempt from third-party detection and don't need it. requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking} - setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropWithClaudeCodeSet)) + if !strings.Contains(strings.ToLower(modelID), "haiku") { + requiredBetas = []string{claude.BetaClaudeCode, claude.BetaOAuth, claude.BetaInterleavedThinking} + } + setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropSet)) } else { // Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta clientBetaHeader := getHeaderRaw(req.Header, "anthropic-beta") From e51c9e50b5376cb486a0b7123e5f1ec026d5c526 Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 8 Apr 2026 16:11:19 +0800 Subject: [PATCH 27/59] feat: sync billing header cc_version with User-Agent and add opt-in CCH signing - Sync cc_version in x-anthropic-billing-header with the fingerprint User-Agent version, preserving the message-derived suffix - Implement xxHash64-based CCH signing to replace the cch=00000 placeholder with a computed hash - Add admin toggle (enable_cch_signing) under gateway forwarding settings, disabled by default --- .../internal/handler/admin/setting_handler.go | 12 ++ backend/internal/handler/dto/settings.go | 1 + backend/internal/service/domain_constants.go | 2 + .../service/gateway_billing_header.go | 73 ++++++++ .../service/gateway_billing_header_test.go | 165 ++++++++++++++++++ backend/internal/service/gateway_service.go | 27 ++- backend/internal/service/setting_service.go | 28 +-- backend/internal/service/settings_view.go | 1 + frontend/src/api/admin/settings.ts | 2 + frontend/src/i18n/locales/en.ts | 2 + frontend/src/i18n/locales/zh.ts | 2 + frontend/src/views/admin/SettingsView.vue | 19 +- 12 files changed, 317 insertions(+), 17 deletions(-) create mode 100644 backend/internal/service/gateway_billing_header.go create mode 100644 backend/internal/service/gateway_billing_header_test.go diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 069169172d..4cbe5188ec 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -128,6 +128,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { BackendModeEnabled: settings.BackendModeEnabled, EnableFingerprintUnification: settings.EnableFingerprintUnification, EnableMetadataPassthrough: settings.EnableMetadataPassthrough, + EnableCCHSigning: settings.EnableCCHSigning, }) } @@ -211,6 +212,7 @@ type UpdateSettingsRequest struct { // Gateway forwarding behavior EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"` EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"` + EnableCCHSigning *bool `json:"enable_cch_signing"` } // UpdateSettings 更新系统设置 @@ -614,6 +616,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.EnableMetadataPassthrough }(), + EnableCCHSigning: func() bool { + if req.EnableCCHSigning != nil { + return *req.EnableCCHSigning + } + return previousSettings.EnableCCHSigning + }(), } if err := h.settingService.UpdateSettings(c.Request.Context(), settings); err != nil { @@ -693,6 +701,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { BackendModeEnabled: updatedSettings.BackendModeEnabled, EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification, EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough, + EnableCCHSigning: updatedSettings.EnableCCHSigning, }) } @@ -871,6 +880,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.EnableMetadataPassthrough != after.EnableMetadataPassthrough { changed = append(changed, "enable_metadata_passthrough") } + if before.EnableCCHSigning != after.EnableCCHSigning { + changed = append(changed, "enable_cch_signing") + } return changed } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index aecbf0c8be..73707f79a5 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -97,6 +97,7 @@ type SystemSettings struct { // Gateway forwarding behavior EnableFingerprintUnification bool `json:"enable_fingerprint_unification"` EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"` + EnableCCHSigning bool `json:"enable_cch_signing"` } type DefaultSubscriptionSetting struct { diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 52df52d656..92be3e06cf 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -218,6 +218,8 @@ const ( SettingKeyEnableFingerprintUnification = "enable_fingerprint_unification" // SettingKeyEnableMetadataPassthrough 是否透传客户端原始 metadata.user_id(默认 false) SettingKeyEnableMetadataPassthrough = "enable_metadata_passthrough" + // SettingKeyEnableCCHSigning 是否对 billing header 中的 cch 进行 xxHash64 签名(默认 false) + SettingKeyEnableCCHSigning = "enable_cch_signing" ) // AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys). diff --git a/backend/internal/service/gateway_billing_header.go b/backend/internal/service/gateway_billing_header.go new file mode 100644 index 0000000000..2102e53453 --- /dev/null +++ b/backend/internal/service/gateway_billing_header.go @@ -0,0 +1,73 @@ +package service + +import ( + "fmt" + "regexp" + "strings" + + "github.com/cespare/xxhash/v2" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// ccVersionInBillingRe matches the semver part of cc_version (X.Y.Z), preserving +// the trailing message-derived suffix (e.g. ".c02") if present. +var ccVersionInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+`) + +// cchPlaceholderRe matches the cch=00000 placeholder in billing header text, +// scoped to x-anthropic-billing-header to avoid touching user content. +var cchPlaceholderRe = regexp.MustCompile(`(x-anthropic-billing-header:[^"]*?\bcch=)(00000)(;)`) + +const cchSeed uint64 = 0x6E52736AC806831E + +// syncBillingHeaderVersion rewrites cc_version in x-anthropic-billing-header +// system text blocks to match the version extracted from userAgent. +// Only touches system array blocks whose text starts with "x-anthropic-billing-header". +func syncBillingHeaderVersion(body []byte, userAgent string) []byte { + version := ExtractCLIVersion(userAgent) + if version == "" { + return body + } + + systemResult := gjson.GetBytes(body, "system") + if !systemResult.Exists() || !systemResult.IsArray() { + return body + } + + replacement := "cc_version=" + version + idx := 0 + systemResult.ForEach(func(_, item gjson.Result) bool { + text := item.Get("text") + if text.Exists() && text.Type == gjson.String && + strings.HasPrefix(text.String(), "x-anthropic-billing-header") { + newText := ccVersionInBillingRe.ReplaceAllString(text.String(), replacement) + if newText != text.String() { + if updated, err := sjson.SetBytes(body, fmt.Sprintf("system.%d.text", idx), newText); err == nil { + body = updated + } + } + } + idx++ + return true + }) + + return body +} + +// signBillingHeaderCCH computes the xxHash64-based CCH signature for the request +// body and replaces the cch=00000 placeholder with the computed 5-hex-char hash. +// The body must contain the placeholder when this function is called. +func signBillingHeaderCCH(body []byte) []byte { + if !cchPlaceholderRe.Match(body) { + return body + } + cch := fmt.Sprintf("%05x", xxHash64Seeded(body, cchSeed)&0xFFFFF) + return cchPlaceholderRe.ReplaceAll(body, []byte("${1}"+cch+"${3}")) +} + +// xxHash64Seeded computes xxHash64 of data with a custom seed. +func xxHash64Seeded(data []byte, seed uint64) uint64 { + d := xxhash.NewWithSeed(seed) + d.Write(data) + return d.Sum64() +} diff --git a/backend/internal/service/gateway_billing_header_test.go b/backend/internal/service/gateway_billing_header_test.go new file mode 100644 index 0000000000..ffc4091c63 --- /dev/null +++ b/backend/internal/service/gateway_billing_header_test.go @@ -0,0 +1,165 @@ +package service + +import ( + "fmt" + "testing" + + "github.com/cespare/xxhash/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestSyncBillingHeaderVersion(t *testing.T) { + tests := []struct { + name string + body string + userAgent string + wantSub string // substring expected in result + unchanged bool // expect body to remain the same + }{ + { + name: "replaces cc_version preserving message-derived suffix", + body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81.df2; cc_entrypoint=cli; cch=00000;"},{"type":"text","text":"You are Claude Code.","cache_control":{"type":"ephemeral"}}],"messages":[]}`, + userAgent: "claude-cli/2.1.22 (external, cli)", + wantSub: "cc_version=2.1.22.df2", + }, + { + name: "no billing header in system", + body: `{"system":[{"type":"text","text":"You are Claude Code."}],"messages":[]}`, + userAgent: "claude-cli/2.1.22", + unchanged: true, + }, + { + name: "no system field", + body: `{"messages":[]}`, + userAgent: "claude-cli/2.1.22", + unchanged: true, + }, + { + name: "user-agent without version", + body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`, + userAgent: "Mozilla/5.0", + unchanged: true, + }, + { + name: "empty user-agent", + body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`, + userAgent: "", + unchanged: true, + }, + { + name: "version already matches", + body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.22; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`, + userAgent: "claude-cli/2.1.22", + unchanged: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := syncBillingHeaderVersion([]byte(tt.body), tt.userAgent) + if tt.unchanged { + assert.Equal(t, tt.body, string(result), "body should remain unchanged") + } else { + assert.Contains(t, string(result), tt.wantSub) + // Ensure old semver is gone + assert.NotContains(t, string(result), "cc_version=2.1.81") + } + }) + } +} + +func TestSignBillingHeaderCCH(t *testing.T) { + t.Run("replaces placeholder with hash", func(t *testing.T) { + body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63.a43; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`) + result := signBillingHeaderCCH(body) + + // Should not have the placeholder anymore + assert.NotContains(t, string(result), "cch=00000") + + // Should have a 5 hex-char cch value + billingText := gjson.GetBytes(result, "system.0.text").String() + require.Contains(t, billingText, "cch=") + assert.Regexp(t, `cch=[0-9a-f]{5};`, billingText) + }) + + t.Run("no placeholder - body unchanged", func(t *testing.T) { + body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=abcde;"}],"messages":[]}`) + result := signBillingHeaderCCH(body) + assert.Equal(t, string(body), string(result)) + }) + + t.Run("no billing header - body unchanged", func(t *testing.T) { + body := []byte(`{"system":[{"type":"text","text":"You are Claude Code."}],"messages":[]}`) + result := signBillingHeaderCCH(body) + assert.Equal(t, string(body), string(result)) + }) + + t.Run("cch=00000 in user content is not touched", func(t *testing.T) { + body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"keep literal cch=00000 in this message"}]}]}`) + result := signBillingHeaderCCH(body) + + // Billing header should be signed + billingText := gjson.GetBytes(result, "system.0.text").String() + assert.NotContains(t, billingText, "cch=00000") + + // User message should keep its literal cch=00000 + userText := gjson.GetBytes(result, "messages.0.content.0.text").String() + assert.Contains(t, userText, "cch=00000") + }) + + t.Run("signing is deterministic", func(t *testing.T) { + body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"hi"}]}`) + r1 := signBillingHeaderCCH(body) + body2 := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"hi"}]}`) + r2 := signBillingHeaderCCH(body2) + assert.Equal(t, string(r1), string(r2)) + }) + + t.Run("matches reference algorithm", func(t *testing.T) { + // Verify: signBillingHeaderCCH(body) produces cch = xxHash64(body_with_placeholder, seed) & 0xFFFFF + body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63.a43; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`) + expectedCCH := fmt.Sprintf("%05x", xxHash64Seeded(body, cchSeed)&0xFFFFF) + + result := signBillingHeaderCCH(body) + billingText := gjson.GetBytes(result, "system.0.text").String() + assert.Contains(t, billingText, "cch="+expectedCCH+";") + }) +} + +func TestXXHash64Seeded(t *testing.T) { + t.Run("matches cespare/xxhash for seed 0", func(t *testing.T) { + inputs := []string{"", "a", "hello world", "The quick brown fox jumps over the lazy dog"} + for _, s := range inputs { + data := []byte(s) + expected := xxhash.Sum64(data) + got := xxHash64Seeded(data, 0) + assert.Equal(t, expected, got, "mismatch for input %q", s) + } + }) + + t.Run("large input matches cespare", func(t *testing.T) { + data := make([]byte, 256) + for i := range data { + data[i] = byte(i) + } + expected := xxhash.Sum64(data) + got := xxHash64Seeded(data, 0) + assert.Equal(t, expected, got) + }) + + t.Run("deterministic with custom seed", func(t *testing.T) { + data := []byte("hello world") + h1 := xxHash64Seeded(data, cchSeed) + h2 := xxHash64Seeded(data, cchSeed) + assert.Equal(t, h1, h2) + }) + + t.Run("different seeds produce different results", func(t *testing.T) { + data := []byte("test data for hashing") + h1 := xxHash64Seeded(data, 0) + h2 := xxHash64Seeded(data, cchSeed) + assert.NotEqual(t, h1, h2) + }) +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index fbbebc212e..a473364916 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -4002,7 +4002,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header) if err == nil && fp != nil { // metadata 透传开启时跳过 metadata 注入 - _, mimicMPT := s.settingService.GetGatewayForwardingSettings(ctx) + _, mimicMPT, _ := s.settingService.GetGatewayForwardingSettings(ctx) if !mimicMPT { if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" { normalizeOpts.injectMetadata = true @@ -5548,9 +5548,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // OAuth账号:应用统一指纹和metadata重写(受设置开关控制) var fingerprint *Fingerprint - enableFP, enableMPT := true, false + enableFP, enableMPT, enableCCH := true, false, false if s.settingService != nil { - enableFP, enableMPT = s.settingService.GetGatewayForwardingSettings(ctx) + enableFP, enableMPT, enableCCH = s.settingService.GetGatewayForwardingSettings(ctx) } if account.IsOAuth() && s.identityService != nil { // 1. 获取或创建指纹(包含随机生成的ClientID) @@ -5577,6 +5577,15 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } } + // 同步 billing header cc_version 与实际发送的 User-Agent 版本 + if fingerprint != nil { + body = syncBillingHeaderVersion(body, fingerprint.UserAgent) + } + // CCH 签名:将 cch=00000 占位符替换为 xxHash64 签名(需在所有 body 修改之后) + if enableCCH { + body = signBillingHeaderCCH(body) + } + req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) if err != nil { return nil, err @@ -8461,9 +8470,9 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con // OAuth 账号:应用统一指纹和重写 userID(受设置开关控制) // 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值 - ctEnableFP, ctEnableMPT := true, false + ctEnableFP, ctEnableMPT, ctEnableCCH := true, false, false if s.settingService != nil { - ctEnableFP, ctEnableMPT = s.settingService.GetGatewayForwardingSettings(ctx) + ctEnableFP, ctEnableMPT, ctEnableCCH = s.settingService.GetGatewayForwardingSettings(ctx) } var ctFingerprint *Fingerprint if account.IsOAuth() && s.identityService != nil { @@ -8481,6 +8490,14 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con } } + // 同步 billing header cc_version 与实际发送的 User-Agent 版本 + if ctFingerprint != nil && ctEnableFP { + body = syncBillingHeaderVersion(body, ctFingerprint.UserAgent) + } + if ctEnableCCH { + body = signBillingHeaderCCH(body) + } + req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) if err != nil { return nil, err diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index b7145121ad..7d0ef5bdd9 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -81,6 +81,7 @@ const backendModeDBTimeout = 5 * time.Second type cachedGatewayForwardingSettings struct { fingerprintUnification bool metadataPassthrough bool + cchSigning bool expiresAt int64 // unix nano } @@ -514,6 +515,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet // Gateway forwarding behavior updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification) updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough) + updates[SettingKeyEnableCCHSigning] = strconv.FormatBool(settings.EnableCCHSigning) err = s.settingRepo.SetMultiple(ctx, updates) if err == nil { @@ -533,6 +535,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ fingerprintUnification: settings.EnableFingerprintUnification, metadataPassthrough: settings.EnableMetadataPassthrough, + cchSigning: settings.EnableCCHSigning, expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), }) if s.onUpdate != nil { @@ -639,20 +642,20 @@ func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool { // GetGatewayForwardingSettings returns cached gateway forwarding settings. // Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path. -// Returns (fingerprintUnification, metadataPassthrough). -func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fingerprintUnification, metadataPassthrough bool) { +// Returns (fingerprintUnification, metadataPassthrough, cchSigning). +func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fingerprintUnification, metadataPassthrough, cchSigning bool) { if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil { if time.Now().UnixNano() < cached.expiresAt { - return cached.fingerprintUnification, cached.metadataPassthrough + return cached.fingerprintUnification, cached.metadataPassthrough, cached.cchSigning } } type gwfResult struct { - fp, mp bool + fp, mp, cch bool } val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) { if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil { if time.Now().UnixNano() < cached.expiresAt { - return gwfResult{cached.fingerprintUnification, cached.metadataPassthrough}, nil + return gwfResult{cached.fingerprintUnification, cached.metadataPassthrough, cached.cchSigning}, nil } } dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout) @@ -660,32 +663,36 @@ func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fing values, err := s.settingRepo.GetMultiple(dbCtx, []string{ SettingKeyEnableFingerprintUnification, SettingKeyEnableMetadataPassthrough, + SettingKeyEnableCCHSigning, }) if err != nil { slog.Warn("failed to get gateway forwarding settings", "error", err) gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ fingerprintUnification: true, metadataPassthrough: false, + cchSigning: false, expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(), }) - return gwfResult{true, false}, nil + return gwfResult{true, false, false}, nil } fp := true if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" { fp = v == "true" } mp := values[SettingKeyEnableMetadataPassthrough] == "true" + cch := values[SettingKeyEnableCCHSigning] == "true" gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{ fingerprintUnification: fp, metadataPassthrough: mp, + cchSigning: cch, expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(), }) - return gwfResult{fp, mp}, nil + return gwfResult{fp, mp, cch}, nil }) if r, ok := val.(gwfResult); ok { - return r.fp, r.mp + return r.fp, r.mp, r.cch } - return true, false // fail-open defaults + return true, false, false // fail-open defaults } // IsEmailVerifyEnabled 检查是否开启邮件验证 @@ -983,13 +990,14 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // 分组隔离 result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true" - // Gateway forwarding behavior (defaults: fingerprint=true, metadata_passthrough=false) + // Gateway forwarding behavior (defaults: fingerprint=true, metadata_passthrough=false, cch_signing=false) if v, ok := settings[SettingKeyEnableFingerprintUnification]; ok && v != "" { result.EnableFingerprintUnification = v == "true" } else { result.EnableFingerprintUnification = true // default: enabled (current behavior) } result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true" + result.EnableCCHSigning = settings[SettingKeyEnableCCHSigning] == "true" return result } diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 473d72978c..fedb3f2fe1 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -78,6 +78,7 @@ type SystemSettings struct { // Gateway forwarding behavior EnableFingerprintUnification bool // 是否统一 OAuth 账号的指纹头(默认 true) EnableMetadataPassthrough bool // 是否透传客户端原始 metadata(默认 false) + EnableCCHSigning bool // 是否对 billing header cch 进行签名(默认 false) } type DefaultSubscriptionSetting struct { diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 013f2dfb10..b7ee6be5ce 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -89,6 +89,7 @@ export interface SystemSettings { // Gateway forwarding behavior enable_fingerprint_unification: boolean enable_metadata_passthrough: boolean + enable_cch_signing: boolean } export interface UpdateSettingsRequest { @@ -146,6 +147,7 @@ export interface UpdateSettingsRequest { allow_ungrouped_key_scheduling?: boolean enable_fingerprint_unification?: boolean enable_metadata_passthrough?: boolean + enable_cch_signing?: boolean } /** diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index fc9297fd93..d3b16d4ad6 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4268,6 +4268,8 @@ export default { fingerprintUnificationHint: 'Unify X-Stainless-* headers across users sharing the same OAuth account. Disabling passes through each client\'s original headers.', metadataPassthrough: 'Metadata Passthrough', metadataPassthroughHint: 'Pass through client\'s original metadata.user_id without rewriting. May improve upstream cache hit rates.', + cchSigning: 'CCH Signing', + cchSigningHint: 'Sign the billing header in forwarded requests with CCH hash. When disabled, the placeholder is preserved.', }, site: { title: 'Site Settings', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 57bfefdc82..fcaaf5abd2 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4431,6 +4431,8 @@ export default { fingerprintUnificationHint: '统一共享同一 OAuth 账号的用户的 X-Stainless-* 请求头。关闭后透传客户端原始请求头。', metadataPassthrough: 'Metadata 透传', metadataPassthroughHint: '透传客户端原始 metadata.user_id,不进行重写。可能提高上游缓存命中率。', + cchSigning: 'CCH 签名', + cchSigningHint: '对转发请求的 billing header 进行 CCH 哈希签名。关闭时保留原始占位符。', }, site: { title: '站点设置', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 9ae40aeb31..f43140abc1 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1376,6 +1376,19 @@ + + +
+
+ +

+ {{ t('admin.settings.gatewayForwarding.cchSigningHint') }} +

+
+ +
@@ -2248,7 +2261,8 @@ const form = reactive({ allow_ungrouped_key_scheduling: false, // Gateway forwarding behavior enable_fingerprint_unification: true, - enable_metadata_passthrough: false + enable_metadata_passthrough: false, + enable_cch_signing: false }) const defaultSubscriptionGroupOptions = computed(() => @@ -2556,7 +2570,8 @@ async function saveSettings() { max_claude_code_version: form.max_claude_code_version, allow_ungrouped_key_scheduling: form.allow_ungrouped_key_scheduling, enable_fingerprint_unification: form.enable_fingerprint_unification, - enable_metadata_passthrough: form.enable_metadata_passthrough + enable_metadata_passthrough: form.enable_metadata_passthrough, + enable_cch_signing: form.enable_cch_signing } const updated = await adminAPI.settings.updateSettings(payload) Object.assign(form, updated) From 7060596a301f56f7b560d3df4e7a749365a2edd2 Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 8 Apr 2026 16:17:15 +0800 Subject: [PATCH 28/59] fix: bump Go from 1.26.1 to 1.26.2 to resolve 6 stdlib CVEs Fixes GO-2026-4947, GO-2026-4946, GO-2026-4870, GO-2026-4869, GO-2026-4866, GO-2026-4865 in crypto/x509, crypto/tls, archive/tar, and html/template. --- .github/workflows/backend-ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- .github/workflows/security-scan.yml | 2 +- Dockerfile | 2 +- backend/go.mod | 13 ++--------- backend/go.sum | 36 ----------------------------- deploy/Dockerfile | 2 +- 7 files changed, 8 insertions(+), 53 deletions(-) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 01c00bb962..6f76ef4f47 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -19,7 +19,7 @@ jobs: cache: true - name: Verify Go version run: | - go version | grep -q 'go1.26.1' + go version | grep -q 'go1.26.2' - name: Unit tests working-directory: backend run: make test-unit @@ -38,7 +38,7 @@ jobs: cache: true - name: Verify Go version run: | - go version | grep -q 'go1.26.1' + go version | grep -q 'go1.26.2' - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c51b3c075e..b729c575ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,7 +115,7 @@ jobs: - name: Verify Go version run: | - go version | grep -q 'go1.26.1' + go version | grep -q 'go1.26.2' # Docker setup for GoReleaser - name: Set up QEMU diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index cc5a90cf30..600fd2faec 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -23,7 +23,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.1' + go version | grep -q 'go1.26.2' - name: Run govulncheck working-directory: backend run: | diff --git a/Dockerfile b/Dockerfile index a16eb958f2..890bda0bfa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.26.1-alpine +ARG GOLANG_IMAGE=golang:1.26.2-alpine ARG ALPINE_IMAGE=alpine:3.21 ARG POSTGRES_IMAGE=postgres:18-alpine ARG GOPROXY=https://goproxy.cn,direct diff --git a/backend/go.mod b/backend/go.mod index 135cbd3eaf..c4fc52f130 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,12 +1,12 @@ module github.com/Wei-Shaw/sub2api -go 1.26.1 +go 1.26.2 require ( entgo.io/ent v0.14.5 github.com/DATA-DOG/go-sqlmock v1.5.2 - github.com/DouDOU-start/go-sora2api v1.1.0 github.com/alitto/pond/v2 v2.6.2 + github.com/andybalholm/brotli v1.2.0 github.com/aws/aws-sdk-go-v2 v1.41.3 github.com/aws/aws-sdk-go-v2/config v1.32.10 github.com/aws/aws-sdk-go-v2/credentials v1.19.10 @@ -50,7 +50,6 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/agext/levenshtein v1.2.3 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect @@ -67,14 +66,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect github.com/aws/smithy-go v1.24.2 // indirect - github.com/bdandy/go-errors v1.2.2 // indirect - github.com/bdandy/go-socks4 v1.2.3 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect - github.com/bogdanfinn/fhttp v0.6.8 // indirect - github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect - github.com/bogdanfinn/tls-client v1.14.0 // indirect - github.com/bogdanfinn/utls v1.7.7-barnius // indirect - github.com/bogdanfinn/websocket v1.5.5-barnius // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/bytedance/sonic v1.9.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -151,7 +143,6 @@ require ( github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect github.com/testcontainers/testcontainers-go v0.40.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index f5b7968fed..996a4b6db7 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -10,8 +10,6 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/DouDOU-start/go-sora2api v1.1.0 h1:PxWiukK77StiHxEngOFwT1rKUn9oTAJJTl07wQUXwiU= -github.com/DouDOU-start/go-sora2api v1.1.0/go.mod h1:dcwpethoKfAsMWskDD9iGgc/3yox2tkthPLSMVGnhkE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= @@ -60,24 +58,10 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb8 github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= -github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= -github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= -github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= -github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= -github.com/bogdanfinn/fhttp v0.6.8 h1:LiQyHOY3i0QoxxNB7nq27/nGNNbtPj0fuBPozhR7Ws4= -github.com/bogdanfinn/fhttp v0.6.8/go.mod h1:A+EKDzMx2hb4IUbMx4TlkoHnaJEiLl8r/1Ss1Y+5e5M= -github.com/bogdanfinn/quic-go-utls v1.0.9-utls h1:tV6eDEiRbRCcepALSzxR94JUVD3N3ACIiRLgyc2Ep8s= -github.com/bogdanfinn/quic-go-utls v1.0.9-utls/go.mod h1:aHph9B9H9yPOt5xnhWKSOum27DJAqpiHzwX+gjvaXcg= -github.com/bogdanfinn/tls-client v1.14.0 h1:vyk7Cn4BIvLAGVuMfb0tP22OqogfO1lYamquQNEZU1A= -github.com/bogdanfinn/tls-client v1.14.0/go.mod h1:LsU6mXVn8MOFDwTkyRfI7V1BZM1p0wf2ZfZsICW/1fM= -github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5ZlrCR7fOLU= -github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg= -github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI= -github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -94,10 +78,6 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -199,8 +179,6 @@ github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI= github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -236,8 +214,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -271,8 +247,6 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -324,8 +298,6 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= @@ -347,8 +319,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc= -github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng= github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk= @@ -421,15 +391,12 @@ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -439,15 +406,12 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 7caa5ca63e..b0b6036c67 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -7,7 +7,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.26.1-alpine +ARG GOLANG_IMAGE=golang:1.26.2-alpine ARG ALPINE_IMAGE=alpine:3.20 ARG GOPROXY=https://goproxy.cn,direct ARG GOSUMDB=sum.golang.google.cn From b982076e5264895328b80b61f9219c4cceba06f2 Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 8 Apr 2026 16:23:02 +0800 Subject: [PATCH 29/59] fix: resolve errcheck lint and add missing enable_cch_signing to test - Suppress errcheck for xxhash Digest.Write (never returns error) - Add enable_cch_signing field to settings API contract test --- backend/internal/server/api_contract_test.go | 1 + backend/internal/service/gateway_billing_header.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index d412ea34d6..24f60f2705 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -536,6 +536,7 @@ func TestAPIContracts(t *testing.T) { "max_claude_code_version": "", "allow_ungrouped_key_scheduling": false, "backend_mode_enabled": false, + "enable_cch_signing": false, "enable_fingerprint_unification": true, "enable_metadata_passthrough": false, "custom_menu_items": [], diff --git a/backend/internal/service/gateway_billing_header.go b/backend/internal/service/gateway_billing_header.go index 2102e53453..91fbfd8fdd 100644 --- a/backend/internal/service/gateway_billing_header.go +++ b/backend/internal/service/gateway_billing_header.go @@ -68,6 +68,6 @@ func signBillingHeaderCCH(body []byte) []byte { // xxHash64Seeded computes xxHash64 of data with a custom seed. func xxHash64Seeded(data []byte, seed uint64) uint64 { d := xxhash.NewWithSeed(seed) - d.Write(data) + _, _ = d.Write(data) return d.Sum64() } From f54e9d0b1c6f9de777c8730ba4adec8a14bdf912 Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 8 Apr 2026 16:37:00 +0800 Subject: [PATCH 30/59] chore: update readme --- README.md | 5 +++++ README_CN.md | 4 ++++ README_JA.md | 5 +++++ assets/partners/logos/silkapi.png | Bin 0 -> 2833 bytes 4 files changed, 14 insertions(+) create mode 100644 assets/partners/logos/silkapi.png diff --git a/README.md b/README.md index 50611a6d0e..2f73e92ae7 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,11 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
+ + + + +
CTok Thanks to CTok.ai for sponsoring this project! CTok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click here to register!
silkapiThanks to SilkAPI for sponsoring this project! SilkAPI is a relay service built on Sub2API, specializing in providing high-speed and stable Codex API relay.
## Ecosystem diff --git a/README_CN.md b/README_CN.md index 797f106b99..a0c3fd4bf3 100644 --- a/README_CN.md +++ b/README_CN.md @@ -69,6 +69,10 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击这里注册! + +silkapi +感谢 丝绸API 赞助了本项目! 丝绸API 是基于 Sub2API 搭建的中转服务,专注于提供 Codex 高速稳定API中转。 + ## 生态项目 diff --git a/README_JA.md b/README_JA.md index b7820554d7..bd69e06bf5 100644 --- a/README_JA.md +++ b/README_JA.md @@ -68,6 +68,11 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを CTok CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code の専用プランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex もサポートしています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシスト プログラミングを真の生産性向上ツールにします。こちらから登録! + + +silkapi +SilkAPI のご支援に感謝します!SilkAPI は Sub2API をベースに構築された中継サービスで、高速かつ安定した Codex API 中継の提供に特化しています。 + ## エコシステム diff --git a/assets/partners/logos/silkapi.png b/assets/partners/logos/silkapi.png new file mode 100644 index 0000000000000000000000000000000000000000..97afbda9267889cecf439f648a49aacc220e7b40 GIT binary patch literal 2833 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 Date: Wed, 8 Apr 2026 08:41:32 +0000 Subject: [PATCH 31/59] chore: sync VERSION to 0.1.110 [skip ci] --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 94e74b8ef3..b6e5c2adfa 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.109 +0.1.110 From 265687b56d876bd8b672c7b3adf70f9676a19719 Mon Sep 17 00:00:00 2001 From: ius Date: Wed, 8 Apr 2026 10:10:15 -0700 Subject: [PATCH 32/59] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E5=BF=AB=E7=85=A7=E7=BC=93=E5=AD=98=E4=BB=A5=E9=81=BF?= =?UTF-8?q?=E5=85=8D=20Redis=20=E5=A4=A7=20MGET?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/wire_gen.go | 2 +- backend/internal/config/config.go | 12 ++ ...eway_handler_warmup_intercept_unit_test.go | 7 +- .../repository/integration_harness_test.go | 4 + .../internal/repository/scheduler_cache.go | 203 ++++++++++++++++-- .../scheduler_cache_integration_test.go | 88 ++++++++ backend/internal/repository/wire.go | 17 +- backend/internal/service/gateway_service.go | 165 +++++++------- .../service/gemini_messages_compat_service.go | 18 +- .../service/openai_gateway_service.go | 113 +++++----- .../scheduler_snapshot_hydration_test.go | 159 ++++++++++++++ deploy/config.example.yaml | 6 + 12 files changed, 631 insertions(+), 163 deletions(-) create mode 100644 backend/internal/repository/scheduler_cache_integration_test.go create mode 100644 backend/internal/service/scheduler_snapshot_hydration_test.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index fdc5c6acdb..aff9a0ffe5 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -100,7 +100,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { } dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, configConfig) dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService) - schedulerCache := repository.NewSchedulerCache(redisClient) + schedulerCache := repository.ProvideSchedulerCache(redisClient, configConfig) accountRepository := repository.NewAccountRepository(client, db, schedulerCache) proxyRepository := repository.NewProxyRepository(client, db) proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 9b43037789..ad023dc10c 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -620,6 +620,10 @@ type GatewaySchedulingConfig struct { // 负载计算 LoadBatchEnabled bool `mapstructure:"load_batch_enabled"` + // 快照桶读取时的 MGET 分块大小 + SnapshotMGetChunkSize int `mapstructure:"snapshot_mget_chunk_size"` + // 快照重建时的缓存写入分块大小 + SnapshotWriteChunkSize int `mapstructure:"snapshot_write_chunk_size"` // 过期槽位清理周期(0 表示禁用) SlotCleanupInterval time.Duration `mapstructure:"slot_cleanup_interval"` @@ -1340,6 +1344,8 @@ func setDefaults() { viper.SetDefault("gateway.scheduling.fallback_max_waiting", 100) viper.SetDefault("gateway.scheduling.fallback_selection_mode", "last_used") viper.SetDefault("gateway.scheduling.load_batch_enabled", true) + viper.SetDefault("gateway.scheduling.snapshot_mget_chunk_size", 128) + viper.SetDefault("gateway.scheduling.snapshot_write_chunk_size", 256) viper.SetDefault("gateway.scheduling.slot_cleanup_interval", 30*time.Second) viper.SetDefault("gateway.scheduling.db_fallback_enabled", true) viper.SetDefault("gateway.scheduling.db_fallback_timeout_seconds", 0) @@ -2001,6 +2007,12 @@ func (c *Config) Validate() error { if c.Gateway.Scheduling.FallbackMaxWaiting <= 0 { return fmt.Errorf("gateway.scheduling.fallback_max_waiting must be positive") } + if c.Gateway.Scheduling.SnapshotMGetChunkSize <= 0 { + return fmt.Errorf("gateway.scheduling.snapshot_mget_chunk_size must be positive") + } + if c.Gateway.Scheduling.SnapshotWriteChunkSize <= 0 { + return fmt.Errorf("gateway.scheduling.snapshot_write_chunk_size must be positive") + } if c.Gateway.Scheduling.SlotCleanupInterval < 0 { return fmt.Errorf("gateway.scheduling.slot_cleanup_interval must be non-negative") } diff --git a/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go b/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go index 4caef9551b..acea37804f 100644 --- a/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go +++ b/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go @@ -34,7 +34,12 @@ func (f *fakeSchedulerCache) GetSnapshot(_ context.Context, _ service.SchedulerB func (f *fakeSchedulerCache) SetSnapshot(_ context.Context, _ service.SchedulerBucket, _ []service.Account) error { return nil } -func (f *fakeSchedulerCache) GetAccount(_ context.Context, _ int64) (*service.Account, error) { +func (f *fakeSchedulerCache) GetAccount(_ context.Context, id int64) (*service.Account, error) { + for _, account := range f.accounts { + if account != nil && account.ID == id { + return account, nil + } + } return nil, nil } func (f *fakeSchedulerCache) SetAccount(_ context.Context, _ *service.Account) error { return nil } diff --git a/backend/internal/repository/integration_harness_test.go b/backend/internal/repository/integration_harness_test.go index fb9c26c4aa..5857fbcb2a 100644 --- a/backend/internal/repository/integration_harness_test.go +++ b/backend/internal/repository/integration_harness_test.go @@ -332,6 +332,10 @@ func (h prefixHook) prefixCmd(cmd redisclient.Cmder) { "hgetall", "hget", "hset", "hdel", "hincrbyfloat", "exists", "zadd", "zcard", "zrange", "zrangebyscore", "zrem", "zremrangebyscore", "zrevrange", "zrevrangebyscore", "zscore": prefixOne(1) + case "mget": + for i := 1; i < len(args); i++ { + prefixOne(i) + } case "del", "unlink": for i := 1; i < len(args); i++ { prefixOne(i) diff --git a/backend/internal/repository/scheduler_cache.go b/backend/internal/repository/scheduler_cache.go index 4f447e4fea..35345a8b77 100644 --- a/backend/internal/repository/scheduler_cache.go +++ b/backend/internal/repository/scheduler_cache.go @@ -15,19 +15,39 @@ const ( schedulerBucketSetKey = "sched:buckets" schedulerOutboxWatermarkKey = "sched:outbox:watermark" schedulerAccountPrefix = "sched:acc:" + schedulerAccountMetaPrefix = "sched:meta:" schedulerActivePrefix = "sched:active:" schedulerReadyPrefix = "sched:ready:" schedulerVersionPrefix = "sched:ver:" schedulerSnapshotPrefix = "sched:" schedulerLockPrefix = "sched:lock:" + + defaultSchedulerSnapshotMGetChunkSize = 128 + defaultSchedulerSnapshotWriteChunkSize = 256 ) type schedulerCache struct { - rdb *redis.Client + rdb *redis.Client + mgetChunkSize int + writeChunkSize int } func NewSchedulerCache(rdb *redis.Client) service.SchedulerCache { - return &schedulerCache{rdb: rdb} + return newSchedulerCacheWithChunkSizes(rdb, defaultSchedulerSnapshotMGetChunkSize, defaultSchedulerSnapshotWriteChunkSize) +} + +func newSchedulerCacheWithChunkSizes(rdb *redis.Client, mgetChunkSize, writeChunkSize int) service.SchedulerCache { + if mgetChunkSize <= 0 { + mgetChunkSize = defaultSchedulerSnapshotMGetChunkSize + } + if writeChunkSize <= 0 { + writeChunkSize = defaultSchedulerSnapshotWriteChunkSize + } + return &schedulerCache{ + rdb: rdb, + mgetChunkSize: mgetChunkSize, + writeChunkSize: writeChunkSize, + } } func (c *schedulerCache) GetSnapshot(ctx context.Context, bucket service.SchedulerBucket) ([]*service.Account, bool, error) { @@ -65,9 +85,9 @@ func (c *schedulerCache) GetSnapshot(ctx context.Context, bucket service.Schedul keys := make([]string, 0, len(ids)) for _, id := range ids { - keys = append(keys, schedulerAccountKey(id)) + keys = append(keys, schedulerAccountMetaKey(id)) } - values, err := c.rdb.MGet(ctx, keys...).Result() + values, err := c.mgetChunked(ctx, keys) if err != nil { return nil, false, err } @@ -100,14 +120,11 @@ func (c *schedulerCache) SetSnapshot(ctx context.Context, bucket service.Schedul versionStr := strconv.FormatInt(version, 10) snapshotKey := schedulerSnapshotKey(bucket, versionStr) - pipe := c.rdb.Pipeline() - for _, account := range accounts { - payload, err := json.Marshal(account) - if err != nil { - return err - } - pipe.Set(ctx, schedulerAccountKey(strconv.FormatInt(account.ID, 10)), payload, 0) + if err := c.writeAccounts(ctx, accounts); err != nil { + return err } + + pipe := c.rdb.Pipeline() if len(accounts) > 0 { // 使用序号作为 score,保持数据库返回的排序语义。 members := make([]redis.Z, 0, len(accounts)) @@ -117,7 +134,13 @@ func (c *schedulerCache) SetSnapshot(ctx context.Context, bucket service.Schedul Member: strconv.FormatInt(account.ID, 10), }) } - pipe.ZAdd(ctx, snapshotKey, members...) + for start := 0; start < len(members); start += c.writeChunkSize { + end := start + c.writeChunkSize + if end > len(members) { + end = len(members) + } + pipe.ZAdd(ctx, snapshotKey, members[start:end]...) + } } else { pipe.Del(ctx, snapshotKey) } @@ -151,20 +174,15 @@ func (c *schedulerCache) SetAccount(ctx context.Context, account *service.Accoun if account == nil || account.ID <= 0 { return nil } - payload, err := json.Marshal(account) - if err != nil { - return err - } - key := schedulerAccountKey(strconv.FormatInt(account.ID, 10)) - return c.rdb.Set(ctx, key, payload, 0).Err() + return c.writeAccounts(ctx, []service.Account{*account}) } func (c *schedulerCache) DeleteAccount(ctx context.Context, accountID int64) error { if accountID <= 0 { return nil } - key := schedulerAccountKey(strconv.FormatInt(accountID, 10)) - return c.rdb.Del(ctx, key).Err() + id := strconv.FormatInt(accountID, 10) + return c.rdb.Del(ctx, schedulerAccountKey(id), schedulerAccountMetaKey(id)).Err() } func (c *schedulerCache) UpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error { @@ -179,7 +197,7 @@ func (c *schedulerCache) UpdateLastUsed(ctx context.Context, updates map[int64]t ids = append(ids, id) } - values, err := c.rdb.MGet(ctx, keys...).Result() + values, err := c.mgetChunked(ctx, keys) if err != nil { return err } @@ -198,7 +216,12 @@ func (c *schedulerCache) UpdateLastUsed(ctx context.Context, updates map[int64]t if err != nil { return err } + metaPayload, err := json.Marshal(buildSchedulerMetadataAccount(*account)) + if err != nil { + return err + } pipe.Set(ctx, keys[i], updated, 0) + pipe.Set(ctx, schedulerAccountMetaKey(strconv.FormatInt(ids[i], 10)), metaPayload, 0) } _, err = pipe.Exec(ctx) return err @@ -256,6 +279,10 @@ func schedulerAccountKey(id string) string { return schedulerAccountPrefix + id } +func schedulerAccountMetaKey(id string) string { + return schedulerAccountMetaPrefix + id +} + func ptrTime(t time.Time) *time.Time { return &t } @@ -276,3 +303,137 @@ func decodeCachedAccount(val any) (*service.Account, error) { } return &account, nil } + +func (c *schedulerCache) writeAccounts(ctx context.Context, accounts []service.Account) error { + if len(accounts) == 0 { + return nil + } + + pipe := c.rdb.Pipeline() + pending := 0 + flush := func() error { + if pending == 0 { + return nil + } + if _, err := pipe.Exec(ctx); err != nil { + return err + } + pipe = c.rdb.Pipeline() + pending = 0 + return nil + } + + for _, account := range accounts { + fullPayload, err := json.Marshal(account) + if err != nil { + return err + } + metaPayload, err := json.Marshal(buildSchedulerMetadataAccount(account)) + if err != nil { + return err + } + + id := strconv.FormatInt(account.ID, 10) + pipe.Set(ctx, schedulerAccountKey(id), fullPayload, 0) + pipe.Set(ctx, schedulerAccountMetaKey(id), metaPayload, 0) + pending++ + if pending >= c.writeChunkSize { + if err := flush(); err != nil { + return err + } + } + } + + return flush() +} + +func (c *schedulerCache) mgetChunked(ctx context.Context, keys []string) ([]any, error) { + if len(keys) == 0 { + return []any{}, nil + } + + out := make([]any, 0, len(keys)) + chunkSize := c.mgetChunkSize + if chunkSize <= 0 { + chunkSize = defaultSchedulerSnapshotMGetChunkSize + } + for start := 0; start < len(keys); start += chunkSize { + end := start + chunkSize + if end > len(keys) { + end = len(keys) + } + part, err := c.rdb.MGet(ctx, keys[start:end]...).Result() + if err != nil { + return nil, err + } + out = append(out, part...) + } + return out, nil +} + +func buildSchedulerMetadataAccount(account service.Account) service.Account { + return service.Account{ + ID: account.ID, + Name: account.Name, + Platform: account.Platform, + Type: account.Type, + Concurrency: account.Concurrency, + Priority: account.Priority, + RateMultiplier: account.RateMultiplier, + Status: account.Status, + LastUsedAt: account.LastUsedAt, + ExpiresAt: account.ExpiresAt, + AutoPauseOnExpired: account.AutoPauseOnExpired, + Schedulable: account.Schedulable, + RateLimitedAt: account.RateLimitedAt, + RateLimitResetAt: account.RateLimitResetAt, + OverloadUntil: account.OverloadUntil, + TempUnschedulableUntil: account.TempUnschedulableUntil, + TempUnschedulableReason: account.TempUnschedulableReason, + SessionWindowStart: account.SessionWindowStart, + SessionWindowEnd: account.SessionWindowEnd, + SessionWindowStatus: account.SessionWindowStatus, + Credentials: filterSchedulerCredentials(account.Credentials), + Extra: filterSchedulerExtra(account.Extra), + } +} + +func filterSchedulerCredentials(credentials map[string]any) map[string]any { + if len(credentials) == 0 { + return nil + } + keys := []string{"model_mapping", "api_key", "project_id", "oauth_type"} + filtered := make(map[string]any) + for _, key := range keys { + if value, ok := credentials[key]; ok && value != nil { + filtered[key] = value + } + } + if len(filtered) == 0 { + return nil + } + return filtered +} + +func filterSchedulerExtra(extra map[string]any) map[string]any { + if len(extra) == 0 { + return nil + } + keys := []string{ + "mixed_scheduling", + "window_cost_limit", + "window_cost_sticky_reserve", + "max_sessions", + "session_idle_timeout_minutes", + } + filtered := make(map[string]any) + for _, key := range keys { + if value, ok := extra[key]; ok && value != nil { + filtered[key] = value + } + } + if len(filtered) == 0 { + return nil + } + return filtered +} diff --git a/backend/internal/repository/scheduler_cache_integration_test.go b/backend/internal/repository/scheduler_cache_integration_test.go new file mode 100644 index 0000000000..134a6a0753 --- /dev/null +++ b/backend/internal/repository/scheduler_cache_integration_test.go @@ -0,0 +1,88 @@ +//go:build integration + +package repository + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestSchedulerCacheSnapshotUsesSlimMetadataButKeepsFullAccount(t *testing.T) { + ctx := context.Background() + rdb := testRedis(t) + cache := NewSchedulerCache(rdb) + + bucket := service.SchedulerBucket{GroupID: 2, Platform: service.PlatformGemini, Mode: service.SchedulerModeSingle} + now := time.Now().UTC().Truncate(time.Second) + limitReset := now.Add(10 * time.Minute) + overloadUntil := now.Add(2 * time.Minute) + tempUnschedUntil := now.Add(3 * time.Minute) + windowEnd := now.Add(5 * time.Hour) + + account := service.Account{ + ID: 101, + Name: "gemini-heavy", + Platform: service.PlatformGemini, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 3, + Priority: 7, + LastUsedAt: &now, + Credentials: map[string]any{ + "api_key": "gemini-api-key", + "access_token": "secret-access-token", + "project_id": "proj-1", + "oauth_type": "ai_studio", + "model_mapping": map[string]any{"gemini-2.5-pro": "gemini-2.5-pro"}, + "huge_blob": strings.Repeat("x", 4096), + }, + Extra: map[string]any{ + "mixed_scheduling": true, + "window_cost_limit": 12.5, + "window_cost_sticky_reserve": 8.0, + "max_sessions": 4, + "session_idle_timeout_minutes": 11, + "unused_large_field": strings.Repeat("y", 4096), + }, + RateLimitResetAt: &limitReset, + OverloadUntil: &overloadUntil, + TempUnschedulableUntil: &tempUnschedUntil, + SessionWindowStart: &now, + SessionWindowEnd: &windowEnd, + SessionWindowStatus: "active", + } + + require.NoError(t, cache.SetSnapshot(ctx, bucket, []service.Account{account})) + + snapshot, hit, err := cache.GetSnapshot(ctx, bucket) + require.NoError(t, err) + require.True(t, hit) + require.Len(t, snapshot, 1) + + got := snapshot[0] + require.NotNil(t, got) + require.Equal(t, "gemini-api-key", got.GetCredential("api_key")) + require.Equal(t, "proj-1", got.GetCredential("project_id")) + require.Equal(t, "ai_studio", got.GetCredential("oauth_type")) + require.NotEmpty(t, got.GetModelMapping()) + require.Empty(t, got.GetCredential("access_token")) + require.Empty(t, got.GetCredential("huge_blob")) + require.Equal(t, true, got.Extra["mixed_scheduling"]) + require.Equal(t, 12.5, got.GetWindowCostLimit()) + require.Equal(t, 8.0, got.GetWindowCostStickyReserve()) + require.Equal(t, 4, got.GetMaxSessions()) + require.Equal(t, 11, got.GetSessionIdleTimeoutMinutes()) + require.Nil(t, got.Extra["unused_large_field"]) + + full, err := cache.GetAccount(ctx, account.ID) + require.NoError(t, err) + require.NotNil(t, full) + require.Equal(t, "secret-access-token", full.GetCredential("access_token")) + require.Equal(t, strings.Repeat("x", 4096), full.GetCredential("huge_blob")) +} diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 657e3ed66c..d3adb4a0aa 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -47,6 +47,21 @@ func ProvideSessionLimitCache(rdb *redis.Client, cfg *config.Config) service.Ses return NewSessionLimitCache(rdb, defaultIdleTimeoutMinutes) } +// ProvideSchedulerCache 创建调度快照缓存,并注入快照分块参数。 +func ProvideSchedulerCache(rdb *redis.Client, cfg *config.Config) service.SchedulerCache { + mgetChunkSize := defaultSchedulerSnapshotMGetChunkSize + writeChunkSize := defaultSchedulerSnapshotWriteChunkSize + if cfg != nil { + if cfg.Gateway.Scheduling.SnapshotMGetChunkSize > 0 { + mgetChunkSize = cfg.Gateway.Scheduling.SnapshotMGetChunkSize + } + if cfg.Gateway.Scheduling.SnapshotWriteChunkSize > 0 { + writeChunkSize = cfg.Gateway.Scheduling.SnapshotWriteChunkSize + } + } + return newSchedulerCacheWithChunkSizes(rdb, mgetChunkSize, writeChunkSize) +} + // ProviderSet is the Wire provider set for all repositories var ProviderSet = wire.NewSet( NewUserRepository, @@ -92,7 +107,7 @@ var ProviderSet = wire.NewSet( NewRedeemCache, NewUpdateCache, NewGeminiTokenCache, - NewSchedulerCache, + ProvideSchedulerCache, NewSchedulerOutboxRepository, NewProxyLatencyCache, NewTotpCache, diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index a473364916..8b0bdc2a58 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1192,12 +1192,20 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context // anthropic/gemini 分组支持混合调度(包含启用了 mixed_scheduling 的 antigravity 账户) // 注意:强制平台模式不走混合调度 if (platform == PlatformAnthropic || platform == PlatformGemini) && !hasForcePlatform { - return s.selectAccountWithMixedScheduling(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform) + account, err := s.selectAccountWithMixedScheduling(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform) + if err != nil { + return nil, err + } + return s.hydrateSelectedAccount(ctx, account) } // antigravity 分组、强制平台模式或无分组使用单平台选择 // 注意:强制平台模式也必须遵守分组限制,不再回退到全平台查询 - return s.selectAccountForModelWithPlatform(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform) + account, err := s.selectAccountForModelWithPlatform(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform) + if err != nil { + return nil, err + } + return s.hydrateSelectedAccount(ctx, account) } // SelectAccountWithLoadAwareness selects account with load-awareness and wait plan. @@ -1273,11 +1281,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro localExcluded[account.ID] = struct{}{} // 排除此账号 continue // 重新选择 } - return &AccountSelectionResult{ - Account: account, - Acquired: true, - ReleaseFunc: result.ReleaseFunc, - }, nil + return s.newSelectionResult(ctx, account, true, result.ReleaseFunc, nil) } // 对于等待计划的情况,也需要先检查会话限制 @@ -1289,26 +1293,20 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if stickyAccountID > 0 && stickyAccountID == account.ID && s.concurrencyService != nil { waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, account.ID) if waitingCount < cfg.StickySessionMaxWaiting { - return &AccountSelectionResult{ - Account: account, - WaitPlan: &AccountWaitPlan{ - AccountID: account.ID, - MaxConcurrency: account.Concurrency, - Timeout: cfg.StickySessionWaitTimeout, - MaxWaiting: cfg.StickySessionMaxWaiting, - }, - }, nil + return s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{ + AccountID: account.ID, + MaxConcurrency: account.Concurrency, + Timeout: cfg.StickySessionWaitTimeout, + MaxWaiting: cfg.StickySessionMaxWaiting, + }) } } - return &AccountSelectionResult{ - Account: account, - WaitPlan: &AccountWaitPlan{ - AccountID: account.ID, - MaxConcurrency: account.Concurrency, - Timeout: cfg.FallbackWaitTimeout, - MaxWaiting: cfg.FallbackMaxWaiting, - }, - }, nil + return s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{ + AccountID: account.ID, + MaxConcurrency: account.Concurrency, + Timeout: cfg.FallbackWaitTimeout, + MaxWaiting: cfg.FallbackMaxWaiting, + }) } } @@ -1455,11 +1453,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if s.debugModelRoutingEnabled() { logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] routed sticky hit: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), stickyAccountID) } - return &AccountSelectionResult{ - Account: stickyAccount, - Acquired: true, - ReleaseFunc: result.ReleaseFunc, - }, nil + return s.newSelectionResult(ctx, stickyAccount, true, result.ReleaseFunc, nil) } } @@ -1570,11 +1564,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if s.debugModelRoutingEnabled() { logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] routed select: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), item.account.ID) } - return &AccountSelectionResult{ - Account: item.account, - Acquired: true, - ReleaseFunc: result.ReleaseFunc, - }, nil + return s.newSelectionResult(ctx, item.account, true, result.ReleaseFunc, nil) } } @@ -1587,15 +1577,12 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if s.debugModelRoutingEnabled() { logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] routed wait: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), item.account.ID) } - return &AccountSelectionResult{ - Account: item.account, - WaitPlan: &AccountWaitPlan{ - AccountID: item.account.ID, - MaxConcurrency: item.account.Concurrency, - Timeout: cfg.StickySessionWaitTimeout, - MaxWaiting: cfg.StickySessionMaxWaiting, - }, - }, nil + return s.newSelectionResult(ctx, item.account, false, nil, &AccountWaitPlan{ + AccountID: item.account.ID, + MaxConcurrency: item.account.Concurrency, + Timeout: cfg.StickySessionWaitTimeout, + MaxWaiting: cfg.StickySessionMaxWaiting, + }) } // 所有路由账号会话限制都已满,继续到 Layer 2 回退 } @@ -1631,11 +1618,10 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if !s.checkAndRegisterSession(ctx, account, sessionHash) { result.ReleaseFunc() // 释放槽位,继续到 Layer 2 } else { - return &AccountSelectionResult{ - Account: account, - Acquired: true, - ReleaseFunc: result.ReleaseFunc, - }, nil + if s.cache != nil { + _ = s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL) + } + return s.newSelectionResult(ctx, account, true, result.ReleaseFunc, nil) } } @@ -1647,15 +1633,12 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro // 会话限制已满,继续到 Layer 2 // Session limit full, continue to Layer 2 } else { - return &AccountSelectionResult{ - Account: account, - WaitPlan: &AccountWaitPlan{ - AccountID: accountID, - MaxConcurrency: account.Concurrency, - Timeout: cfg.StickySessionWaitTimeout, - MaxWaiting: cfg.StickySessionMaxWaiting, - }, - }, nil + return s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{ + AccountID: accountID, + MaxConcurrency: account.Concurrency, + Timeout: cfg.StickySessionWaitTimeout, + MaxWaiting: cfg.StickySessionMaxWaiting, + }) } } } @@ -1714,7 +1697,9 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro loadMap, err := s.concurrencyService.GetAccountsLoadBatch(ctx, accountLoads) if err != nil { - if result, ok := s.tryAcquireByLegacyOrder(ctx, candidates, groupID, sessionHash, preferOAuth); ok { + if result, ok, legacyErr := s.tryAcquireByLegacyOrder(ctx, candidates, groupID, sessionHash, preferOAuth); legacyErr != nil { + return nil, legacyErr + } else if ok { return result, nil } } else { @@ -1753,11 +1738,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if sessionHash != "" && s.cache != nil { _ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.account.ID, stickySessionTTL) } - return &AccountSelectionResult{ - Account: selected.account, - Acquired: true, - ReleaseFunc: result.ReleaseFunc, - }, nil + return s.newSelectionResult(ctx, selected.account, true, result.ReleaseFunc, nil) } } @@ -1780,20 +1761,17 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if !s.checkAndRegisterSession(ctx, acc, sessionHash) { continue // 会话限制已满,尝试下一个账号 } - return &AccountSelectionResult{ - Account: acc, - WaitPlan: &AccountWaitPlan{ - AccountID: acc.ID, - MaxConcurrency: acc.Concurrency, - Timeout: cfg.FallbackWaitTimeout, - MaxWaiting: cfg.FallbackMaxWaiting, - }, - }, nil + return s.newSelectionResult(ctx, acc, false, nil, &AccountWaitPlan{ + AccountID: acc.ID, + MaxConcurrency: acc.Concurrency, + Timeout: cfg.FallbackWaitTimeout, + MaxWaiting: cfg.FallbackMaxWaiting, + }) } return nil, ErrNoAvailableAccounts } -func (s *GatewayService) tryAcquireByLegacyOrder(ctx context.Context, candidates []*Account, groupID *int64, sessionHash string, preferOAuth bool) (*AccountSelectionResult, bool) { +func (s *GatewayService) tryAcquireByLegacyOrder(ctx context.Context, candidates []*Account, groupID *int64, sessionHash string, preferOAuth bool) (*AccountSelectionResult, bool, error) { ordered := append([]*Account(nil), candidates...) sortAccountsByPriorityAndLastUsed(ordered, preferOAuth) @@ -1808,15 +1786,15 @@ func (s *GatewayService) tryAcquireByLegacyOrder(ctx context.Context, candidates if sessionHash != "" && s.cache != nil { _ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, acc.ID, stickySessionTTL) } - return &AccountSelectionResult{ - Account: acc, - Acquired: true, - ReleaseFunc: result.ReleaseFunc, - }, true + selection, err := s.newSelectionResult(ctx, acc, true, result.ReleaseFunc, nil) + if err != nil { + return nil, false, err + } + return selection, true, nil } } - return nil, false + return nil, false, nil } func (s *GatewayService) schedulingConfig() config.GatewaySchedulingConfig { @@ -2431,6 +2409,33 @@ func (s *GatewayService) getSchedulableAccount(ctx context.Context, accountID in return s.accountRepo.GetByID(ctx, accountID) } +func (s *GatewayService) hydrateSelectedAccount(ctx context.Context, account *Account) (*Account, error) { + if account == nil || s.schedulerSnapshot == nil { + return account, nil + } + hydrated, err := s.schedulerSnapshot.GetAccount(ctx, account.ID) + if err != nil { + return nil, err + } + if hydrated == nil { + return nil, fmt.Errorf("selected gateway account %d not found during hydration", account.ID) + } + return hydrated, nil +} + +func (s *GatewayService) newSelectionResult(ctx context.Context, account *Account, acquired bool, release func(), waitPlan *AccountWaitPlan) (*AccountSelectionResult, error) { + hydrated, err := s.hydrateSelectedAccount(ctx, account) + if err != nil { + return nil, err + } + return &AccountSelectionResult{ + Account: hydrated, + Acquired: acquired, + ReleaseFunc: release, + WaitPlan: waitPlan, + }, nil +} + // filterByMinPriority 过滤出优先级最小的账号集合 func filterByMinPriority(accounts []accountWithLoad) []accountWithLoad { if len(accounts) == 0 { diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 32bf21c01f..5a9490f36d 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -137,7 +137,7 @@ func (s *GeminiMessagesCompatService) SelectAccountForModelWithExclusions(ctx co _ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), cacheKey, selected.ID, geminiStickySessionTTL) } - return selected, nil + return s.hydrateSelectedAccount(ctx, selected) } // resolvePlatformAndSchedulingMode 解析目标平台和调度模式。 @@ -416,6 +416,20 @@ func (s *GeminiMessagesCompatService) getSchedulableAccount(ctx context.Context, return s.accountRepo.GetByID(ctx, accountID) } +func (s *GeminiMessagesCompatService) hydrateSelectedAccount(ctx context.Context, account *Account) (*Account, error) { + if account == nil || s.schedulerSnapshot == nil { + return account, nil + } + hydrated, err := s.schedulerSnapshot.GetAccount(ctx, account.ID) + if err != nil { + return nil, err + } + if hydrated == nil { + return nil, fmt.Errorf("selected gemini account %d not found during hydration", account.ID) + } + return hydrated, nil +} + func (s *GeminiMessagesCompatService) listSchedulableAccountsOnce(ctx context.Context, groupID *int64, platform string, hasForcePlatform bool) ([]Account, error) { if s.schedulerSnapshot != nil { accounts, _, err := s.schedulerSnapshot.ListSchedulableAccounts(ctx, groupID, platform, hasForcePlatform) @@ -546,7 +560,7 @@ func (s *GeminiMessagesCompatService) SelectAccountForAIStudioEndpoints(ctx cont if selected == nil { return nil, errors.New("no available Gemini accounts") } - return selected, nil + return s.hydrateSelectedAccount(ctx, selected) } func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) { diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 2623d77377..dbc5386920 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -1243,7 +1243,7 @@ func (s *OpenAIGatewayService) selectAccountForModelWithExclusions(ctx context.C _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, selected.ID, openaiStickySessionTTL) } - return selected, nil + return s.hydrateSelectedAccount(ctx, selected) } // tryStickySessionHit 尝试从粘性会话获取账号。 @@ -1408,35 +1408,25 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex } result, err := s.tryAcquireAccountSlot(ctx, account.ID, account.Concurrency) if err == nil && result.Acquired { - return &AccountSelectionResult{ - Account: account, - Acquired: true, - ReleaseFunc: result.ReleaseFunc, - }, nil + return s.newSelectionResult(ctx, account, true, result.ReleaseFunc, nil) } if stickyAccountID > 0 && stickyAccountID == account.ID && s.concurrencyService != nil { waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, account.ID) if waitingCount < cfg.StickySessionMaxWaiting { - return &AccountSelectionResult{ - Account: account, - WaitPlan: &AccountWaitPlan{ - AccountID: account.ID, - MaxConcurrency: account.Concurrency, - Timeout: cfg.StickySessionWaitTimeout, - MaxWaiting: cfg.StickySessionMaxWaiting, - }, - }, nil + return s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{ + AccountID: account.ID, + MaxConcurrency: account.Concurrency, + Timeout: cfg.StickySessionWaitTimeout, + MaxWaiting: cfg.StickySessionMaxWaiting, + }) } } - return &AccountSelectionResult{ - Account: account, - WaitPlan: &AccountWaitPlan{ - AccountID: account.ID, - MaxConcurrency: account.Concurrency, - Timeout: cfg.FallbackWaitTimeout, - MaxWaiting: cfg.FallbackMaxWaiting, - }, - }, nil + return s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{ + AccountID: account.ID, + MaxConcurrency: account.Concurrency, + Timeout: cfg.FallbackWaitTimeout, + MaxWaiting: cfg.FallbackMaxWaiting, + }) } accounts, err := s.listSchedulableAccounts(ctx, groupID) @@ -1476,24 +1466,17 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency) if err == nil && result.Acquired { _ = s.refreshStickySessionTTL(ctx, groupID, sessionHash, openaiStickySessionTTL) - return &AccountSelectionResult{ - Account: account, - Acquired: true, - ReleaseFunc: result.ReleaseFunc, - }, nil + return s.newSelectionResult(ctx, account, true, result.ReleaseFunc, nil) } waitingCount, _ := s.concurrencyService.GetAccountWaitingCount(ctx, accountID) if waitingCount < cfg.StickySessionMaxWaiting { - return &AccountSelectionResult{ - Account: account, - WaitPlan: &AccountWaitPlan{ - AccountID: accountID, - MaxConcurrency: account.Concurrency, - Timeout: cfg.StickySessionWaitTimeout, - MaxWaiting: cfg.StickySessionMaxWaiting, - }, - }, nil + return s.newSelectionResult(ctx, account, false, nil, &AccountWaitPlan{ + AccountID: accountID, + MaxConcurrency: account.Concurrency, + Timeout: cfg.StickySessionWaitTimeout, + MaxWaiting: cfg.StickySessionMaxWaiting, + }) } } } @@ -1552,11 +1535,7 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex if sessionHash != "" { _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, openaiStickySessionTTL) } - return &AccountSelectionResult{ - Account: fresh, - Acquired: true, - ReleaseFunc: result.ReleaseFunc, - }, nil + return s.newSelectionResult(ctx, fresh, true, result.ReleaseFunc, nil) } } } else { @@ -1609,11 +1588,7 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex if sessionHash != "" { _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, openaiStickySessionTTL) } - return &AccountSelectionResult{ - Account: fresh, - Acquired: true, - ReleaseFunc: result.ReleaseFunc, - }, nil + return s.newSelectionResult(ctx, fresh, true, result.ReleaseFunc, nil) } } } @@ -1629,15 +1604,12 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, fresh, requestedModel) { continue } - return &AccountSelectionResult{ - Account: fresh, - WaitPlan: &AccountWaitPlan{ - AccountID: fresh.ID, - MaxConcurrency: fresh.Concurrency, - Timeout: cfg.FallbackWaitTimeout, - MaxWaiting: cfg.FallbackMaxWaiting, - }, - }, nil + return s.newSelectionResult(ctx, fresh, false, nil, &AccountWaitPlan{ + AccountID: fresh.ID, + MaxConcurrency: fresh.Concurrency, + Timeout: cfg.FallbackWaitTimeout, + MaxWaiting: cfg.FallbackMaxWaiting, + }) } return nil, ErrNoAvailableAccounts @@ -1732,6 +1704,33 @@ func (s *OpenAIGatewayService) getSchedulableAccount(ctx context.Context, accoun return account, nil } +func (s *OpenAIGatewayService) hydrateSelectedAccount(ctx context.Context, account *Account) (*Account, error) { + if account == nil || s.schedulerSnapshot == nil { + return account, nil + } + hydrated, err := s.schedulerSnapshot.GetAccount(ctx, account.ID) + if err != nil { + return nil, err + } + if hydrated == nil { + return nil, fmt.Errorf("selected openai account %d not found during hydration", account.ID) + } + return hydrated, nil +} + +func (s *OpenAIGatewayService) newSelectionResult(ctx context.Context, account *Account, acquired bool, release func(), waitPlan *AccountWaitPlan) (*AccountSelectionResult, error) { + hydrated, err := s.hydrateSelectedAccount(ctx, account) + if err != nil { + return nil, err + } + return &AccountSelectionResult{ + Account: hydrated, + Acquired: acquired, + ReleaseFunc: release, + WaitPlan: waitPlan, + }, nil +} + func (s *OpenAIGatewayService) schedulingConfig() config.GatewaySchedulingConfig { if s.cfg != nil { return s.cfg.Gateway.Scheduling diff --git a/backend/internal/service/scheduler_snapshot_hydration_test.go b/backend/internal/service/scheduler_snapshot_hydration_test.go new file mode 100644 index 0000000000..5c0b289b7a --- /dev/null +++ b/backend/internal/service/scheduler_snapshot_hydration_test.go @@ -0,0 +1,159 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" +) + +type snapshotHydrationCache struct { + snapshot []*Account + accounts map[int64]*Account +} + +func (c *snapshotHydrationCache) GetSnapshot(ctx context.Context, bucket SchedulerBucket) ([]*Account, bool, error) { + return c.snapshot, true, nil +} + +func (c *snapshotHydrationCache) SetSnapshot(ctx context.Context, bucket SchedulerBucket, accounts []Account) error { + return nil +} + +func (c *snapshotHydrationCache) GetAccount(ctx context.Context, accountID int64) (*Account, error) { + if c.accounts == nil { + return nil, nil + } + return c.accounts[accountID], nil +} + +func (c *snapshotHydrationCache) SetAccount(ctx context.Context, account *Account) error { + return nil +} + +func (c *snapshotHydrationCache) DeleteAccount(ctx context.Context, accountID int64) error { + return nil +} + +func (c *snapshotHydrationCache) UpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error { + return nil +} + +func (c *snapshotHydrationCache) TryLockBucket(ctx context.Context, bucket SchedulerBucket, ttl time.Duration) (bool, error) { + return true, nil +} + +func (c *snapshotHydrationCache) ListBuckets(ctx context.Context) ([]SchedulerBucket, error) { + return nil, nil +} + +func (c *snapshotHydrationCache) GetOutboxWatermark(ctx context.Context) (int64, error) { + return 0, nil +} + +func (c *snapshotHydrationCache) SetOutboxWatermark(ctx context.Context, id int64) error { + return nil +} + +func TestOpenAISelectAccountWithLoadAwareness_HydratesSelectedAccountFromSchedulerSnapshot(t *testing.T) { + cache := &snapshotHydrationCache{ + snapshot: []*Account{ + { + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 1, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "gpt-4": "gpt-4", + }, + }, + }, + }, + accounts: map[int64]*Account{ + 1: { + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 1, + Credentials: map[string]any{ + "api_key": "sk-live", + "model_mapping": map[string]any{"gpt-4": "gpt-4"}, + }, + }, + }, + } + + schedulerSnapshot := NewSchedulerSnapshotService(cache, nil, nil, nil, nil) + groupID := int64(2) + svc := &OpenAIGatewayService{ + schedulerSnapshot: schedulerSnapshot, + cache: &stubGatewayCache{}, + } + + selection, err := svc.SelectAccountWithLoadAwareness(context.Background(), &groupID, "", "gpt-4", nil) + if err != nil { + t.Fatalf("SelectAccountWithLoadAwareness error: %v", err) + } + if selection == nil || selection.Account == nil { + t.Fatalf("expected selected account") + } + if got := selection.Account.GetOpenAIApiKey(); got != "sk-live" { + t.Fatalf("expected hydrated api key, got %q", got) + } +} + +func TestGatewaySelectAccountWithLoadAwareness_HydratesSelectedAccountFromSchedulerSnapshot(t *testing.T) { + cache := &snapshotHydrationCache{ + snapshot: []*Account{ + { + ID: 9, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 1, + }, + }, + accounts: map[int64]*Account{ + 9: { + ID: 9, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 1, + Credentials: map[string]any{ + "api_key": "anthropic-live-key", + }, + }, + }, + } + + schedulerSnapshot := NewSchedulerSnapshotService(cache, nil, nil, nil, nil) + svc := &GatewayService{ + schedulerSnapshot: schedulerSnapshot, + cache: &mockGatewayCacheForPlatform{}, + cfg: testConfig(), + } + + result, err := svc.SelectAccountWithLoadAwareness(context.Background(), nil, "", "claude-3-5-sonnet-20241022", nil, "", 0) + if err != nil { + t.Fatalf("SelectAccountWithLoadAwareness error: %v", err) + } + if result == nil || result.Account == nil { + t.Fatalf("expected selected account") + } + if got := result.Account.GetCredential("api_key"); got != "anthropic-live-key" { + t.Fatalf("expected hydrated api key, got %q", got) + } +} diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 8f60acd5ed..45440761db 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -347,6 +347,12 @@ gateway: # Enable batch load calculation for scheduling # 启用调度批量负载计算 load_batch_enabled: true + # Snapshot bucket MGET chunk size + # 调度快照分桶读取时的 MGET 分块大小 + snapshot_mget_chunk_size: 128 + # Snapshot bucket write chunk size + # 调度快照重建写入时的分块大小 + snapshot_write_chunk_size: 256 # Slot cleanup interval (duration) # 并发槽位清理周期(时间段) slot_cleanup_interval: 30s From 155d3474d6b82bc0fb0d83abb574740efb6fcbd2 Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 9 Apr 2026 09:21:37 +0800 Subject: [PATCH 33/59] chore: update Sponsors --- README.md | 6 ++++++ README_CN.md | 6 ++++++ README_JA.md | 6 ++++++ assets/partners/logos/ylscode.png | Bin 0 -> 26820 bytes 4 files changed, 18 insertions(+) create mode 100644 assets/partners/logos/ylscode.png diff --git a/README.md b/README.md index 2f73e92ae7..25bef4730b 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,12 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot silkapi Thanks to SilkAPI for sponsoring this project! SilkAPI is a relay service built on Sub2API, specializing in providing high-speed and stable Codex API relay. + + +ylscode +Thanks to YLS Code for sponsoring this project! YLS Code is dedicated to building secure enterprise-grade Coding Agent productivity services, offering stable and fast Codex / Claude / Gemini subscription services along with pay-as-you-go API options for flexible choices. Register now for a limited-time 3-day Codex trial bonus! + + ## Ecosystem diff --git a/README_CN.md b/README_CN.md index a0c3fd4bf3..003a9530cb 100644 --- a/README_CN.md +++ b/README_CN.md @@ -73,6 +73,12 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 silkapi 感谢 丝绸API 赞助了本项目! 丝绸API 是基于 Sub2API 搭建的中转服务,专注于提供 Codex 高速稳定API中转。 + + +silkapi +感谢 伊莉思Code 赞助了本项目! 伊莉思Code 致力于构建安全的企业级Coding Agent生产力服务,提供稳定快速的 Codex / Claude / Gemini 订阅服务与即用即付API多种方案灵活选择,限时注册赠送 3 天 Codex 试用福利! + + ## 生态项目 diff --git a/README_JA.md b/README_JA.md index bd69e06bf5..818e944b68 100644 --- a/README_JA.md +++ b/README_JA.md @@ -73,6 +73,12 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを silkapi SilkAPI のご支援に感謝します!SilkAPI は Sub2API をベースに構築された中継サービスで、高速かつ安定した Codex API 中継の提供に特化しています。 + + +ylscode +YLS Code のご支援に感謝します!YLS Code は安全なエンタープライズグレードの Coding Agent 生産性サービスの構築に取り組んでおり、安定かつ高速な Codex / Claude / Gemini サブスクリプションサービスと従量課金 API の柔軟なプランを提供しています。期間限定で新規登録者に 3 日間の Codex 試用特典をプレゼント中! + + ## エコシステム diff --git a/assets/partners/logos/ylscode.png b/assets/partners/logos/ylscode.png new file mode 100644 index 0000000000000000000000000000000000000000..4d374f04c2ffea503d71e0022b2937000d206126 GIT binary patch literal 26820 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 XzFW3 Date: Thu, 9 Apr 2026 09:47:27 +0800 Subject: [PATCH 34/59] fix: include home_content URL in CSP frame-src origins (fixes #1519) --- backend/internal/service/setting_service.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 7d0ef5bdd9..5c90317d64 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -333,8 +333,8 @@ func safeRawJSONArray(raw string) json.RawMessage { return json.RawMessage("[]") } -// GetFrameSrcOrigins returns deduplicated http(s) origins from purchase_subscription_url -// and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection. +// GetFrameSrcOrigins returns deduplicated http(s) origins from home_content URL, +// purchase_subscription_url, and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection. func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, error) { settings, err := s.GetPublicSettings(ctx) if err != nil { @@ -353,6 +353,9 @@ func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, erro } } + // home content URL (when home_content is set to a URL for iframe embedding) + addOrigin(settings.HomeContent) + // purchase subscription URL if settings.PurchaseSubscriptionEnabled { addOrigin(settings.PurchaseSubscriptionURL) From 02a66a01c3da45d29c37080abb4b87cfbd40bd25 Mon Sep 17 00:00:00 2001 From: ruiqurm Date: Fri, 13 Mar 2026 23:38:58 +0800 Subject: [PATCH 35/59] feat: support OIDC login. --- backend/internal/config/config.go | 160 ++++ backend/internal/config/config_test.go | 54 ++ .../internal/handler/admin/setting_handler.go | 281 ++++++ backend/internal/handler/auth_oidc_oauth.go | 865 ++++++++++++++++++ .../internal/handler/auth_oidc_oauth_test.go | 106 +++ backend/internal/handler/dto/settings.go | 26 + backend/internal/handler/setting_handler.go | 3 + backend/internal/server/api_contract_test.go | 46 +- backend/internal/server/routes/auth.go | 8 + backend/internal/service/auth_service.go | 3 +- backend/internal/service/domain_constants.go | 27 + backend/internal/service/setting_service.go | 458 ++++++++++ .../setting_service_oidc_config_test.go | 103 +++ backend/internal/service/settings_view.go | 33 +- deploy/config.example.yaml | 40 + frontend/src/api/admin/settings.ts | 46 + frontend/src/api/auth.ts | 25 +- .../components/auth/LinuxDoOAuthSection.vue | 12 +- .../src/components/auth/OidcOAuthSection.vue | 53 ++ frontend/src/i18n/locales/en.ts | 66 ++ frontend/src/i18n/locales/zh.ts | 65 ++ frontend/src/router/index.ts | 9 + frontend/src/stores/app.ts | 3 + frontend/src/types/index.ts | 3 + frontend/src/views/admin/SettingsView.vue | 386 +++++++- frontend/src/views/auth/LoginView.vue | 28 +- frontend/src/views/auth/OidcCallbackView.vue | 234 +++++ frontend/src/views/auth/RegisterView.vue | 27 +- 28 files changed, 3154 insertions(+), 16 deletions(-) create mode 100644 backend/internal/handler/auth_oidc_oauth.go create mode 100644 backend/internal/handler/auth_oidc_oauth_test.go create mode 100644 backend/internal/service/setting_service_oidc_config_test.go create mode 100644 frontend/src/components/auth/OidcOAuthSection.vue create mode 100644 frontend/src/views/auth/OidcCallbackView.vue diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 9b43037789..117d42936a 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -65,6 +65,7 @@ type Config struct { JWT JWTConfig `mapstructure:"jwt"` Totp TotpConfig `mapstructure:"totp"` LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"` + OIDC OIDCConnectConfig `mapstructure:"oidc_connect"` Default DefaultConfig `mapstructure:"default"` RateLimit RateLimitConfig `mapstructure:"rate_limit"` Pricing PricingConfig `mapstructure:"pricing"` @@ -184,6 +185,34 @@ type LinuxDoConnectConfig struct { UserInfoUsernamePath string `mapstructure:"userinfo_username_path"` } +type OIDCConnectConfig struct { + Enabled bool `mapstructure:"enabled"` + ProviderName string `mapstructure:"provider_name"` // 显示名: "Keycloak" 等 + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + IssuerURL string `mapstructure:"issuer_url"` + DiscoveryURL string `mapstructure:"discovery_url"` + AuthorizeURL string `mapstructure:"authorize_url"` + TokenURL string `mapstructure:"token_url"` + UserInfoURL string `mapstructure:"userinfo_url"` + JWKSURL string `mapstructure:"jwks_url"` + Scopes string `mapstructure:"scopes"` // 默认 "openid email profile" + RedirectURL string `mapstructure:"redirect_url"` // 后端回调地址(需在提供方后台登记) + FrontendRedirectURL string `mapstructure:"frontend_redirect_url"` // 前端接收 token 的路由(默认:/auth/oidc/callback) + TokenAuthMethod string `mapstructure:"token_auth_method"` // client_secret_post / client_secret_basic / none + UsePKCE bool `mapstructure:"use_pkce"` + ValidateIDToken bool `mapstructure:"validate_id_token"` + AllowedSigningAlgs string `mapstructure:"allowed_signing_algs"` // 默认 "RS256,ES256,PS256" + ClockSkewSeconds int `mapstructure:"clock_skew_seconds"` // 默认 120 + RequireEmailVerified bool `mapstructure:"require_email_verified"` // 默认 false + + // 可选:用于从 userinfo JSON 中提取字段的 gjson 路径。 + // 为空时,服务端会尝试一组常见字段名。 + UserInfoEmailPath string `mapstructure:"userinfo_email_path"` + UserInfoIDPath string `mapstructure:"userinfo_id_path"` + UserInfoUsernamePath string `mapstructure:"userinfo_username_path"` +} + // TokenRefreshConfig OAuth token自动刷新配置 type TokenRefreshConfig struct { // 是否启用自动刷新 @@ -968,6 +997,23 @@ func load(allowMissingJWTSecret bool) (*Config, error) { cfg.LinuxDo.UserInfoEmailPath = strings.TrimSpace(cfg.LinuxDo.UserInfoEmailPath) cfg.LinuxDo.UserInfoIDPath = strings.TrimSpace(cfg.LinuxDo.UserInfoIDPath) cfg.LinuxDo.UserInfoUsernamePath = strings.TrimSpace(cfg.LinuxDo.UserInfoUsernamePath) + cfg.OIDC.ProviderName = strings.TrimSpace(cfg.OIDC.ProviderName) + cfg.OIDC.ClientID = strings.TrimSpace(cfg.OIDC.ClientID) + cfg.OIDC.ClientSecret = strings.TrimSpace(cfg.OIDC.ClientSecret) + cfg.OIDC.IssuerURL = strings.TrimSpace(cfg.OIDC.IssuerURL) + cfg.OIDC.DiscoveryURL = strings.TrimSpace(cfg.OIDC.DiscoveryURL) + cfg.OIDC.AuthorizeURL = strings.TrimSpace(cfg.OIDC.AuthorizeURL) + cfg.OIDC.TokenURL = strings.TrimSpace(cfg.OIDC.TokenURL) + cfg.OIDC.UserInfoURL = strings.TrimSpace(cfg.OIDC.UserInfoURL) + cfg.OIDC.JWKSURL = strings.TrimSpace(cfg.OIDC.JWKSURL) + cfg.OIDC.Scopes = strings.TrimSpace(cfg.OIDC.Scopes) + cfg.OIDC.RedirectURL = strings.TrimSpace(cfg.OIDC.RedirectURL) + cfg.OIDC.FrontendRedirectURL = strings.TrimSpace(cfg.OIDC.FrontendRedirectURL) + cfg.OIDC.TokenAuthMethod = strings.ToLower(strings.TrimSpace(cfg.OIDC.TokenAuthMethod)) + cfg.OIDC.AllowedSigningAlgs = strings.TrimSpace(cfg.OIDC.AllowedSigningAlgs) + cfg.OIDC.UserInfoEmailPath = strings.TrimSpace(cfg.OIDC.UserInfoEmailPath) + cfg.OIDC.UserInfoIDPath = strings.TrimSpace(cfg.OIDC.UserInfoIDPath) + cfg.OIDC.UserInfoUsernamePath = strings.TrimSpace(cfg.OIDC.UserInfoUsernamePath) cfg.Dashboard.KeyPrefix = strings.TrimSpace(cfg.Dashboard.KeyPrefix) cfg.CORS.AllowedOrigins = normalizeStringSlice(cfg.CORS.AllowedOrigins) cfg.Security.ResponseHeaders.AdditionalAllowed = normalizeStringSlice(cfg.Security.ResponseHeaders.AdditionalAllowed) @@ -1138,6 +1184,30 @@ func setDefaults() { viper.SetDefault("linuxdo_connect.userinfo_id_path", "") viper.SetDefault("linuxdo_connect.userinfo_username_path", "") + // Generic OIDC OAuth 登录 + viper.SetDefault("oidc_connect.enabled", false) + viper.SetDefault("oidc_connect.provider_name", "OIDC") + viper.SetDefault("oidc_connect.client_id", "") + viper.SetDefault("oidc_connect.client_secret", "") + viper.SetDefault("oidc_connect.issuer_url", "") + viper.SetDefault("oidc_connect.discovery_url", "") + viper.SetDefault("oidc_connect.authorize_url", "") + viper.SetDefault("oidc_connect.token_url", "") + viper.SetDefault("oidc_connect.userinfo_url", "") + viper.SetDefault("oidc_connect.jwks_url", "") + viper.SetDefault("oidc_connect.scopes", "openid email profile") + viper.SetDefault("oidc_connect.redirect_url", "") + viper.SetDefault("oidc_connect.frontend_redirect_url", "/auth/oidc/callback") + viper.SetDefault("oidc_connect.token_auth_method", "client_secret_post") + viper.SetDefault("oidc_connect.use_pkce", false) + viper.SetDefault("oidc_connect.validate_id_token", true) + viper.SetDefault("oidc_connect.allowed_signing_algs", "RS256,ES256,PS256") + viper.SetDefault("oidc_connect.clock_skew_seconds", 120) + viper.SetDefault("oidc_connect.require_email_verified", false) + viper.SetDefault("oidc_connect.userinfo_email_path", "") + viper.SetDefault("oidc_connect.userinfo_id_path", "") + viper.SetDefault("oidc_connect.userinfo_username_path", "") + // Database viper.SetDefault("database.host", "localhost") viper.SetDefault("database.port", 5432) @@ -1572,6 +1642,87 @@ func (c *Config) Validate() error { warnIfInsecureURL("linuxdo_connect.redirect_url", c.LinuxDo.RedirectURL) warnIfInsecureURL("linuxdo_connect.frontend_redirect_url", c.LinuxDo.FrontendRedirectURL) } + if c.OIDC.Enabled { + if strings.TrimSpace(c.OIDC.ClientID) == "" { + return fmt.Errorf("oidc_connect.client_id is required when oidc_connect.enabled=true") + } + if strings.TrimSpace(c.OIDC.IssuerURL) == "" { + return fmt.Errorf("oidc_connect.issuer_url is required when oidc_connect.enabled=true") + } + if strings.TrimSpace(c.OIDC.RedirectURL) == "" { + return fmt.Errorf("oidc_connect.redirect_url is required when oidc_connect.enabled=true") + } + if strings.TrimSpace(c.OIDC.FrontendRedirectURL) == "" { + return fmt.Errorf("oidc_connect.frontend_redirect_url is required when oidc_connect.enabled=true") + } + if !scopeContainsOpenID(c.OIDC.Scopes) { + return fmt.Errorf("oidc_connect.scopes must contain openid") + } + + method := strings.ToLower(strings.TrimSpace(c.OIDC.TokenAuthMethod)) + switch method { + case "", "client_secret_post", "client_secret_basic", "none": + default: + return fmt.Errorf("oidc_connect.token_auth_method must be one of: client_secret_post/client_secret_basic/none") + } + if method == "none" && !c.OIDC.UsePKCE { + return fmt.Errorf("oidc_connect.use_pkce must be true when oidc_connect.token_auth_method=none") + } + if (method == "" || method == "client_secret_post" || method == "client_secret_basic") && + strings.TrimSpace(c.OIDC.ClientSecret) == "" { + return fmt.Errorf("oidc_connect.client_secret is required when oidc_connect.enabled=true and token_auth_method is client_secret_post/client_secret_basic") + } + if c.OIDC.ClockSkewSeconds < 0 || c.OIDC.ClockSkewSeconds > 600 { + return fmt.Errorf("oidc_connect.clock_skew_seconds must be between 0 and 600") + } + if c.OIDC.ValidateIDToken && strings.TrimSpace(c.OIDC.AllowedSigningAlgs) == "" { + return fmt.Errorf("oidc_connect.allowed_signing_algs is required when oidc_connect.validate_id_token=true") + } + + if err := ValidateAbsoluteHTTPURL(c.OIDC.IssuerURL); err != nil { + return fmt.Errorf("oidc_connect.issuer_url invalid: %w", err) + } + if v := strings.TrimSpace(c.OIDC.DiscoveryURL); v != "" { + if err := ValidateAbsoluteHTTPURL(v); err != nil { + return fmt.Errorf("oidc_connect.discovery_url invalid: %w", err) + } + } + if v := strings.TrimSpace(c.OIDC.AuthorizeURL); v != "" { + if err := ValidateAbsoluteHTTPURL(v); err != nil { + return fmt.Errorf("oidc_connect.authorize_url invalid: %w", err) + } + } + if v := strings.TrimSpace(c.OIDC.TokenURL); v != "" { + if err := ValidateAbsoluteHTTPURL(v); err != nil { + return fmt.Errorf("oidc_connect.token_url invalid: %w", err) + } + } + if v := strings.TrimSpace(c.OIDC.UserInfoURL); v != "" { + if err := ValidateAbsoluteHTTPURL(v); err != nil { + return fmt.Errorf("oidc_connect.userinfo_url invalid: %w", err) + } + } + if v := strings.TrimSpace(c.OIDC.JWKSURL); v != "" { + if err := ValidateAbsoluteHTTPURL(v); err != nil { + return fmt.Errorf("oidc_connect.jwks_url invalid: %w", err) + } + } + if err := ValidateAbsoluteHTTPURL(c.OIDC.RedirectURL); err != nil { + return fmt.Errorf("oidc_connect.redirect_url invalid: %w", err) + } + if err := ValidateFrontendRedirectURL(c.OIDC.FrontendRedirectURL); err != nil { + return fmt.Errorf("oidc_connect.frontend_redirect_url invalid: %w", err) + } + + warnIfInsecureURL("oidc_connect.issuer_url", c.OIDC.IssuerURL) + warnIfInsecureURL("oidc_connect.discovery_url", c.OIDC.DiscoveryURL) + warnIfInsecureURL("oidc_connect.authorize_url", c.OIDC.AuthorizeURL) + warnIfInsecureURL("oidc_connect.token_url", c.OIDC.TokenURL) + warnIfInsecureURL("oidc_connect.userinfo_url", c.OIDC.UserInfoURL) + warnIfInsecureURL("oidc_connect.jwks_url", c.OIDC.JWKSURL) + warnIfInsecureURL("oidc_connect.redirect_url", c.OIDC.RedirectURL) + warnIfInsecureURL("oidc_connect.frontend_redirect_url", c.OIDC.FrontendRedirectURL) + } if c.Billing.CircuitBreaker.Enabled { if c.Billing.CircuitBreaker.FailureThreshold <= 0 { return fmt.Errorf("billing.circuit_breaker.failure_threshold must be positive") @@ -2184,6 +2335,15 @@ func ValidateFrontendRedirectURL(raw string) error { return nil } +func scopeContainsOpenID(scopes string) bool { + for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) { + if scope == "openid" { + return true + } + } + return false +} + // isHTTPScheme 检查是否为 HTTP 或 HTTPS 协议 func isHTTPScheme(scheme string) bool { return strings.EqualFold(scheme, "http") || strings.EqualFold(scheme, "https") diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 2de5451ee0..b9660b78a1 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -351,6 +351,60 @@ func TestValidateLinuxDoPKCERequiredForPublicClient(t *testing.T) { } } +func TestValidateOIDCScopesMustContainOpenID(t *testing.T) { + resetViperWithJWTSecret(t) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + cfg.OIDC.Enabled = true + cfg.OIDC.ClientID = "oidc-client" + cfg.OIDC.ClientSecret = "oidc-secret" + cfg.OIDC.IssuerURL = "https://issuer.example.com" + cfg.OIDC.AuthorizeURL = "https://issuer.example.com/auth" + cfg.OIDC.TokenURL = "https://issuer.example.com/token" + cfg.OIDC.JWKSURL = "https://issuer.example.com/jwks" + cfg.OIDC.RedirectURL = "https://example.com/api/v1/auth/oauth/oidc/callback" + cfg.OIDC.FrontendRedirectURL = "/auth/oidc/callback" + cfg.OIDC.Scopes = "profile email" + + err = cfg.Validate() + if err == nil { + t.Fatalf("Validate() expected error when scopes do not include openid, got nil") + } + if !strings.Contains(err.Error(), "oidc_connect.scopes") { + t.Fatalf("Validate() expected oidc_connect.scopes error, got: %v", err) + } +} + +func TestValidateOIDCAllowsIssuerOnlyEndpointsWithDiscoveryFallback(t *testing.T) { + resetViperWithJWTSecret(t) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + cfg.OIDC.Enabled = true + cfg.OIDC.ClientID = "oidc-client" + cfg.OIDC.ClientSecret = "oidc-secret" + cfg.OIDC.IssuerURL = "https://issuer.example.com" + cfg.OIDC.AuthorizeURL = "" + cfg.OIDC.TokenURL = "" + cfg.OIDC.JWKSURL = "" + cfg.OIDC.RedirectURL = "https://example.com/api/v1/auth/oauth/oidc/callback" + cfg.OIDC.FrontendRedirectURL = "/auth/oidc/callback" + cfg.OIDC.Scopes = "openid email profile" + cfg.OIDC.ValidateIDToken = true + + err = cfg.Validate() + if err != nil { + t.Fatalf("Validate() expected issuer-only OIDC config to pass with discovery fallback, got: %v", err) + } +} + func TestLoadDefaultDashboardCacheConfig(t *testing.T) { resetViperWithJWTSecret(t) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 4cbe5188ec..abae75d909 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -35,6 +35,15 @@ func generateMenuItemID() (string, error) { return hex.EncodeToString(b), nil } +func scopesContainOpenID(scopes string) bool { + for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) { + if scope == "openid" { + return true + } + } + return false +} + // SettingHandler 系统设置处理器 type SettingHandler struct { settingService *service.SettingService @@ -96,6 +105,28 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { LinuxDoConnectClientID: settings.LinuxDoConnectClientID, LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured, LinuxDoConnectRedirectURL: settings.LinuxDoConnectRedirectURL, + OIDCConnectEnabled: settings.OIDCConnectEnabled, + OIDCConnectProviderName: settings.OIDCConnectProviderName, + OIDCConnectClientID: settings.OIDCConnectClientID, + OIDCConnectClientSecretConfigured: settings.OIDCConnectClientSecretConfigured, + OIDCConnectIssuerURL: settings.OIDCConnectIssuerURL, + OIDCConnectDiscoveryURL: settings.OIDCConnectDiscoveryURL, + OIDCConnectAuthorizeURL: settings.OIDCConnectAuthorizeURL, + OIDCConnectTokenURL: settings.OIDCConnectTokenURL, + OIDCConnectUserInfoURL: settings.OIDCConnectUserInfoURL, + OIDCConnectJWKSURL: settings.OIDCConnectJWKSURL, + OIDCConnectScopes: settings.OIDCConnectScopes, + OIDCConnectRedirectURL: settings.OIDCConnectRedirectURL, + OIDCConnectFrontendRedirectURL: settings.OIDCConnectFrontendRedirectURL, + OIDCConnectTokenAuthMethod: settings.OIDCConnectTokenAuthMethod, + OIDCConnectUsePKCE: settings.OIDCConnectUsePKCE, + OIDCConnectValidateIDToken: settings.OIDCConnectValidateIDToken, + OIDCConnectAllowedSigningAlgs: settings.OIDCConnectAllowedSigningAlgs, + OIDCConnectClockSkewSeconds: settings.OIDCConnectClockSkewSeconds, + OIDCConnectRequireEmailVerified: settings.OIDCConnectRequireEmailVerified, + OIDCConnectUserInfoEmailPath: settings.OIDCConnectUserInfoEmailPath, + OIDCConnectUserInfoIDPath: settings.OIDCConnectUserInfoIDPath, + OIDCConnectUserInfoUsernamePath: settings.OIDCConnectUserInfoUsernamePath, SiteName: settings.SiteName, SiteLogo: settings.SiteLogo, SiteSubtitle: settings.SiteSubtitle, @@ -164,6 +195,30 @@ type UpdateSettingsRequest struct { LinuxDoConnectClientSecret string `json:"linuxdo_connect_client_secret"` LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"` + // Generic OIDC OAuth 登录 + OIDCConnectEnabled bool `json:"oidc_connect_enabled"` + OIDCConnectProviderName string `json:"oidc_connect_provider_name"` + OIDCConnectClientID string `json:"oidc_connect_client_id"` + OIDCConnectClientSecret string `json:"oidc_connect_client_secret"` + OIDCConnectIssuerURL string `json:"oidc_connect_issuer_url"` + OIDCConnectDiscoveryURL string `json:"oidc_connect_discovery_url"` + OIDCConnectAuthorizeURL string `json:"oidc_connect_authorize_url"` + OIDCConnectTokenURL string `json:"oidc_connect_token_url"` + OIDCConnectUserInfoURL string `json:"oidc_connect_userinfo_url"` + OIDCConnectJWKSURL string `json:"oidc_connect_jwks_url"` + OIDCConnectScopes string `json:"oidc_connect_scopes"` + OIDCConnectRedirectURL string `json:"oidc_connect_redirect_url"` + OIDCConnectFrontendRedirectURL string `json:"oidc_connect_frontend_redirect_url"` + OIDCConnectTokenAuthMethod string `json:"oidc_connect_token_auth_method"` + OIDCConnectUsePKCE bool `json:"oidc_connect_use_pkce"` + OIDCConnectValidateIDToken bool `json:"oidc_connect_validate_id_token"` + OIDCConnectAllowedSigningAlgs string `json:"oidc_connect_allowed_signing_algs"` + OIDCConnectClockSkewSeconds int `json:"oidc_connect_clock_skew_seconds"` + OIDCConnectRequireEmailVerified bool `json:"oidc_connect_require_email_verified"` + OIDCConnectUserInfoEmailPath string `json:"oidc_connect_userinfo_email_path"` + OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"` + OIDCConnectUserInfoUsernamePath string `json:"oidc_connect_userinfo_username_path"` + // OEM设置 SiteName string `json:"site_name"` SiteLogo string `json:"site_logo"` @@ -324,6 +379,122 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } + // Generic OIDC 参数验证 + if req.OIDCConnectEnabled { + req.OIDCConnectProviderName = strings.TrimSpace(req.OIDCConnectProviderName) + req.OIDCConnectClientID = strings.TrimSpace(req.OIDCConnectClientID) + req.OIDCConnectClientSecret = strings.TrimSpace(req.OIDCConnectClientSecret) + req.OIDCConnectIssuerURL = strings.TrimSpace(req.OIDCConnectIssuerURL) + req.OIDCConnectDiscoveryURL = strings.TrimSpace(req.OIDCConnectDiscoveryURL) + req.OIDCConnectAuthorizeURL = strings.TrimSpace(req.OIDCConnectAuthorizeURL) + req.OIDCConnectTokenURL = strings.TrimSpace(req.OIDCConnectTokenURL) + req.OIDCConnectUserInfoURL = strings.TrimSpace(req.OIDCConnectUserInfoURL) + req.OIDCConnectJWKSURL = strings.TrimSpace(req.OIDCConnectJWKSURL) + req.OIDCConnectScopes = strings.TrimSpace(req.OIDCConnectScopes) + req.OIDCConnectRedirectURL = strings.TrimSpace(req.OIDCConnectRedirectURL) + req.OIDCConnectFrontendRedirectURL = strings.TrimSpace(req.OIDCConnectFrontendRedirectURL) + req.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(req.OIDCConnectTokenAuthMethod)) + req.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(req.OIDCConnectAllowedSigningAlgs) + req.OIDCConnectUserInfoEmailPath = strings.TrimSpace(req.OIDCConnectUserInfoEmailPath) + req.OIDCConnectUserInfoIDPath = strings.TrimSpace(req.OIDCConnectUserInfoIDPath) + req.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(req.OIDCConnectUserInfoUsernamePath) + + if req.OIDCConnectProviderName == "" { + req.OIDCConnectProviderName = "OIDC" + } + if req.OIDCConnectClientID == "" { + response.BadRequest(c, "OIDC Client ID is required when enabled") + return + } + if req.OIDCConnectIssuerURL == "" { + response.BadRequest(c, "OIDC Issuer URL is required when enabled") + return + } + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectIssuerURL); err != nil { + response.BadRequest(c, "OIDC Issuer URL must be an absolute http(s) URL") + return + } + if req.OIDCConnectDiscoveryURL != "" { + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectDiscoveryURL); err != nil { + response.BadRequest(c, "OIDC Discovery URL must be an absolute http(s) URL") + return + } + } + if req.OIDCConnectAuthorizeURL != "" { + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectAuthorizeURL); err != nil { + response.BadRequest(c, "OIDC Authorize URL must be an absolute http(s) URL") + return + } + } + if req.OIDCConnectTokenURL != "" { + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectTokenURL); err != nil { + response.BadRequest(c, "OIDC Token URL must be an absolute http(s) URL") + return + } + } + if req.OIDCConnectUserInfoURL != "" { + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectUserInfoURL); err != nil { + response.BadRequest(c, "OIDC UserInfo URL must be an absolute http(s) URL") + return + } + } + if req.OIDCConnectRedirectURL == "" { + response.BadRequest(c, "OIDC Redirect URL is required when enabled") + return + } + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectRedirectURL); err != nil { + response.BadRequest(c, "OIDC Redirect URL must be an absolute http(s) URL") + return + } + if req.OIDCConnectFrontendRedirectURL == "" { + response.BadRequest(c, "OIDC Frontend Redirect URL is required when enabled") + return + } + if err := config.ValidateFrontendRedirectURL(req.OIDCConnectFrontendRedirectURL); err != nil { + response.BadRequest(c, "OIDC Frontend Redirect URL is invalid") + return + } + if !scopesContainOpenID(req.OIDCConnectScopes) { + response.BadRequest(c, "OIDC scopes must contain openid") + return + } + switch req.OIDCConnectTokenAuthMethod { + case "", "client_secret_post", "client_secret_basic", "none": + default: + response.BadRequest(c, "OIDC Token Auth Method must be one of client_secret_post/client_secret_basic/none") + return + } + if req.OIDCConnectTokenAuthMethod == "none" && !req.OIDCConnectUsePKCE { + response.BadRequest(c, "OIDC PKCE must be enabled when token_auth_method=none") + return + } + if req.OIDCConnectClockSkewSeconds < 0 || req.OIDCConnectClockSkewSeconds > 600 { + response.BadRequest(c, "OIDC clock skew seconds must be between 0 and 600") + return + } + if req.OIDCConnectValidateIDToken { + if req.OIDCConnectAllowedSigningAlgs == "" { + response.BadRequest(c, "OIDC Allowed Signing Algs is required when validate_id_token=true") + return + } + } + if req.OIDCConnectJWKSURL != "" { + if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectJWKSURL); err != nil { + response.BadRequest(c, "OIDC JWKS URL must be an absolute http(s) URL") + return + } + } + if req.OIDCConnectTokenAuthMethod == "" || req.OIDCConnectTokenAuthMethod == "client_secret_post" || req.OIDCConnectTokenAuthMethod == "client_secret_basic" { + if req.OIDCConnectClientSecret == "" { + if previousSettings.OIDCConnectClientSecret == "" { + response.BadRequest(c, "OIDC Client Secret is required when enabled") + return + } + req.OIDCConnectClientSecret = previousSettings.OIDCConnectClientSecret + } + } + } + // “购买订阅”页面配置验证 purchaseEnabled := previousSettings.PurchaseSubscriptionEnabled if req.PurchaseSubscriptionEnabled != nil { @@ -554,6 +725,28 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { LinuxDoConnectClientID: req.LinuxDoConnectClientID, LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret, LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL, + OIDCConnectEnabled: req.OIDCConnectEnabled, + OIDCConnectProviderName: req.OIDCConnectProviderName, + OIDCConnectClientID: req.OIDCConnectClientID, + OIDCConnectClientSecret: req.OIDCConnectClientSecret, + OIDCConnectIssuerURL: req.OIDCConnectIssuerURL, + OIDCConnectDiscoveryURL: req.OIDCConnectDiscoveryURL, + OIDCConnectAuthorizeURL: req.OIDCConnectAuthorizeURL, + OIDCConnectTokenURL: req.OIDCConnectTokenURL, + OIDCConnectUserInfoURL: req.OIDCConnectUserInfoURL, + OIDCConnectJWKSURL: req.OIDCConnectJWKSURL, + OIDCConnectScopes: req.OIDCConnectScopes, + OIDCConnectRedirectURL: req.OIDCConnectRedirectURL, + OIDCConnectFrontendRedirectURL: req.OIDCConnectFrontendRedirectURL, + OIDCConnectTokenAuthMethod: req.OIDCConnectTokenAuthMethod, + OIDCConnectUsePKCE: req.OIDCConnectUsePKCE, + OIDCConnectValidateIDToken: req.OIDCConnectValidateIDToken, + OIDCConnectAllowedSigningAlgs: req.OIDCConnectAllowedSigningAlgs, + OIDCConnectClockSkewSeconds: req.OIDCConnectClockSkewSeconds, + OIDCConnectRequireEmailVerified: req.OIDCConnectRequireEmailVerified, + OIDCConnectUserInfoEmailPath: req.OIDCConnectUserInfoEmailPath, + OIDCConnectUserInfoIDPath: req.OIDCConnectUserInfoIDPath, + OIDCConnectUserInfoUsernamePath: req.OIDCConnectUserInfoUsernamePath, SiteName: req.SiteName, SiteLogo: req.SiteLogo, SiteSubtitle: req.SiteSubtitle, @@ -669,6 +862,28 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID, LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured, LinuxDoConnectRedirectURL: updatedSettings.LinuxDoConnectRedirectURL, + OIDCConnectEnabled: updatedSettings.OIDCConnectEnabled, + OIDCConnectProviderName: updatedSettings.OIDCConnectProviderName, + OIDCConnectClientID: updatedSettings.OIDCConnectClientID, + OIDCConnectClientSecretConfigured: updatedSettings.OIDCConnectClientSecretConfigured, + OIDCConnectIssuerURL: updatedSettings.OIDCConnectIssuerURL, + OIDCConnectDiscoveryURL: updatedSettings.OIDCConnectDiscoveryURL, + OIDCConnectAuthorizeURL: updatedSettings.OIDCConnectAuthorizeURL, + OIDCConnectTokenURL: updatedSettings.OIDCConnectTokenURL, + OIDCConnectUserInfoURL: updatedSettings.OIDCConnectUserInfoURL, + OIDCConnectJWKSURL: updatedSettings.OIDCConnectJWKSURL, + OIDCConnectScopes: updatedSettings.OIDCConnectScopes, + OIDCConnectRedirectURL: updatedSettings.OIDCConnectRedirectURL, + OIDCConnectFrontendRedirectURL: updatedSettings.OIDCConnectFrontendRedirectURL, + OIDCConnectTokenAuthMethod: updatedSettings.OIDCConnectTokenAuthMethod, + OIDCConnectUsePKCE: updatedSettings.OIDCConnectUsePKCE, + OIDCConnectValidateIDToken: updatedSettings.OIDCConnectValidateIDToken, + OIDCConnectAllowedSigningAlgs: updatedSettings.OIDCConnectAllowedSigningAlgs, + OIDCConnectClockSkewSeconds: updatedSettings.OIDCConnectClockSkewSeconds, + OIDCConnectRequireEmailVerified: updatedSettings.OIDCConnectRequireEmailVerified, + OIDCConnectUserInfoEmailPath: updatedSettings.OIDCConnectUserInfoEmailPath, + OIDCConnectUserInfoIDPath: updatedSettings.OIDCConnectUserInfoIDPath, + OIDCConnectUserInfoUsernamePath: updatedSettings.OIDCConnectUserInfoUsernamePath, SiteName: updatedSettings.SiteName, SiteLogo: updatedSettings.SiteLogo, SiteSubtitle: updatedSettings.SiteSubtitle, @@ -787,6 +1002,72 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.LinuxDoConnectRedirectURL != after.LinuxDoConnectRedirectURL { changed = append(changed, "linuxdo_connect_redirect_url") } + if before.OIDCConnectEnabled != after.OIDCConnectEnabled { + changed = append(changed, "oidc_connect_enabled") + } + if before.OIDCConnectProviderName != after.OIDCConnectProviderName { + changed = append(changed, "oidc_connect_provider_name") + } + if before.OIDCConnectClientID != after.OIDCConnectClientID { + changed = append(changed, "oidc_connect_client_id") + } + if req.OIDCConnectClientSecret != "" { + changed = append(changed, "oidc_connect_client_secret") + } + if before.OIDCConnectIssuerURL != after.OIDCConnectIssuerURL { + changed = append(changed, "oidc_connect_issuer_url") + } + if before.OIDCConnectDiscoveryURL != after.OIDCConnectDiscoveryURL { + changed = append(changed, "oidc_connect_discovery_url") + } + if before.OIDCConnectAuthorizeURL != after.OIDCConnectAuthorizeURL { + changed = append(changed, "oidc_connect_authorize_url") + } + if before.OIDCConnectTokenURL != after.OIDCConnectTokenURL { + changed = append(changed, "oidc_connect_token_url") + } + if before.OIDCConnectUserInfoURL != after.OIDCConnectUserInfoURL { + changed = append(changed, "oidc_connect_userinfo_url") + } + if before.OIDCConnectJWKSURL != after.OIDCConnectJWKSURL { + changed = append(changed, "oidc_connect_jwks_url") + } + if before.OIDCConnectScopes != after.OIDCConnectScopes { + changed = append(changed, "oidc_connect_scopes") + } + if before.OIDCConnectRedirectURL != after.OIDCConnectRedirectURL { + changed = append(changed, "oidc_connect_redirect_url") + } + if before.OIDCConnectFrontendRedirectURL != after.OIDCConnectFrontendRedirectURL { + changed = append(changed, "oidc_connect_frontend_redirect_url") + } + if before.OIDCConnectTokenAuthMethod != after.OIDCConnectTokenAuthMethod { + changed = append(changed, "oidc_connect_token_auth_method") + } + if before.OIDCConnectUsePKCE != after.OIDCConnectUsePKCE { + changed = append(changed, "oidc_connect_use_pkce") + } + if before.OIDCConnectValidateIDToken != after.OIDCConnectValidateIDToken { + changed = append(changed, "oidc_connect_validate_id_token") + } + if before.OIDCConnectAllowedSigningAlgs != after.OIDCConnectAllowedSigningAlgs { + changed = append(changed, "oidc_connect_allowed_signing_algs") + } + if before.OIDCConnectClockSkewSeconds != after.OIDCConnectClockSkewSeconds { + changed = append(changed, "oidc_connect_clock_skew_seconds") + } + if before.OIDCConnectRequireEmailVerified != after.OIDCConnectRequireEmailVerified { + changed = append(changed, "oidc_connect_require_email_verified") + } + if before.OIDCConnectUserInfoEmailPath != after.OIDCConnectUserInfoEmailPath { + changed = append(changed, "oidc_connect_userinfo_email_path") + } + if before.OIDCConnectUserInfoIDPath != after.OIDCConnectUserInfoIDPath { + changed = append(changed, "oidc_connect_userinfo_id_path") + } + if before.OIDCConnectUserInfoUsernamePath != after.OIDCConnectUserInfoUsernamePath { + changed = append(changed, "oidc_connect_userinfo_username_path") + } if before.SiteName != after.SiteName { changed = append(changed, "site_name") } diff --git a/backend/internal/handler/auth_oidc_oauth.go b/backend/internal/handler/auth_oidc_oauth.go new file mode 100644 index 0000000000..f46fb8507c --- /dev/null +++ b/backend/internal/handler/auth_oidc_oauth.go @@ -0,0 +1,865 @@ +package handler + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "math/big" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/oauth" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/imroc/req/v3" + "github.com/tidwall/gjson" +) + +const ( + oidcOAuthCookiePath = "/api/v1/auth/oauth/oidc" + oidcOAuthStateCookieName = "oidc_oauth_state" + oidcOAuthVerifierCookie = "oidc_oauth_verifier" + oidcOAuthRedirectCookie = "oidc_oauth_redirect" + oidcOAuthNonceCookie = "oidc_oauth_nonce" + oidcOAuthCookieMaxAgeSec = 10 * 60 // 10 minutes + oidcOAuthDefaultRedirectTo = "/dashboard" + oidcOAuthDefaultFrontendCB = "/auth/oidc/callback" +) + +type oidcTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope,omitempty"` + IDToken string `json:"id_token,omitempty"` +} + +type oidcTokenExchangeError struct { + StatusCode int + ProviderError string + ProviderDescription string + Body string +} + +func (e *oidcTokenExchangeError) Error() string { + if e == nil { + return "" + } + parts := []string{fmt.Sprintf("token exchange status=%d", e.StatusCode)} + if strings.TrimSpace(e.ProviderError) != "" { + parts = append(parts, "error="+strings.TrimSpace(e.ProviderError)) + } + if strings.TrimSpace(e.ProviderDescription) != "" { + parts = append(parts, "error_description="+strings.TrimSpace(e.ProviderDescription)) + } + return strings.Join(parts, " ") +} + +type oidcIDTokenClaims struct { + Email string `json:"email,omitempty"` + EmailVerified *bool `json:"email_verified,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + Name string `json:"name,omitempty"` + Nonce string `json:"nonce,omitempty"` + Azp string `json:"azp,omitempty"` + jwt.RegisteredClaims +} + +type oidcUserInfoClaims struct { + Email string + Username string + Subject string + EmailVerified *bool +} + +type oidcJWKSet struct { + Keys []oidcJWK `json:"keys"` +} + +type oidcJWK struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Use string `json:"use"` + Alg string `json:"alg"` + + N string `json:"n"` + E string `json:"e"` + + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` +} + +// OIDCOAuthStart 启动通用 OIDC OAuth 登录流程。 +// GET /api/v1/auth/oauth/oidc/start?redirect=/dashboard +func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) { + cfg, err := h.getOIDCOAuthConfig(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + state, err := oauth.GenerateState() + if err != nil { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err)) + return + } + + redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect")) + if redirectTo == "" { + redirectTo = oidcOAuthDefaultRedirectTo + } + + secureCookie := isRequestHTTPS(c) + oidcSetCookie(c, oidcOAuthStateCookieName, encodeCookieValue(state), oidcOAuthCookieMaxAgeSec, secureCookie) + oidcSetCookie(c, oidcOAuthRedirectCookie, encodeCookieValue(redirectTo), oidcOAuthCookieMaxAgeSec, secureCookie) + + codeChallenge := "" + if cfg.UsePKCE { + verifier, genErr := oauth.GenerateCodeVerifier() + if genErr != nil { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_PKCE_GEN_FAILED", "failed to generate pkce verifier").WithCause(genErr)) + return + } + codeChallenge = oauth.GenerateCodeChallenge(verifier) + oidcSetCookie(c, oidcOAuthVerifierCookie, encodeCookieValue(verifier), oidcOAuthCookieMaxAgeSec, secureCookie) + } + + nonce := "" + if cfg.ValidateIDToken { + nonce, err = oauth.GenerateState() + if err != nil { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_NONCE_GEN_FAILED", "failed to generate oauth nonce").WithCause(err)) + return + } + oidcSetCookie(c, oidcOAuthNonceCookie, encodeCookieValue(nonce), oidcOAuthCookieMaxAgeSec, secureCookie) + } + + redirectURI := strings.TrimSpace(cfg.RedirectURL) + if redirectURI == "" { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured")) + return + } + + authURL, err := buildOIDCAuthorizeURL(cfg, state, nonce, codeChallenge, redirectURI) + if err != nil { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err)) + return + } + + c.Redirect(http.StatusFound, authURL) +} + +// OIDCOAuthCallback 处理 OIDC 回调:校验 id_token、创建/登录用户并重定向到前端。 +// GET /api/v1/auth/oauth/oidc/callback?code=...&state=... +func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) { + cfg, cfgErr := h.getOIDCOAuthConfig(c.Request.Context()) + if cfgErr != nil { + response.ErrorFrom(c, cfgErr) + return + } + + frontendCallback := strings.TrimSpace(cfg.FrontendRedirectURL) + if frontendCallback == "" { + frontendCallback = oidcOAuthDefaultFrontendCB + } + + if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" { + redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description")) + return + } + + code := strings.TrimSpace(c.Query("code")) + state := strings.TrimSpace(c.Query("state")) + if code == "" || state == "" { + redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "") + return + } + + secureCookie := isRequestHTTPS(c) + defer func() { + oidcClearCookie(c, oidcOAuthStateCookieName, secureCookie) + oidcClearCookie(c, oidcOAuthVerifierCookie, secureCookie) + oidcClearCookie(c, oidcOAuthRedirectCookie, secureCookie) + oidcClearCookie(c, oidcOAuthNonceCookie, secureCookie) + }() + + expectedState, err := readCookieDecoded(c, oidcOAuthStateCookieName) + if err != nil || expectedState == "" || state != expectedState { + redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "") + return + } + + redirectTo, _ := readCookieDecoded(c, oidcOAuthRedirectCookie) + redirectTo = sanitizeFrontendRedirectPath(redirectTo) + if redirectTo == "" { + redirectTo = oidcOAuthDefaultRedirectTo + } + + codeVerifier := "" + if cfg.UsePKCE { + codeVerifier, _ = readCookieDecoded(c, oidcOAuthVerifierCookie) + if codeVerifier == "" { + redirectOAuthError(c, frontendCallback, "missing_verifier", "missing pkce verifier", "") + return + } + } + + expectedNonce := "" + if cfg.ValidateIDToken { + expectedNonce, _ = readCookieDecoded(c, oidcOAuthNonceCookie) + if expectedNonce == "" { + redirectOAuthError(c, frontendCallback, "missing_nonce", "missing oauth nonce", "") + return + } + } + + redirectURI := strings.TrimSpace(cfg.RedirectURL) + if redirectURI == "" { + redirectOAuthError(c, frontendCallback, "config_error", "oauth redirect url not configured", "") + return + } + + tokenResp, err := oidcExchangeCode(c.Request.Context(), cfg, code, redirectURI, codeVerifier) + if err != nil { + description := "" + var exchangeErr *oidcTokenExchangeError + if errors.As(err, &exchangeErr) && exchangeErr != nil { + log.Printf( + "[OIDC OAuth] token exchange failed: status=%d provider_error=%q provider_description=%q body=%s", + exchangeErr.StatusCode, + exchangeErr.ProviderError, + exchangeErr.ProviderDescription, + truncateLogValue(exchangeErr.Body, 2048), + ) + description = exchangeErr.Error() + } else { + log.Printf("[OIDC OAuth] token exchange failed: %v", err) + description = err.Error() + } + redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", singleLine(description)) + return + } + + if cfg.ValidateIDToken && strings.TrimSpace(tokenResp.IDToken) == "" { + redirectOAuthError(c, frontendCallback, "missing_id_token", "missing id_token", "") + return + } + + idClaims, err := oidcParseAndValidateIDToken(c.Request.Context(), cfg, tokenResp.IDToken, expectedNonce) + if err != nil { + log.Printf("[OIDC OAuth] id_token validation failed: %v", err) + redirectOAuthError(c, frontendCallback, "invalid_id_token", "failed to validate id_token", "") + return + } + + userInfoClaims, err := oidcFetchUserInfo(c.Request.Context(), cfg, tokenResp) + if err != nil { + log.Printf("[OIDC OAuth] userinfo fetch failed: %v", err) + redirectOAuthError(c, frontendCallback, "userinfo_failed", "failed to fetch user info", "") + return + } + + subject := strings.TrimSpace(idClaims.Subject) + if subject == "" { + subject = strings.TrimSpace(userInfoClaims.Subject) + } + if subject == "" { + redirectOAuthError(c, frontendCallback, "missing_subject", "missing subject claim", "") + return + } + issuer := strings.TrimSpace(idClaims.Issuer) + if issuer == "" { + issuer = strings.TrimSpace(cfg.IssuerURL) + } + if issuer == "" { + redirectOAuthError(c, frontendCallback, "missing_issuer", "missing issuer claim", "") + return + } + + emailVerified := userInfoClaims.EmailVerified + if emailVerified == nil { + emailVerified = idClaims.EmailVerified + } + if cfg.RequireEmailVerified { + if emailVerified == nil || !*emailVerified { + redirectOAuthError(c, frontendCallback, "email_not_verified", "email is not verified", "") + return + } + } + + identityKey := oidcIdentityKey(issuer, subject) + email := oidcSyntheticEmailFromIdentityKey(identityKey) + username := firstNonEmpty( + userInfoClaims.Username, + idClaims.PreferredUsername, + idClaims.Name, + oidcFallbackUsername(subject), + ) + + // 传入空邀请码;如果需要邀请码,服务层返回 ErrOAuthInvitationRequired + tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "") + if err != nil { + if errors.Is(err, service.ErrOAuthInvitationRequired) { + pendingToken, tokenErr := h.authService.CreatePendingOAuthToken(email, username) + if tokenErr != nil { + redirectOAuthError(c, frontendCallback, "login_failed", "service_error", "") + return + } + fragment := url.Values{} + fragment.Set("error", "invitation_required") + fragment.Set("pending_oauth_token", pendingToken) + fragment.Set("redirect", redirectTo) + redirectWithFragment(c, frontendCallback, fragment) + return + } + redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err)) + return + } + + fragment := url.Values{} + fragment.Set("access_token", tokenPair.AccessToken) + fragment.Set("refresh_token", tokenPair.RefreshToken) + fragment.Set("expires_in", fmt.Sprintf("%d", tokenPair.ExpiresIn)) + fragment.Set("token_type", "Bearer") + fragment.Set("redirect", redirectTo) + redirectWithFragment(c, frontendCallback, fragment) +} + +type completeOIDCOAuthRequest struct { + PendingOAuthToken string `json:"pending_oauth_token" binding:"required"` + InvitationCode string `json:"invitation_code" binding:"required"` +} + +// CompleteOIDCOAuthRegistration completes a pending OAuth registration by validating +// the invitation code and creating the user account. +// POST /api/v1/auth/oauth/oidc/complete-registration +func (h *AuthHandler) CompleteOIDCOAuthRegistration(c *gin.Context) { + var req completeOIDCOAuthRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "INVALID_REQUEST", "message": err.Error()}) + return + } + + email, username, err := h.authService.VerifyPendingOAuthToken(req.PendingOAuthToken) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "INVALID_TOKEN", "message": "invalid or expired registration token"}) + return + } + + tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode) + if err != nil { + response.ErrorFrom(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "access_token": tokenPair.AccessToken, + "refresh_token": tokenPair.RefreshToken, + "expires_in": tokenPair.ExpiresIn, + "token_type": "Bearer", + }) +} + +func (h *AuthHandler) getOIDCOAuthConfig(ctx context.Context) (config.OIDCConnectConfig, error) { + if h != nil && h.settingSvc != nil { + return h.settingSvc.GetOIDCConnectOAuthConfig(ctx) + } + if h == nil || h.cfg == nil { + return config.OIDCConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") + } + if !h.cfg.OIDC.Enabled { + return config.OIDCConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") + } + return h.cfg.OIDC, nil +} + +func oidcExchangeCode( + ctx context.Context, + cfg config.OIDCConnectConfig, + code string, + redirectURI string, + codeVerifier string, +) (*oidcTokenResponse, error) { + client := req.C().SetTimeout(30 * time.Second) + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("client_id", cfg.ClientID) + form.Set("code", code) + form.Set("redirect_uri", redirectURI) + if cfg.UsePKCE { + form.Set("code_verifier", codeVerifier) + } + + r := client.R(). + SetContext(ctx). + SetHeader("Accept", "application/json") + + switch strings.ToLower(strings.TrimSpace(cfg.TokenAuthMethod)) { + case "", "client_secret_post": + form.Set("client_secret", cfg.ClientSecret) + case "client_secret_basic": + r.SetBasicAuth(cfg.ClientID, cfg.ClientSecret) + case "none": + default: + return nil, fmt.Errorf("unsupported token_auth_method: %s", cfg.TokenAuthMethod) + } + + resp, err := r.SetFormDataFromValues(form).Post(cfg.TokenURL) + if err != nil { + return nil, fmt.Errorf("request token: %w", err) + } + body := strings.TrimSpace(resp.String()) + if !resp.IsSuccessState() { + providerErr, providerDesc := parseOAuthProviderError(body) + return nil, &oidcTokenExchangeError{ + StatusCode: resp.StatusCode, + ProviderError: providerErr, + ProviderDescription: providerDesc, + Body: body, + } + } + + tokenResp, ok := oidcParseTokenResponse(body) + if !ok { + return nil, &oidcTokenExchangeError{StatusCode: resp.StatusCode, Body: body} + } + if strings.TrimSpace(tokenResp.TokenType) == "" { + tokenResp.TokenType = "Bearer" + } + if strings.TrimSpace(tokenResp.AccessToken) == "" && strings.TrimSpace(tokenResp.IDToken) == "" { + return nil, &oidcTokenExchangeError{StatusCode: resp.StatusCode, Body: body} + } + return tokenResp, nil +} + +func oidcParseTokenResponse(body string) (*oidcTokenResponse, bool) { + body = strings.TrimSpace(body) + if body == "" { + return nil, false + } + + accessToken := strings.TrimSpace(getGJSON(body, "access_token")) + idToken := strings.TrimSpace(getGJSON(body, "id_token")) + if accessToken != "" || idToken != "" { + tokenType := strings.TrimSpace(getGJSON(body, "token_type")) + refreshToken := strings.TrimSpace(getGJSON(body, "refresh_token")) + scope := strings.TrimSpace(getGJSON(body, "scope")) + expiresIn := gjson.Get(body, "expires_in").Int() + return &oidcTokenResponse{ + AccessToken: accessToken, + TokenType: tokenType, + ExpiresIn: expiresIn, + RefreshToken: refreshToken, + Scope: scope, + IDToken: idToken, + }, true + } + + values, err := url.ParseQuery(body) + if err != nil { + return nil, false + } + accessToken = strings.TrimSpace(values.Get("access_token")) + idToken = strings.TrimSpace(values.Get("id_token")) + if accessToken == "" && idToken == "" { + return nil, false + } + expiresIn := int64(0) + if raw := strings.TrimSpace(values.Get("expires_in")); raw != "" { + if v, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil { + expiresIn = v + } + } + return &oidcTokenResponse{ + AccessToken: accessToken, + TokenType: strings.TrimSpace(values.Get("token_type")), + ExpiresIn: expiresIn, + RefreshToken: strings.TrimSpace(values.Get("refresh_token")), + Scope: strings.TrimSpace(values.Get("scope")), + IDToken: idToken, + }, true +} + +func oidcFetchUserInfo( + ctx context.Context, + cfg config.OIDCConnectConfig, + token *oidcTokenResponse, +) (*oidcUserInfoClaims, error) { + if strings.TrimSpace(cfg.UserInfoURL) == "" { + return &oidcUserInfoClaims{}, nil + } + if token == nil || strings.TrimSpace(token.AccessToken) == "" { + return nil, errors.New("missing access_token for userinfo request") + } + + client := req.C().SetTimeout(30 * time.Second) + authorization, err := buildBearerAuthorization(token.TokenType, token.AccessToken) + if err != nil { + return nil, fmt.Errorf("invalid token for userinfo request: %w", err) + } + + resp, err := client.R(). + SetContext(ctx). + SetHeader("Accept", "application/json"). + SetHeader("Authorization", authorization). + Get(cfg.UserInfoURL) + if err != nil { + return nil, fmt.Errorf("request userinfo: %w", err) + } + if !resp.IsSuccessState() { + return nil, fmt.Errorf("userinfo status=%d", resp.StatusCode) + } + + return oidcParseUserInfo(resp.String(), cfg), nil +} + +func oidcParseUserInfo(body string, cfg config.OIDCConnectConfig) *oidcUserInfoClaims { + claims := &oidcUserInfoClaims{} + claims.Email = firstNonEmpty( + getGJSON(body, cfg.UserInfoEmailPath), + getGJSON(body, "email"), + getGJSON(body, "user.email"), + getGJSON(body, "data.email"), + getGJSON(body, "attributes.email"), + ) + claims.Username = firstNonEmpty( + getGJSON(body, cfg.UserInfoUsernamePath), + getGJSON(body, "preferred_username"), + getGJSON(body, "username"), + getGJSON(body, "name"), + getGJSON(body, "user.username"), + getGJSON(body, "user.name"), + ) + claims.Subject = firstNonEmpty( + getGJSON(body, cfg.UserInfoIDPath), + getGJSON(body, "sub"), + getGJSON(body, "id"), + getGJSON(body, "user_id"), + getGJSON(body, "uid"), + getGJSON(body, "user.id"), + ) + if verified, ok := getGJSONBool(body, "email_verified"); ok { + claims.EmailVerified = &verified + } + claims.Email = strings.TrimSpace(claims.Email) + claims.Username = strings.TrimSpace(claims.Username) + claims.Subject = strings.TrimSpace(claims.Subject) + return claims +} + +func getGJSONBool(body string, path string) (bool, bool) { + path = strings.TrimSpace(path) + if path == "" { + return false, false + } + res := gjson.Get(body, path) + if !res.Exists() { + return false, false + } + return res.Bool(), true +} + +func buildOIDCAuthorizeURL(cfg config.OIDCConnectConfig, state, nonce, codeChallenge, redirectURI string) (string, error) { + u, err := url.Parse(cfg.AuthorizeURL) + if err != nil { + return "", fmt.Errorf("parse authorize_url: %w", err) + } + + q := u.Query() + q.Set("response_type", "code") + q.Set("client_id", cfg.ClientID) + q.Set("redirect_uri", redirectURI) + if strings.TrimSpace(cfg.Scopes) != "" { + q.Set("scope", cfg.Scopes) + } + q.Set("state", state) + if strings.TrimSpace(nonce) != "" { + q.Set("nonce", nonce) + } + if cfg.UsePKCE { + q.Set("code_challenge", codeChallenge) + q.Set("code_challenge_method", "S256") + } + + u.RawQuery = q.Encode() + return u.String(), nil +} + +func oidcParseAndValidateIDToken(ctx context.Context, cfg config.OIDCConnectConfig, idToken string, expectedNonce string) (*oidcIDTokenClaims, error) { + idToken = strings.TrimSpace(idToken) + if idToken == "" { + return nil, errors.New("missing id_token") + } + allowed := oidcAllowedSigningAlgs(cfg.AllowedSigningAlgs) + if len(allowed) == 0 { + return nil, errors.New("empty allowed signing algorithms") + } + + jwks, err := oidcFetchJWKSet(ctx, cfg.JWKSURL) + if err != nil { + return nil, err + } + leeway := time.Duration(cfg.ClockSkewSeconds) * time.Second + claims := &oidcIDTokenClaims{} + + parsed, err := jwt.ParseWithClaims( + idToken, + claims, + func(token *jwt.Token) (any, error) { + alg := strings.TrimSpace(token.Method.Alg()) + if !containsString(allowed, alg) { + return nil, fmt.Errorf("unexpected signing algorithm: %s", alg) + } + kid, _ := token.Header["kid"].(string) + return oidcFindPublicKey(jwks, strings.TrimSpace(kid), alg) + }, + jwt.WithValidMethods(allowed), + jwt.WithAudience(cfg.ClientID), + jwt.WithIssuer(cfg.IssuerURL), + jwt.WithLeeway(leeway), + ) + if err != nil { + return nil, err + } + if !parsed.Valid { + return nil, errors.New("id_token invalid") + } + if strings.TrimSpace(claims.Subject) == "" { + return nil, errors.New("id_token missing sub") + } + if expectedNonce != "" && strings.TrimSpace(claims.Nonce) != strings.TrimSpace(expectedNonce) { + return nil, errors.New("id_token nonce mismatch") + } + if len(claims.Audience) > 1 { + if strings.TrimSpace(claims.Azp) == "" || strings.TrimSpace(claims.Azp) != strings.TrimSpace(cfg.ClientID) { + return nil, errors.New("id_token azp mismatch") + } + } + return claims, nil +} + +func oidcAllowedSigningAlgs(raw string) []string { + if strings.TrimSpace(raw) == "" { + return []string{"RS256", "ES256", "PS256"} + } + seen := make(map[string]struct{}) + out := make([]string, 0, 4) + for _, part := range strings.Split(raw, ",") { + alg := strings.ToUpper(strings.TrimSpace(part)) + if alg == "" { + continue + } + if _, ok := seen[alg]; ok { + continue + } + seen[alg] = struct{}{} + out = append(out, alg) + } + return out +} + +func oidcFetchJWKSet(ctx context.Context, jwksURL string) (*oidcJWKSet, error) { + jwksURL = strings.TrimSpace(jwksURL) + if jwksURL == "" { + return nil, errors.New("missing jwks_url") + } + resp, err := req.C(). + SetTimeout(30*time.Second). + R(). + SetContext(ctx). + SetHeader("Accept", "application/json"). + Get(jwksURL) + if err != nil { + return nil, fmt.Errorf("request jwks: %w", err) + } + if !resp.IsSuccessState() { + return nil, fmt.Errorf("jwks status=%d", resp.StatusCode) + } + set := &oidcJWKSet{} + if err := json.Unmarshal(resp.Bytes(), set); err != nil { + return nil, fmt.Errorf("parse jwks: %w", err) + } + if len(set.Keys) == 0 { + return nil, errors.New("jwks empty keys") + } + return set, nil +} + +func oidcFindPublicKey(set *oidcJWKSet, kid, alg string) (any, error) { + if set == nil { + return nil, errors.New("jwks not loaded") + } + alg = strings.ToUpper(strings.TrimSpace(alg)) + kid = strings.TrimSpace(kid) + + var lastErr error + for i := range set.Keys { + k := set.Keys[i] + if strings.TrimSpace(k.Use) != "" && !strings.EqualFold(strings.TrimSpace(k.Use), "sig") { + continue + } + if kid != "" && strings.TrimSpace(k.Kid) != kid { + continue + } + if strings.TrimSpace(k.Alg) != "" && !strings.EqualFold(strings.TrimSpace(k.Alg), alg) { + continue + } + pk, err := k.publicKey() + if err != nil { + lastErr = err + continue + } + if pk != nil { + return pk, nil + } + } + if lastErr != nil { + return nil, lastErr + } + if kid != "" { + return nil, fmt.Errorf("jwk not found for kid=%s", kid) + } + return nil, errors.New("jwk not found") +} + +func (k oidcJWK) publicKey() (any, error) { + switch strings.ToUpper(strings.TrimSpace(k.Kty)) { + case "RSA": + n, err := decodeBase64URLBigInt(k.N) + if err != nil { + return nil, fmt.Errorf("decode rsa n: %w", err) + } + eBytes, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(k.E)) + if err != nil { + return nil, fmt.Errorf("decode rsa e: %w", err) + } + if len(eBytes) == 0 { + return nil, errors.New("empty rsa e") + } + e := 0 + for _, b := range eBytes { + e = (e << 8) | int(b) + } + if e <= 0 { + return nil, errors.New("invalid rsa exponent") + } + if n.Sign() <= 0 { + return nil, errors.New("invalid rsa modulus") + } + return &rsa.PublicKey{N: n, E: e}, nil + case "EC": + var curve elliptic.Curve + switch strings.TrimSpace(k.Crv) { + case "P-256": + curve = elliptic.P256() + case "P-384": + curve = elliptic.P384() + case "P-521": + curve = elliptic.P521() + default: + return nil, fmt.Errorf("unsupported ec curve: %s", k.Crv) + } + x, err := decodeBase64URLBigInt(k.X) + if err != nil { + return nil, fmt.Errorf("decode ec x: %w", err) + } + y, err := decodeBase64URLBigInt(k.Y) + if err != nil { + return nil, fmt.Errorf("decode ec y: %w", err) + } + if !curve.IsOnCurve(x, y) { + return nil, errors.New("ec point is not on curve") + } + return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil + default: + return nil, fmt.Errorf("unsupported jwk kty: %s", k.Kty) + } +} + +func decodeBase64URLBigInt(raw string) (*big.Int, error) { + buf, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw)) + if err != nil { + return nil, err + } + if len(buf) == 0 { + return nil, errors.New("empty value") + } + return new(big.Int).SetBytes(buf), nil +} + +func containsString(values []string, target string) bool { + target = strings.TrimSpace(target) + for _, v := range values { + if strings.EqualFold(strings.TrimSpace(v), target) { + return true + } + } + return false +} + +func oidcIdentityKey(issuer, subject string) string { + issuer = strings.TrimSpace(strings.ToLower(issuer)) + subject = strings.TrimSpace(subject) + return issuer + "\x1f" + subject +} + +func oidcSyntheticEmailFromIdentityKey(identityKey string) string { + identityKey = strings.TrimSpace(identityKey) + if identityKey == "" { + return "" + } + sum := sha256.Sum256([]byte(identityKey)) + return "oidc-" + hex.EncodeToString(sum[:16]) + service.OIDCConnectSyntheticEmailDomain +} + +func oidcFallbackUsername(subject string) string { + subject = strings.TrimSpace(subject) + if subject == "" { + return "oidc_user" + } + sum := sha256.Sum256([]byte(subject)) + return "oidc_" + hex.EncodeToString(sum[:])[:12] +} + +func oidcSetCookie(c *gin.Context, name, value string, maxAgeSec int, secure bool) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: name, + Value: value, + Path: oidcOAuthCookiePath, + MaxAge: maxAgeSec, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} + +func oidcClearCookie(c *gin.Context, name string, secure bool) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: name, + Value: "", + Path: oidcOAuthCookiePath, + MaxAge: -1, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} diff --git a/backend/internal/handler/auth_oidc_oauth_test.go b/backend/internal/handler/auth_oidc_oauth_test.go new file mode 100644 index 0000000000..1f50dd49e8 --- /dev/null +++ b/backend/internal/handler/auth_oidc_oauth_test.go @@ -0,0 +1,106 @@ +package handler + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" +) + +func TestOIDCSyntheticEmailStableAndDistinct(t *testing.T) { + k1 := oidcIdentityKey("https://issuer.example.com", "subject-a") + k2 := oidcIdentityKey("https://issuer.example.com", "subject-b") + + e1 := oidcSyntheticEmailFromIdentityKey(k1) + e1Again := oidcSyntheticEmailFromIdentityKey(k1) + e2 := oidcSyntheticEmailFromIdentityKey(k2) + + require.Equal(t, e1, e1Again) + require.NotEqual(t, e1, e2) + require.Contains(t, e1, "@oidc-connect.invalid") +} + +func TestBuildOIDCAuthorizeURLIncludesNonceAndPKCE(t *testing.T) { + cfg := config.OIDCConnectConfig{ + AuthorizeURL: "https://issuer.example.com/auth", + ClientID: "cid", + Scopes: "openid email profile", + UsePKCE: true, + } + + u, err := buildOIDCAuthorizeURL(cfg, "state123", "nonce123", "challenge123", "https://app.example.com/callback") + require.NoError(t, err) + require.Contains(t, u, "nonce=nonce123") + require.Contains(t, u, "code_challenge=challenge123") + require.Contains(t, u, "code_challenge_method=S256") + require.Contains(t, u, "scope=openid+email+profile") +} + +func TestOIDCParseAndValidateIDToken(t *testing.T) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + kid := "kid-1" + jwks := oidcJWKSet{Keys: []oidcJWK{buildRSAJWK(kid, &priv.PublicKey)}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewEncoder(w).Encode(jwks)) + })) + defer srv.Close() + + now := time.Now() + claims := oidcIDTokenClaims{ + Nonce: "nonce-ok", + Azp: "client-1", + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "https://issuer.example.com", + Subject: "subject-1", + Audience: jwt.ClaimStrings{"client-1", "another-aud"}, + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now.Add(-30 * time.Second)), + ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)), + }, + } + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + tok.Header["kid"] = kid + signed, err := tok.SignedString(priv) + require.NoError(t, err) + + cfg := config.OIDCConnectConfig{ + ClientID: "client-1", + IssuerURL: "https://issuer.example.com", + JWKSURL: srv.URL, + AllowedSigningAlgs: "RS256", + ClockSkewSeconds: 120, + } + + parsed, err := oidcParseAndValidateIDToken(context.Background(), cfg, signed, "nonce-ok") + require.NoError(t, err) + require.Equal(t, "subject-1", parsed.Subject) + require.Equal(t, "https://issuer.example.com", parsed.Issuer) + + _, err = oidcParseAndValidateIDToken(context.Background(), cfg, signed, "bad-nonce") + require.Error(t, err) +} + +func buildRSAJWK(kid string, pub *rsa.PublicKey) oidcJWK { + n := base64.RawURLEncoding.EncodeToString(pub.N.Bytes()) + e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()) + return oidcJWK{ + Kty: "RSA", + Kid: kid, + Use: "sig", + Alg: "RS256", + N: n, + E: e, + } +} diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 73707f79a5..c8fc3b5d6f 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -51,6 +51,29 @@ type SystemSettings struct { LinuxDoConnectClientSecretConfigured bool `json:"linuxdo_connect_client_secret_configured"` LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"` + OIDCConnectEnabled bool `json:"oidc_connect_enabled"` + OIDCConnectProviderName string `json:"oidc_connect_provider_name"` + OIDCConnectClientID string `json:"oidc_connect_client_id"` + OIDCConnectClientSecretConfigured bool `json:"oidc_connect_client_secret_configured"` + OIDCConnectIssuerURL string `json:"oidc_connect_issuer_url"` + OIDCConnectDiscoveryURL string `json:"oidc_connect_discovery_url"` + OIDCConnectAuthorizeURL string `json:"oidc_connect_authorize_url"` + OIDCConnectTokenURL string `json:"oidc_connect_token_url"` + OIDCConnectUserInfoURL string `json:"oidc_connect_userinfo_url"` + OIDCConnectJWKSURL string `json:"oidc_connect_jwks_url"` + OIDCConnectScopes string `json:"oidc_connect_scopes"` + OIDCConnectRedirectURL string `json:"oidc_connect_redirect_url"` + OIDCConnectFrontendRedirectURL string `json:"oidc_connect_frontend_redirect_url"` + OIDCConnectTokenAuthMethod string `json:"oidc_connect_token_auth_method"` + OIDCConnectUsePKCE bool `json:"oidc_connect_use_pkce"` + OIDCConnectValidateIDToken bool `json:"oidc_connect_validate_id_token"` + OIDCConnectAllowedSigningAlgs string `json:"oidc_connect_allowed_signing_algs"` + OIDCConnectClockSkewSeconds int `json:"oidc_connect_clock_skew_seconds"` + OIDCConnectRequireEmailVerified bool `json:"oidc_connect_require_email_verified"` + OIDCConnectUserInfoEmailPath string `json:"oidc_connect_userinfo_email_path"` + OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"` + OIDCConnectUserInfoUsernamePath string `json:"oidc_connect_userinfo_username_path"` + SiteName string `json:"site_name"` SiteLogo string `json:"site_logo"` SiteSubtitle string `json:"site_subtitle"` @@ -128,6 +151,9 @@ type PublicSettings struct { CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` + OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` + SoraClientEnabled bool `json:"sora_client_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"` Version string `json:"version"` } diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 977c2301bb..8b53687766 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -54,6 +54,9 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems), CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints), LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, + OIDCOAuthEnabled: settings.OIDCOAuthEnabled, + OIDCOAuthProviderName: settings.OIDCOAuthProviderName, + SoraClientEnabled: settings.SoraClientEnabled, BackendModeEnabled: settings.BackendModeEnabled, Version: h.version, }) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 24f60f2705..fee879a46f 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -462,6 +462,28 @@ func TestAPIContracts(t *testing.T) { service.SettingKeyTurnstileSiteKey: "site-key", service.SettingKeyTurnstileSecretKey: "secret-key", + service.SettingKeyOIDCConnectEnabled: "false", + service.SettingKeyOIDCConnectProviderName: "OIDC", + service.SettingKeyOIDCConnectClientID: "", + service.SettingKeyOIDCConnectIssuerURL: "", + service.SettingKeyOIDCConnectDiscoveryURL: "", + service.SettingKeyOIDCConnectAuthorizeURL: "", + service.SettingKeyOIDCConnectTokenURL: "", + service.SettingKeyOIDCConnectUserInfoURL: "", + service.SettingKeyOIDCConnectJWKSURL: "", + service.SettingKeyOIDCConnectScopes: "openid email profile", + service.SettingKeyOIDCConnectRedirectURL: "", + service.SettingKeyOIDCConnectFrontendRedirectURL: "/auth/oidc/callback", + service.SettingKeyOIDCConnectTokenAuthMethod: "client_secret_post", + service.SettingKeyOIDCConnectUsePKCE: "false", + service.SettingKeyOIDCConnectValidateIDToken: "true", + service.SettingKeyOIDCConnectAllowedSigningAlgs: "RS256,ES256,PS256", + service.SettingKeyOIDCConnectClockSkewSeconds: "120", + service.SettingKeyOIDCConnectRequireEmailVerified: "false", + service.SettingKeyOIDCConnectUserInfoEmailPath: "", + service.SettingKeyOIDCConnectUserInfoIDPath: "", + service.SettingKeyOIDCConnectUserInfoUsernamePath: "", + service.SettingKeySiteName: "Sub2API", service.SettingKeySiteLogo: "", service.SettingKeySiteSubtitle: "Subtitle", @@ -503,10 +525,32 @@ func TestAPIContracts(t *testing.T) { "turnstile_enabled": true, "turnstile_site_key": "site-key", "turnstile_secret_key_configured": true, - "linuxdo_connect_enabled": false, + "linuxdo_connect_enabled": false, "linuxdo_connect_client_id": "", "linuxdo_connect_client_secret_configured": false, "linuxdo_connect_redirect_url": "", + "oidc_connect_enabled": false, + "oidc_connect_provider_name": "OIDC", + "oidc_connect_client_id": "", + "oidc_connect_client_secret_configured": false, + "oidc_connect_issuer_url": "", + "oidc_connect_discovery_url": "", + "oidc_connect_authorize_url": "", + "oidc_connect_token_url": "", + "oidc_connect_userinfo_url": "", + "oidc_connect_jwks_url": "", + "oidc_connect_scopes": "openid email profile", + "oidc_connect_redirect_url": "", + "oidc_connect_frontend_redirect_url": "/auth/oidc/callback", + "oidc_connect_token_auth_method": "client_secret_post", + "oidc_connect_use_pkce": false, + "oidc_connect_validate_id_token": true, + "oidc_connect_allowed_signing_algs": "RS256,ES256,PS256", + "oidc_connect_clock_skew_seconds": 120, + "oidc_connect_require_email_verified": false, + "oidc_connect_userinfo_email_path": "", + "oidc_connect_userinfo_id_path": "", + "oidc_connect_userinfo_username_path": "", "ops_monitoring_enabled": false, "ops_realtime_monitoring_enabled": true, "ops_query_mode_default": "auto", diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index a6c0ecf568..c143b030fc 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -70,6 +70,14 @@ func RegisterAuthRoutes( }), h.Auth.CompleteLinuxDoOAuthRegistration, ) + auth.GET("/oauth/oidc/start", h.Auth.OIDCOAuthStart) + auth.GET("/oauth/oidc/callback", h.Auth.OIDCOAuthCallback) + auth.POST("/oauth/oidc/complete-registration", + rateLimiter.LimitWithOptions("oauth-oidc-complete", 10, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), + h.Auth.CompleteOIDCOAuthRegistration, + ) } // 公开设置(无需认证) diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 6e524fb91c..fd28cd4235 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -833,7 +833,8 @@ func randomHexString(byteLength int) (string, error) { func isReservedEmail(email string) bool { normalized := strings.ToLower(strings.TrimSpace(email)) - return strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain) + return strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain) || + strings.HasSuffix(normalized, OIDCConnectSyntheticEmailDomain) } // GenerateToken 生成JWT access token diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 92be3e06cf..e194f9214d 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -71,6 +71,9 @@ const ( // LinuxDoConnectSyntheticEmailDomain 是 LinuxDo Connect 用户的合成邮箱后缀(RFC 保留域名)。 const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid" +// OIDCConnectSyntheticEmailDomain 是 OIDC 用户的合成邮箱后缀(RFC 保留域名)。 +const OIDCConnectSyntheticEmailDomain = "@oidc-connect.invalid" + // Setting keys const ( // 注册设置 @@ -105,6 +108,30 @@ const ( SettingKeyLinuxDoConnectClientSecret = "linuxdo_connect_client_secret" SettingKeyLinuxDoConnectRedirectURL = "linuxdo_connect_redirect_url" + // Generic OIDC OAuth 登录设置 + SettingKeyOIDCConnectEnabled = "oidc_connect_enabled" + SettingKeyOIDCConnectProviderName = "oidc_connect_provider_name" + SettingKeyOIDCConnectClientID = "oidc_connect_client_id" + SettingKeyOIDCConnectClientSecret = "oidc_connect_client_secret" + SettingKeyOIDCConnectIssuerURL = "oidc_connect_issuer_url" + SettingKeyOIDCConnectDiscoveryURL = "oidc_connect_discovery_url" + SettingKeyOIDCConnectAuthorizeURL = "oidc_connect_authorize_url" + SettingKeyOIDCConnectTokenURL = "oidc_connect_token_url" + SettingKeyOIDCConnectUserInfoURL = "oidc_connect_userinfo_url" + SettingKeyOIDCConnectJWKSURL = "oidc_connect_jwks_url" + SettingKeyOIDCConnectScopes = "oidc_connect_scopes" + SettingKeyOIDCConnectRedirectURL = "oidc_connect_redirect_url" + SettingKeyOIDCConnectFrontendRedirectURL = "oidc_connect_frontend_redirect_url" + SettingKeyOIDCConnectTokenAuthMethod = "oidc_connect_token_auth_method" + SettingKeyOIDCConnectUsePKCE = "oidc_connect_use_pkce" + SettingKeyOIDCConnectValidateIDToken = "oidc_connect_validate_id_token" + SettingKeyOIDCConnectAllowedSigningAlgs = "oidc_connect_allowed_signing_algs" + SettingKeyOIDCConnectClockSkewSeconds = "oidc_connect_clock_skew_seconds" + SettingKeyOIDCConnectRequireEmailVerified = "oidc_connect_require_email_verified" + SettingKeyOIDCConnectUserInfoEmailPath = "oidc_connect_userinfo_email_path" + SettingKeyOIDCConnectUserInfoIDPath = "oidc_connect_userinfo_id_path" + SettingKeyOIDCConnectUserInfoUsernamePath = "oidc_connect_userinfo_username_path" + // OEM设置 SettingKeySiteName = "site_name" // 网站名称 SettingKeySiteLogo = "site_logo" // 网站Logo (base64) diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 7d0ef5bdd9..37677fa5d1 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -16,6 +16,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/imroc/req/v3" "golang.org/x/sync/singleflight" ) @@ -164,6 +165,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyCustomEndpoints, SettingKeyLinuxDoConnectEnabled, SettingKeyBackendModeEnabled, + SettingKeyOIDCConnectEnabled, + SettingKeyOIDCConnectProviderName, } settings, err := s.settingRepo.GetMultiple(ctx, keys) @@ -177,6 +180,19 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings } else { linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled } + oidcEnabled := false + if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { + oidcEnabled = raw == "true" + } else { + oidcEnabled = s.cfg != nil && s.cfg.OIDC.Enabled + } + oidcProviderName := strings.TrimSpace(settings[SettingKeyOIDCConnectProviderName]) + if oidcProviderName == "" && s.cfg != nil { + oidcProviderName = strings.TrimSpace(s.cfg.OIDC.ProviderName) + } + if oidcProviderName == "" { + oidcProviderName = "OIDC" + } // Password reset requires email verification to be enabled emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" @@ -209,6 +225,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings CustomEndpoints: settings[SettingKeyCustomEndpoints], LinuxDoOAuthEnabled: linuxDoEnabled, BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", + OIDCOAuthEnabled: oidcEnabled, + OIDCOAuthProviderName: oidcProviderName, }, nil } @@ -256,6 +274,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any CustomEndpoints json.RawMessage `json:"custom_endpoints"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"` + OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` + OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` Version string `json:"version,omitempty"` }{ RegistrationEnabled: settings.RegistrationEnabled, @@ -281,6 +301,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints), LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, BackendModeEnabled: settings.BackendModeEnabled, + OIDCOAuthEnabled: settings.OIDCOAuthEnabled, + OIDCOAuthProviderName: settings.OIDCOAuthProviderName, Version: s.version, }, nil } @@ -460,6 +482,32 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet updates[SettingKeyLinuxDoConnectClientSecret] = settings.LinuxDoConnectClientSecret } + // Generic OIDC OAuth 登录 + updates[SettingKeyOIDCConnectEnabled] = strconv.FormatBool(settings.OIDCConnectEnabled) + updates[SettingKeyOIDCConnectProviderName] = settings.OIDCConnectProviderName + updates[SettingKeyOIDCConnectClientID] = settings.OIDCConnectClientID + updates[SettingKeyOIDCConnectIssuerURL] = settings.OIDCConnectIssuerURL + updates[SettingKeyOIDCConnectDiscoveryURL] = settings.OIDCConnectDiscoveryURL + updates[SettingKeyOIDCConnectAuthorizeURL] = settings.OIDCConnectAuthorizeURL + updates[SettingKeyOIDCConnectTokenURL] = settings.OIDCConnectTokenURL + updates[SettingKeyOIDCConnectUserInfoURL] = settings.OIDCConnectUserInfoURL + updates[SettingKeyOIDCConnectJWKSURL] = settings.OIDCConnectJWKSURL + updates[SettingKeyOIDCConnectScopes] = settings.OIDCConnectScopes + updates[SettingKeyOIDCConnectRedirectURL] = settings.OIDCConnectRedirectURL + updates[SettingKeyOIDCConnectFrontendRedirectURL] = settings.OIDCConnectFrontendRedirectURL + updates[SettingKeyOIDCConnectTokenAuthMethod] = settings.OIDCConnectTokenAuthMethod + updates[SettingKeyOIDCConnectUsePKCE] = strconv.FormatBool(settings.OIDCConnectUsePKCE) + updates[SettingKeyOIDCConnectValidateIDToken] = strconv.FormatBool(settings.OIDCConnectValidateIDToken) + updates[SettingKeyOIDCConnectAllowedSigningAlgs] = settings.OIDCConnectAllowedSigningAlgs + updates[SettingKeyOIDCConnectClockSkewSeconds] = strconv.Itoa(settings.OIDCConnectClockSkewSeconds) + updates[SettingKeyOIDCConnectRequireEmailVerified] = strconv.FormatBool(settings.OIDCConnectRequireEmailVerified) + updates[SettingKeyOIDCConnectUserInfoEmailPath] = settings.OIDCConnectUserInfoEmailPath + updates[SettingKeyOIDCConnectUserInfoIDPath] = settings.OIDCConnectUserInfoIDPath + updates[SettingKeyOIDCConnectUserInfoUsernamePath] = settings.OIDCConnectUserInfoUsernamePath + if settings.OIDCConnectClientSecret != "" { + updates[SettingKeyOIDCConnectClientSecret] = settings.OIDCConnectClientSecret + } + // OEM设置 updates[SettingKeySiteName] = settings.SiteName updates[SettingKeySiteLogo] = settings.SiteLogo @@ -826,6 +874,8 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyPurchaseSubscriptionURL: "", SettingKeyCustomMenuItems: "[]", SettingKeyCustomEndpoints: "[]", + SettingKeyOIDCConnectEnabled: "false", + SettingKeyOIDCConnectProviderName: "OIDC", SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), SettingKeyDefaultSubscriptions: "[]", @@ -951,6 +1001,138 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } result.LinuxDoConnectClientSecretConfigured = result.LinuxDoConnectClientSecret != "" + // Generic OIDC 设置: + // - 兼容 config.yaml/env + // - 支持后台系统设置覆盖并持久化(存储于 DB) + oidcBase := config.OIDCConnectConfig{} + if s.cfg != nil { + oidcBase = s.cfg.OIDC + } + + if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { + result.OIDCConnectEnabled = raw == "true" + } else { + result.OIDCConnectEnabled = oidcBase.Enabled + } + + if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectProviderName = strings.TrimSpace(v) + } else { + result.OIDCConnectProviderName = strings.TrimSpace(oidcBase.ProviderName) + } + if result.OIDCConnectProviderName == "" { + result.OIDCConnectProviderName = "OIDC" + } + + if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectClientID = strings.TrimSpace(v) + } else { + result.OIDCConnectClientID = strings.TrimSpace(oidcBase.ClientID) + } + if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectIssuerURL = strings.TrimSpace(v) + } else { + result.OIDCConnectIssuerURL = strings.TrimSpace(oidcBase.IssuerURL) + } + if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectDiscoveryURL = strings.TrimSpace(v) + } else { + result.OIDCConnectDiscoveryURL = strings.TrimSpace(oidcBase.DiscoveryURL) + } + if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectAuthorizeURL = strings.TrimSpace(v) + } else { + result.OIDCConnectAuthorizeURL = strings.TrimSpace(oidcBase.AuthorizeURL) + } + if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectTokenURL = strings.TrimSpace(v) + } else { + result.OIDCConnectTokenURL = strings.TrimSpace(oidcBase.TokenURL) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectUserInfoURL = strings.TrimSpace(v) + } else { + result.OIDCConnectUserInfoURL = strings.TrimSpace(oidcBase.UserInfoURL) + } + if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectJWKSURL = strings.TrimSpace(v) + } else { + result.OIDCConnectJWKSURL = strings.TrimSpace(oidcBase.JWKSURL) + } + if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectScopes = strings.TrimSpace(v) + } else { + result.OIDCConnectScopes = strings.TrimSpace(oidcBase.Scopes) + } + if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectRedirectURL = strings.TrimSpace(v) + } else { + result.OIDCConnectRedirectURL = strings.TrimSpace(oidcBase.RedirectURL) + } + if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectFrontendRedirectURL = strings.TrimSpace(v) + } else { + result.OIDCConnectFrontendRedirectURL = strings.TrimSpace(oidcBase.FrontendRedirectURL) + } + if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(v)) + } else { + result.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(oidcBase.TokenAuthMethod)) + } + if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok { + result.OIDCConnectUsePKCE = raw == "true" + } else { + result.OIDCConnectUsePKCE = oidcBase.UsePKCE + } + if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok { + result.OIDCConnectValidateIDToken = raw == "true" + } else { + result.OIDCConnectValidateIDToken = oidcBase.ValidateIDToken + } + if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" { + result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(v) + } else { + result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(oidcBase.AllowedSigningAlgs) + } + clockSkewSet := false + if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" { + if parsed, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil { + result.OIDCConnectClockSkewSeconds = parsed + clockSkewSet = true + } + } + if !clockSkewSet { + result.OIDCConnectClockSkewSeconds = oidcBase.ClockSkewSeconds + } + if !clockSkewSet && result.OIDCConnectClockSkewSeconds == 0 { + result.OIDCConnectClockSkewSeconds = 120 + } + if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok { + result.OIDCConnectRequireEmailVerified = raw == "true" + } else { + result.OIDCConnectRequireEmailVerified = oidcBase.RequireEmailVerified + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok { + result.OIDCConnectUserInfoEmailPath = strings.TrimSpace(v) + } else { + result.OIDCConnectUserInfoEmailPath = strings.TrimSpace(oidcBase.UserInfoEmailPath) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok { + result.OIDCConnectUserInfoIDPath = strings.TrimSpace(v) + } else { + result.OIDCConnectUserInfoIDPath = strings.TrimSpace(oidcBase.UserInfoIDPath) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok { + result.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(v) + } else { + result.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(oidcBase.UserInfoUsernamePath) + } + result.OIDCConnectClientSecret = strings.TrimSpace(settings[SettingKeyOIDCConnectClientSecret]) + if result.OIDCConnectClientSecret == "" { + result.OIDCConnectClientSecret = strings.TrimSpace(oidcBase.ClientSecret) + } + result.OIDCConnectClientSecretConfigured = result.OIDCConnectClientSecret != "" + // Model fallback settings result.EnableModelFallback = settings[SettingKeyEnableModelFallback] == "true" result.FallbackModelAnthropic = s.getStringOrDefault(settings, SettingKeyFallbackModelAnthropic, "claude-3-5-sonnet-20241022") @@ -1323,6 +1505,282 @@ func (s *SettingService) SetOverloadCooldownSettings(ctx context.Context, settin return s.settingRepo.Set(ctx, SettingKeyOverloadCooldownSettings, string(data)) } +// GetOIDCConnectOAuthConfig 返回用于登录的“最终生效” OIDC 配置。 +// +// 优先级: +// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值 +// - 否则回退到 config.yaml/env 的值 +func (s *SettingService) GetOIDCConnectOAuthConfig(ctx context.Context) (config.OIDCConnectConfig, error) { + if s == nil || s.cfg == nil { + return config.OIDCConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") + } + + effective := s.cfg.OIDC + + keys := []string{ + SettingKeyOIDCConnectEnabled, + SettingKeyOIDCConnectProviderName, + SettingKeyOIDCConnectClientID, + SettingKeyOIDCConnectClientSecret, + SettingKeyOIDCConnectIssuerURL, + SettingKeyOIDCConnectDiscoveryURL, + SettingKeyOIDCConnectAuthorizeURL, + SettingKeyOIDCConnectTokenURL, + SettingKeyOIDCConnectUserInfoURL, + SettingKeyOIDCConnectJWKSURL, + SettingKeyOIDCConnectScopes, + SettingKeyOIDCConnectRedirectURL, + SettingKeyOIDCConnectFrontendRedirectURL, + SettingKeyOIDCConnectTokenAuthMethod, + SettingKeyOIDCConnectUsePKCE, + SettingKeyOIDCConnectValidateIDToken, + SettingKeyOIDCConnectAllowedSigningAlgs, + SettingKeyOIDCConnectClockSkewSeconds, + SettingKeyOIDCConnectRequireEmailVerified, + SettingKeyOIDCConnectUserInfoEmailPath, + SettingKeyOIDCConnectUserInfoIDPath, + SettingKeyOIDCConnectUserInfoUsernamePath, + } + settings, err := s.settingRepo.GetMultiple(ctx, keys) + if err != nil { + return config.OIDCConnectConfig{}, fmt.Errorf("get oidc connect settings: %w", err) + } + + if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { + effective.Enabled = raw == "true" + } + if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" { + effective.ProviderName = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" { + effective.ClientID = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectClientSecret]; ok && strings.TrimSpace(v) != "" { + effective.ClientSecret = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" { + effective.IssuerURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" { + effective.DiscoveryURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" { + effective.AuthorizeURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" { + effective.TokenURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" { + effective.UserInfoURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" { + effective.JWKSURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" { + effective.Scopes = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { + effective.RedirectURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" { + effective.FrontendRedirectURL = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" { + effective.TokenAuthMethod = strings.ToLower(strings.TrimSpace(v)) + } + if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok { + effective.UsePKCE = raw == "true" + } + if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok { + effective.ValidateIDToken = raw == "true" + } + if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" { + effective.AllowedSigningAlgs = strings.TrimSpace(v) + } + if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" { + if parsed, parseErr := strconv.Atoi(strings.TrimSpace(raw)); parseErr == nil { + effective.ClockSkewSeconds = parsed + } + } + if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok { + effective.RequireEmailVerified = raw == "true" + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok { + effective.UserInfoEmailPath = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok { + effective.UserInfoIDPath = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok { + effective.UserInfoUsernamePath = strings.TrimSpace(v) + } + + if !effective.Enabled { + return config.OIDCConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") + } + if strings.TrimSpace(effective.ProviderName) == "" { + effective.ProviderName = "OIDC" + } + if strings.TrimSpace(effective.ClientID) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") + } + if strings.TrimSpace(effective.IssuerURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url not configured") + } + if strings.TrimSpace(effective.RedirectURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured") + } + if strings.TrimSpace(effective.FrontendRedirectURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured") + } + if !scopesContainOpenID(effective.Scopes) { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth scopes must contain openid") + } + if effective.ClockSkewSeconds < 0 || effective.ClockSkewSeconds > 600 { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth clock skew must be between 0 and 600") + } + + if err := config.ValidateAbsoluteHTTPURL(effective.IssuerURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url invalid") + } + + discoveryURL := strings.TrimSpace(effective.DiscoveryURL) + if discoveryURL == "" { + discoveryURL = oidcDefaultDiscoveryURL(effective.IssuerURL) + effective.DiscoveryURL = discoveryURL + } + if discoveryURL != "" { + if err := config.ValidateAbsoluteHTTPURL(discoveryURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery url invalid") + } + } + + needsDiscovery := strings.TrimSpace(effective.AuthorizeURL) == "" || + strings.TrimSpace(effective.TokenURL) == "" || + (effective.ValidateIDToken && strings.TrimSpace(effective.JWKSURL) == "") + if needsDiscovery && discoveryURL != "" { + metadata, resolveErr := oidcResolveProviderMetadata(ctx, discoveryURL) + if resolveErr != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery resolve failed").WithCause(resolveErr) + } + if strings.TrimSpace(effective.AuthorizeURL) == "" { + effective.AuthorizeURL = strings.TrimSpace(metadata.AuthorizationEndpoint) + } + if strings.TrimSpace(effective.TokenURL) == "" { + effective.TokenURL = strings.TrimSpace(metadata.TokenEndpoint) + } + if strings.TrimSpace(effective.UserInfoURL) == "" { + effective.UserInfoURL = strings.TrimSpace(metadata.UserInfoEndpoint) + } + if strings.TrimSpace(effective.JWKSURL) == "" { + effective.JWKSURL = strings.TrimSpace(metadata.JWKSURI) + } + } + + if strings.TrimSpace(effective.AuthorizeURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured") + } + if strings.TrimSpace(effective.TokenURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured") + } + if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid") + } + if v := strings.TrimSpace(effective.UserInfoURL); v != "" { + if err := config.ValidateAbsoluteHTTPURL(v); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid") + } + } + if effective.ValidateIDToken { + if strings.TrimSpace(effective.JWKSURL) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url not configured") + } + if strings.TrimSpace(effective.AllowedSigningAlgs) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth signing algs not configured") + } + } + if v := strings.TrimSpace(effective.JWKSURL); v != "" { + if err := config.ValidateAbsoluteHTTPURL(v); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url invalid") + } + } + if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid") + } + if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid") + } + + method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod)) + switch method { + case "", "client_secret_post", "client_secret_basic": + if strings.TrimSpace(effective.ClientSecret) == "" { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured") + } + case "none": + if !effective.UsePKCE { + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth pkce must be enabled when token_auth_method=none") + } + default: + return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid") + } + + return effective, nil +} + +func scopesContainOpenID(scopes string) bool { + for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) { + if scope == "openid" { + return true + } + } + return false +} + +type oidcProviderMetadata struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + JWKSURI string `json:"jwks_uri"` +} + +func oidcDefaultDiscoveryURL(issuerURL string) string { + issuerURL = strings.TrimSpace(issuerURL) + if issuerURL == "" { + return "" + } + return strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration" +} + +func oidcResolveProviderMetadata(ctx context.Context, discoveryURL string) (*oidcProviderMetadata, error) { + discoveryURL = strings.TrimSpace(discoveryURL) + if discoveryURL == "" { + return nil, fmt.Errorf("discovery url is empty") + } + + resp, err := req.C(). + SetTimeout(15*time.Second). + R(). + SetContext(ctx). + SetHeader("Accept", "application/json"). + Get(discoveryURL) + if err != nil { + return nil, fmt.Errorf("request discovery document: %w", err) + } + if !resp.IsSuccessState() { + return nil, fmt.Errorf("discovery request failed: status=%d", resp.StatusCode) + } + + metadata := &oidcProviderMetadata{} + if err := json.Unmarshal(resp.Bytes(), metadata); err != nil { + return nil, fmt.Errorf("parse discovery document: %w", err) + } + return metadata, nil +} + // GetStreamTimeoutSettings 获取流超时处理配置 func (s *SettingService) GetStreamTimeoutSettings(ctx context.Context) (*StreamTimeoutSettings, error) { value, err := s.settingRepo.GetValue(ctx, SettingKeyStreamTimeoutSettings) diff --git a/backend/internal/service/setting_service_oidc_config_test.go b/backend/internal/service/setting_service_oidc_config_test.go new file mode 100644 index 0000000000..3809b332bd --- /dev/null +++ b/backend/internal/service/setting_service_oidc_config_test.go @@ -0,0 +1,103 @@ +//go:build unit + +package service + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type settingOIDCRepoStub struct { + values map[string]string +} + +func (s *settingOIDCRepoStub) Get(ctx context.Context, key string) (*Setting, error) { + panic("unexpected Get call") +} + +func (s *settingOIDCRepoStub) GetValue(ctx context.Context, key string) (string, error) { + panic("unexpected GetValue call") +} + +func (s *settingOIDCRepoStub) Set(ctx context.Context, key, value string) error { + panic("unexpected Set call") +} + +func (s *settingOIDCRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + out := make(map[string]string, len(keys)) + for _, key := range keys { + if value, ok := s.values[key]; ok { + out[key] = value + } + } + return out, nil +} + +func (s *settingOIDCRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error { + panic("unexpected SetMultiple call") +} + +func (s *settingOIDCRepoStub) GetAll(ctx context.Context) (map[string]string, error) { + panic("unexpected GetAll call") +} + +func (s *settingOIDCRepoStub) Delete(ctx context.Context, key string) error { + panic("unexpected Delete call") +} + +func TestGetOIDCConnectOAuthConfig_ResolvesEndpointsFromIssuerDiscovery(t *testing.T) { + var discoveryHits int + var baseURL string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/issuer/.well-known/openid-configuration" { + http.NotFound(w, r) + return + } + discoveryHits++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(fmt.Sprintf(`{ + "authorization_endpoint":"%s/issuer/protocol/openid-connect/auth", + "token_endpoint":"%s/issuer/protocol/openid-connect/token", + "userinfo_endpoint":"%s/issuer/protocol/openid-connect/userinfo", + "jwks_uri":"%s/issuer/protocol/openid-connect/certs" + }`, baseURL, baseURL, baseURL, baseURL))) + })) + defer srv.Close() + baseURL = srv.URL + + cfg := &config.Config{ + OIDC: config.OIDCConnectConfig{ + Enabled: true, + ProviderName: "OIDC", + ClientID: "oidc-client", + ClientSecret: "oidc-secret", + IssuerURL: srv.URL + "/issuer", + RedirectURL: "https://example.com/api/v1/auth/oauth/oidc/callback", + FrontendRedirectURL: "/auth/oidc/callback", + Scopes: "openid email profile", + TokenAuthMethod: "client_secret_post", + ValidateIDToken: true, + AllowedSigningAlgs: "RS256", + ClockSkewSeconds: 120, + }, + } + + repo := &settingOIDCRepoStub{values: map[string]string{}} + svc := NewSettingService(repo, cfg) + + got, err := svc.GetOIDCConnectOAuthConfig(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, discoveryHits) + require.Equal(t, srv.URL+"/issuer/.well-known/openid-configuration", got.DiscoveryURL) + require.Equal(t, srv.URL+"/issuer/protocol/openid-connect/auth", got.AuthorizeURL) + require.Equal(t, srv.URL+"/issuer/protocol/openid-connect/token", got.TokenURL) + require.Equal(t, srv.URL+"/issuer/protocol/openid-connect/userinfo", got.UserInfoURL) + require.Equal(t, srv.URL+"/issuer/protocol/openid-connect/certs", got.JWKSURL) +} diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index fedb3f2fe1..80932e9d5d 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -31,6 +31,31 @@ type SystemSettings struct { LinuxDoConnectClientSecretConfigured bool LinuxDoConnectRedirectURL string + // Generic OIDC OAuth 登录 + OIDCConnectEnabled bool + OIDCConnectProviderName string + OIDCConnectClientID string + OIDCConnectClientSecret string + OIDCConnectClientSecretConfigured bool + OIDCConnectIssuerURL string + OIDCConnectDiscoveryURL string + OIDCConnectAuthorizeURL string + OIDCConnectTokenURL string + OIDCConnectUserInfoURL string + OIDCConnectJWKSURL string + OIDCConnectScopes string + OIDCConnectRedirectURL string + OIDCConnectFrontendRedirectURL string + OIDCConnectTokenAuthMethod string + OIDCConnectUsePKCE bool + OIDCConnectValidateIDToken bool + OIDCConnectAllowedSigningAlgs string + OIDCConnectClockSkewSeconds int + OIDCConnectRequireEmailVerified bool + OIDCConnectUserInfoEmailPath string + OIDCConnectUserInfoIDPath string + OIDCConnectUserInfoUsernamePath string + SiteName string SiteLogo string SiteSubtitle string @@ -110,9 +135,11 @@ type PublicSettings struct { CustomMenuItems string // JSON array of custom menu items CustomEndpoints string // JSON array of custom endpoints - LinuxDoOAuthEnabled bool - BackendModeEnabled bool - Version string + LinuxDoOAuthEnabled bool + BackendModeEnabled bool + OIDCOAuthEnabled bool + OIDCOAuthProviderName string + Version string } // StreamTimeoutSettings 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制) diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 8f60acd5ed..cd6e7e3f33 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -820,6 +820,46 @@ linuxdo_connect: userinfo_id_path: "" userinfo_username_path: "" +# ============================================================================= +# Generic OIDC OAuth Login (SSO) +# 通用 OIDC OAuth 登录(用于 Sub2API 用户登录) +# ============================================================================= +oidc_connect: + enabled: false + provider_name: "OIDC" + client_id: "" + client_secret: "" + # 例如: "https://keycloak.example.com/realms/myrealm" + issuer_url: "" + # 可选: OIDC Discovery URL。为空时可手动填写 authorize/token/userinfo/jwks + discovery_url: "" + authorize_url: "" + token_url: "" + # 可选(仅补充 email/username,不用于 sub 可信绑定) + userinfo_url: "" + # validate_id_token=true 时必填 + jwks_url: "" + scopes: "openid email profile" + # 示例: "https://your-domain.com/api/v1/auth/oauth/oidc/callback" + redirect_url: "" + # 安全提示: + # - 建议使用同源相对路径(以 / 开头),避免把 token 重定向到意外的第三方域名 + # - 该地址不应包含 #fragment(本实现使用 URL fragment 传递 access_token) + frontend_redirect_url: "/auth/oidc/callback" + token_auth_method: "client_secret_post" # client_secret_post | client_secret_basic | none + # 注意:当 token_auth_method=none(public client)时,必须启用 PKCE + use_pkce: false + # 开启后强制校验 id_token 的签名和 claims(推荐) + validate_id_token: true + allowed_signing_algs: "RS256,ES256,PS256" + # 允许的时钟偏移(秒) + clock_skew_seconds: 120 + # 若 Provider 返回 email_verified=false,是否拒绝登录 + require_email_verified: false + userinfo_email_path: "" + userinfo_id_path: "" + userinfo_username_path: "" + # ============================================================================= # Default Settings # 默认设置 diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index b7ee6be5ce..9916f1ab62 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -62,6 +62,30 @@ export interface SystemSettings { linuxdo_connect_client_secret_configured: boolean linuxdo_connect_redirect_url: string + // Generic OIDC OAuth settings + oidc_connect_enabled: boolean + oidc_connect_provider_name: string + oidc_connect_client_id: string + oidc_connect_client_secret_configured: boolean + oidc_connect_issuer_url: string + oidc_connect_discovery_url: string + oidc_connect_authorize_url: string + oidc_connect_token_url: string + oidc_connect_userinfo_url: string + oidc_connect_jwks_url: string + oidc_connect_scopes: string + oidc_connect_redirect_url: string + oidc_connect_frontend_redirect_url: string + oidc_connect_token_auth_method: string + oidc_connect_use_pkce: boolean + oidc_connect_validate_id_token: boolean + oidc_connect_allowed_signing_algs: string + oidc_connect_clock_skew_seconds: number + oidc_connect_require_email_verified: boolean + oidc_connect_userinfo_email_path: string + oidc_connect_userinfo_id_path: string + oidc_connect_userinfo_username_path: string + // Model fallback configuration enable_model_fallback: boolean fallback_model_anthropic: string @@ -131,6 +155,28 @@ export interface UpdateSettingsRequest { linuxdo_connect_client_id?: string linuxdo_connect_client_secret?: string linuxdo_connect_redirect_url?: string + oidc_connect_enabled?: boolean + oidc_connect_provider_name?: string + oidc_connect_client_id?: string + oidc_connect_client_secret?: string + oidc_connect_issuer_url?: string + oidc_connect_discovery_url?: string + oidc_connect_authorize_url?: string + oidc_connect_token_url?: string + oidc_connect_userinfo_url?: string + oidc_connect_jwks_url?: string + oidc_connect_scopes?: string + oidc_connect_redirect_url?: string + oidc_connect_frontend_redirect_url?: string + oidc_connect_token_auth_method?: string + oidc_connect_use_pkce?: boolean + oidc_connect_validate_id_token?: boolean + oidc_connect_allowed_signing_algs?: string + oidc_connect_clock_skew_seconds?: number + oidc_connect_require_email_verified?: boolean + oidc_connect_userinfo_email_path?: string + oidc_connect_userinfo_id_path?: string + oidc_connect_userinfo_username_path?: string enable_model_fallback?: boolean fallback_model_anthropic?: string fallback_model_openai?: string diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index c5e1f35daf..837c4f4cf7 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -357,6 +357,28 @@ export async function completeLinuxDoOAuthRegistration( return data } +/** + * Complete OIDC OAuth registration by supplying an invitation code + * @param pendingOAuthToken - Short-lived JWT from the OAuth callback + * @param invitationCode - Invitation code entered by the user + * @returns Token pair on success + */ +export async function completeOIDCOAuthRegistration( + pendingOAuthToken: string, + invitationCode: string +): Promise<{ access_token: string; refresh_token: string; expires_in: number; token_type: string }> { + const { data } = await apiClient.post<{ + access_token: string + refresh_token: string + expires_in: number + token_type: string + }>('/auth/oauth/oidc/complete-registration', { + pending_oauth_token: pendingOAuthToken, + invitation_code: invitationCode + }) + return data +} + export const authAPI = { login, login2FA, @@ -380,7 +402,8 @@ export const authAPI = { resetPassword, refreshToken, revokeAllSessions, - completeLinuxDoOAuthRegistration + completeLinuxDoOAuthRegistration, + completeOIDCOAuthRegistration } export default authAPI diff --git a/frontend/src/components/auth/LinuxDoOAuthSection.vue b/frontend/src/components/auth/LinuxDoOAuthSection.vue index 8012b1011d..c740d06fa3 100644 --- a/frontend/src/components/auth/LinuxDoOAuthSection.vue +++ b/frontend/src/components/auth/LinuxDoOAuthSection.vue @@ -29,10 +29,10 @@ {{ t('auth.linuxdo.signIn') }} -

+
- {{ t('auth.linuxdo.orContinue') }} + {{ t('auth.oauthOrContinue') }}
@@ -43,9 +43,12 @@ import { useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' -defineProps<{ +withDefaults(defineProps<{ disabled?: boolean -}>() + showDivider?: boolean +}>(), { + showDivider: true +}) const route = useRoute() const { t } = useI18n() @@ -58,4 +61,3 @@ function startLogin(): void { window.location.href = startURL } - diff --git a/frontend/src/components/auth/OidcOAuthSection.vue b/frontend/src/components/auth/OidcOAuthSection.vue new file mode 100644 index 0000000000..f7cc7fa30f --- /dev/null +++ b/frontend/src/components/auth/OidcOAuthSection.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index d3b16d4ad6..475d8f33cc 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -428,6 +428,7 @@ export default { invitationCodeInvalid: 'Invalid or used invitation code', invitationCodeValidating: 'Validating invitation code...', invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again', + oauthOrContinue: 'or continue with email', linuxdo: { signIn: 'Continue with Linux.do', orContinue: 'or continue with email', @@ -442,6 +443,20 @@ export default { completing: 'Completing registration…', completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.' }, + oidc: { + signIn: 'Continue with {providerName}', + callbackTitle: 'Signing you in with {providerName}', + callbackProcessing: 'Completing login with {providerName}, please wait...', + callbackHint: 'If you are not redirected automatically, go back to the login page and try again.', + callbackMissingToken: 'Missing login token, please try again.', + backToLogin: 'Back to Login', + invitationRequired: + 'This {providerName} account is not yet registered. The site requires an invitation code — please enter one to complete registration.', + invalidPendingToken: 'The registration token has expired. Please sign in again.', + completeRegistration: 'Complete Registration', + completing: 'Completing registration…', + completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.' + }, oauth: { code: 'Code', state: 'State', @@ -4227,6 +4242,57 @@ export default { quickSetCopy: 'Generate & Copy (current site)', redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard' }, + oidc: { + title: 'OIDC Login', + description: 'Configure a standard OIDC provider (for example Keycloak)', + enable: 'Enable OIDC Login', + enableHint: 'Show OIDC login on the login/register pages', + providerName: 'Provider Name', + providerNamePlaceholder: 'for example Keycloak', + clientId: 'Client ID', + clientIdPlaceholder: 'OIDC client id', + clientSecret: 'Client Secret', + clientSecretPlaceholder: '********', + clientSecretHint: 'Used by backend to exchange tokens (keep it secret)', + clientSecretConfiguredPlaceholder: '********', + clientSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.', + issuerUrl: 'Issuer URL', + issuerUrlPlaceholder: 'https://id.example.com/realms/main', + discoveryUrl: 'Discovery URL', + discoveryUrlPlaceholder: 'Optional, leave empty to auto-derive from issuer', + authorizeUrl: 'Authorize URL', + authorizeUrlPlaceholder: 'Optional, can be discovered automatically', + tokenUrl: 'Token URL', + tokenUrlPlaceholder: 'Optional, can be discovered automatically', + userinfoUrl: 'UserInfo URL', + userinfoUrlPlaceholder: 'Optional, can be discovered automatically', + jwksUrl: 'JWKS URL', + jwksUrlPlaceholder: 'Optional, required when strict ID token validation is enabled', + scopes: 'Scopes', + scopesPlaceholder: 'openid email profile', + scopesHint: 'Must include openid', + redirectUrl: 'Backend Redirect URL', + redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/oidc/callback', + redirectUrlHint: 'Must match the callback URL configured in the OIDC provider', + quickSetCopy: 'Generate & Copy (current site)', + redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard', + frontendRedirectUrl: 'Frontend Callback Path', + frontendRedirectUrlPlaceholder: '/auth/oidc/callback', + frontendRedirectUrlHint: 'Frontend route used after backend callback', + tokenAuthMethod: 'Token Auth Method', + clockSkewSeconds: 'Clock Skew (seconds)', + allowedSigningAlgs: 'Allowed Signing Algs', + allowedSigningAlgsPlaceholder: 'RS256,ES256,PS256', + usePkce: 'Use PKCE', + validateIdToken: 'Validate ID Token', + requireEmailVerified: 'Require Email Verified', + userinfoEmailPath: 'UserInfo Email Path', + userinfoEmailPathPlaceholder: 'for example data.email', + userinfoIdPath: 'UserInfo ID Path', + userinfoIdPathPlaceholder: 'for example data.id', + userinfoUsernamePath: 'UserInfo Username Path', + userinfoUsernamePathPlaceholder: 'for example data.username' + }, defaults: { title: 'Default User Settings', description: 'Default values for new users', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index fcaaf5abd2..eee2725c27 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -427,6 +427,7 @@ export default { invitationCodeInvalid: '邀请码无效或已被使用', invitationCodeValidating: '正在验证邀请码...', invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试', + oauthOrContinue: '或使用邮箱密码继续', linuxdo: { signIn: '使用 Linux.do 登录', orContinue: '或使用邮箱密码继续', @@ -441,6 +442,19 @@ export default { completing: '正在完成注册...', completeRegistrationFailed: '注册失败,请检查邀请码后重试。' }, + oidc: { + signIn: '使用 {providerName} 登录', + callbackTitle: '正在完成 {providerName} 登录', + callbackProcessing: '正在验证 {providerName} 登录信息,请稍候...', + callbackHint: '如果页面未自动跳转,请返回登录页重试。', + callbackMissingToken: '登录信息缺失,请返回重试。', + backToLogin: '返回登录', + invitationRequired: '该 {providerName} 账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。', + invalidPendingToken: '注册凭证已失效,请重新登录。', + completeRegistration: '完成注册', + completing: '正在完成注册...', + completeRegistrationFailed: '注册失败,请检查邀请码后重试。' + }, oauth: { code: '授权码', state: '状态', @@ -4393,6 +4407,57 @@ export default { quickSetCopy: '使用当前站点生成并复制', redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板' }, + oidc: { + title: 'OIDC 登录', + description: '配置标准 OIDC Provider(例如 Keycloak)', + enable: '启用 OIDC 登录', + enableHint: '在登录/注册页面显示 OIDC 登录入口', + providerName: 'Provider 名称', + providerNamePlaceholder: '例如 Keycloak', + clientId: 'Client ID', + clientIdPlaceholder: 'OIDC client id', + clientSecret: 'Client Secret', + clientSecretPlaceholder: '********', + clientSecretHint: '用于后端交换 token(请保密)', + clientSecretConfiguredPlaceholder: '********', + clientSecretConfiguredHint: '密钥已配置,留空以保留当前值。', + issuerUrl: 'Issuer URL', + issuerUrlPlaceholder: 'https://id.example.com/realms/main', + discoveryUrl: 'Discovery URL', + discoveryUrlPlaceholder: '可选,留空将基于 issuer 自动推导', + authorizeUrl: 'Authorize URL', + authorizeUrlPlaceholder: '可选,可通过 discovery 自动获取', + tokenUrl: 'Token URL', + tokenUrlPlaceholder: '可选,可通过 discovery 自动获取', + userinfoUrl: 'UserInfo URL', + userinfoUrlPlaceholder: '可选,可通过 discovery 自动获取', + jwksUrl: 'JWKS URL', + jwksUrlPlaceholder: '可选;启用严格 ID Token 校验时必填', + scopes: 'Scopes', + scopesPlaceholder: 'openid email profile', + scopesHint: '必须包含 openid', + redirectUrl: '后端回调地址(Redirect URL)', + redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/oidc/callback', + redirectUrlHint: '必须与 OIDC Provider 中配置的回调地址一致', + quickSetCopy: '使用当前站点生成并复制', + redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板', + frontendRedirectUrl: '前端回调路径', + frontendRedirectUrlPlaceholder: '/auth/oidc/callback', + frontendRedirectUrlHint: '后端回调完成后重定向到此前端路径', + tokenAuthMethod: 'Token 鉴权方式', + clockSkewSeconds: '时钟偏移(秒)', + allowedSigningAlgs: '允许的签名算法', + allowedSigningAlgsPlaceholder: 'RS256,ES256,PS256', + usePkce: '启用 PKCE', + validateIdToken: '校验 ID Token', + requireEmailVerified: '要求邮箱已验证', + userinfoEmailPath: 'UserInfo 邮箱字段路径', + userinfoEmailPathPlaceholder: '例如 data.email', + userinfoIdPath: 'UserInfo ID 字段路径', + userinfoIdPathPlaceholder: '例如 data.id', + userinfoUsernamePath: 'UserInfo 用户名字段路径', + userinfoUsernamePathPlaceholder: '例如 data.username' + }, defaults: { title: '用户默认设置', description: '新用户的默认值', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 6faf6f598f..9bc6115f6c 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -83,6 +83,15 @@ const routes: RouteRecordRaw[] = [ title: 'LinuxDo OAuth Callback' } }, + { + path: '/auth/oidc/callback', + name: 'OIDCOAuthCallback', + component: () => import('@/views/auth/OidcCallbackView.vue'), + meta: { + requiresAuth: false, + title: 'OIDC OAuth Callback' + } + }, { path: '/forgot-password', name: 'ForgotPassword', diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 24057136b3..edeb3bb14a 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -332,6 +332,9 @@ export const useAppStore = defineStore('app', () => { custom_menu_items: [], custom_endpoints: [], linuxdo_oauth_enabled: false, + oidc_oauth_enabled: false, + oidc_oauth_provider_name: 'OIDC', + sora_client_enabled: false, backend_mode_enabled: false, version: siteVersion.value } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 580126c894..b4f8f79adf 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -109,6 +109,9 @@ export interface PublicSettings { custom_menu_items: CustomMenuItem[] custom_endpoints: CustomEndpoint[] linuxdo_oauth_enabled: boolean + oidc_oauth_enabled: boolean + oidc_oauth_provider_name: string + sora_client_enabled: boolean backend_mode_enabled: boolean version: string } diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index f43140abc1..dd9342358e 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1124,7 +1124,327 @@
- + + +
+
+

+ {{ t('admin.settings.oidc.title') }} +

+

+ {{ t('admin.settings.oidc.description') }} +

+
+
+
+
+ +

+ {{ t('admin.settings.oidc.enableHint') }} +

+
+ +
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +

+ {{ + form.oidc_connect_client_secret_configured + ? t('admin.settings.oidc.clientSecretConfiguredHint') + : t('admin.settings.oidc.clientSecretHint') + }} +

+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + +

+ {{ t('admin.settings.oidc.scopesHint') }} +

+
+ +
+ + +
+ + + {{ oidcRedirectUrlSuggestion }} + +
+

+ {{ t('admin.settings.oidc.redirectUrlHint') }} +

+
+ +
+ + +

+ {{ t('admin.settings.oidc.frontendRedirectUrlHint') }} +

+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+
@@ -2193,6 +2513,7 @@ type SettingsForm = SystemSettings & { smtp_password: string turnstile_secret_key: string linuxdo_connect_client_secret: string + oidc_connect_client_secret: string } const form = reactive({ @@ -2240,6 +2561,30 @@ const form = reactive({ linuxdo_connect_client_secret: '', linuxdo_connect_client_secret_configured: false, linuxdo_connect_redirect_url: '', + // Generic OIDC OAuth 登录 + oidc_connect_enabled: false, + oidc_connect_provider_name: 'OIDC', + oidc_connect_client_id: '', + oidc_connect_client_secret: '', + oidc_connect_client_secret_configured: false, + oidc_connect_issuer_url: '', + oidc_connect_discovery_url: '', + oidc_connect_authorize_url: '', + oidc_connect_token_url: '', + oidc_connect_userinfo_url: '', + oidc_connect_jwks_url: '', + oidc_connect_scopes: 'openid email profile', + oidc_connect_redirect_url: '', + oidc_connect_frontend_redirect_url: '/auth/oidc/callback', + oidc_connect_token_auth_method: 'client_secret_post', + oidc_connect_use_pkce: false, + oidc_connect_validate_id_token: true, + oidc_connect_allowed_signing_algs: 'RS256,ES256,PS256', + oidc_connect_clock_skew_seconds: 120, + oidc_connect_require_email_verified: false, + oidc_connect_userinfo_email_path: '', + oidc_connect_userinfo_id_path: '', + oidc_connect_userinfo_username_path: '', // Model fallback enable_model_fallback: false, fallback_model_anthropic: 'claude-3-5-sonnet-20241022', @@ -2360,6 +2705,21 @@ async function setAndCopyLinuxdoRedirectUrl() { await copyToClipboard(url, t('admin.settings.linuxdo.redirectUrlSetAndCopied')) } +const oidcRedirectUrlSuggestion = computed(() => { + if (typeof window === 'undefined') return '' + const origin = + window.location.origin || `${window.location.protocol}//${window.location.host}` + return `${origin}/api/v1/auth/oauth/oidc/callback` +}) + +async function setAndCopyOIDCRedirectUrl() { + const url = oidcRedirectUrlSuggestion.value + if (!url) return + + form.oidc_connect_redirect_url = url + await copyToClipboard(url, t('admin.settings.oidc.redirectUrlSetAndCopied')) +} + // Custom menu item management function addMenuItem() { form.custom_menu_items.push({ @@ -2425,6 +2785,7 @@ async function loadSettings() { smtpPasswordManuallyEdited.value = false form.turnstile_secret_key = '' form.linuxdo_connect_client_secret = '' + form.oidc_connect_client_secret = '' } catch (error: any) { loadFailed.value = true appStore.showError( @@ -2559,6 +2920,28 @@ async function saveSettings() { linuxdo_connect_client_id: form.linuxdo_connect_client_id, linuxdo_connect_client_secret: form.linuxdo_connect_client_secret || undefined, linuxdo_connect_redirect_url: form.linuxdo_connect_redirect_url, + oidc_connect_enabled: form.oidc_connect_enabled, + oidc_connect_provider_name: form.oidc_connect_provider_name, + oidc_connect_client_id: form.oidc_connect_client_id, + oidc_connect_client_secret: form.oidc_connect_client_secret || undefined, + oidc_connect_issuer_url: form.oidc_connect_issuer_url, + oidc_connect_discovery_url: form.oidc_connect_discovery_url, + oidc_connect_authorize_url: form.oidc_connect_authorize_url, + oidc_connect_token_url: form.oidc_connect_token_url, + oidc_connect_userinfo_url: form.oidc_connect_userinfo_url, + oidc_connect_jwks_url: form.oidc_connect_jwks_url, + oidc_connect_scopes: form.oidc_connect_scopes, + oidc_connect_redirect_url: form.oidc_connect_redirect_url, + oidc_connect_frontend_redirect_url: form.oidc_connect_frontend_redirect_url, + oidc_connect_token_auth_method: form.oidc_connect_token_auth_method, + oidc_connect_use_pkce: form.oidc_connect_use_pkce, + oidc_connect_validate_id_token: form.oidc_connect_validate_id_token, + oidc_connect_allowed_signing_algs: form.oidc_connect_allowed_signing_algs, + oidc_connect_clock_skew_seconds: form.oidc_connect_clock_skew_seconds, + oidc_connect_require_email_verified: form.oidc_connect_require_email_verified, + oidc_connect_userinfo_email_path: form.oidc_connect_userinfo_email_path, + oidc_connect_userinfo_id_path: form.oidc_connect_userinfo_id_path, + oidc_connect_userinfo_username_path: form.oidc_connect_userinfo_username_path, enable_model_fallback: form.enable_model_fallback, fallback_model_anthropic: form.fallback_model_anthropic, fallback_model_openai: form.fallback_model_openai, @@ -2583,6 +2966,7 @@ async function saveSettings() { smtpPasswordManuallyEdited.value = false form.turnstile_secret_key = '' form.linuxdo_connect_client_secret = '' + form.oidc_connect_client_secret = '' // Refresh cached settings so sidebar/header update immediately await appStore.fetchPublicSettings(true) await adminSettingsStore.fetch(true) diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index 73d2474cf0..70b64e3ff5 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -11,8 +11,26 @@

- - +
+ + +
+
+ + {{ t('auth.oauthOrContinue') }} + +
+
+
@@ -181,6 +199,7 @@ import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { AuthLayout } from '@/components/layout' import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue' +import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue' import TotpLoginModal from '@/components/auth/TotpLoginModal.vue' import Icon from '@/components/icons/Icon.vue' import TurnstileWidget from '@/components/TurnstileWidget.vue' @@ -207,6 +226,8 @@ const turnstileEnabled = ref(false) const turnstileSiteKey = ref('') const linuxdoOAuthEnabled = ref(false) const backendModeEnabled = ref(false) +const oidcOAuthEnabled = ref(false) +const oidcOAuthProviderName = ref('OIDC') const passwordResetEnabled = ref(false) // Turnstile @@ -247,6 +268,9 @@ onMounted(async () => { turnstileSiteKey.value = settings.turnstile_site_key || '' linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled backendModeEnabled.value = settings.backend_mode_enabled + oidcOAuthEnabled.value = settings.oidc_oauth_enabled + oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC' + backendModeEnabled.value = settings.backend_mode_enabled passwordResetEnabled.value = settings.password_reset_enabled } catch (error) { console.error('Failed to load public settings:', error) diff --git a/frontend/src/views/auth/OidcCallbackView.vue b/frontend/src/views/auth/OidcCallbackView.vue new file mode 100644 index 0000000000..a6cb6c12e4 --- /dev/null +++ b/frontend/src/views/auth/OidcCallbackView.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue index d1b576d469..bc8b8dce42 100644 --- a/frontend/src/views/auth/RegisterView.vue +++ b/frontend/src/views/auth/RegisterView.vue @@ -11,8 +11,26 @@

- - +
+ + +
+
+ + {{ t('auth.oauthOrContinue') }} + +
+
+
(false) const turnstileSiteKey = ref('') const siteName = ref('Sub2API') const linuxdoOAuthEnabled = ref(false) +const oidcOAuthEnabled = ref(false) +const oidcOAuthProviderName = ref('OIDC') const registrationEmailSuffixWhitelist = ref([]) // Turnstile @@ -376,6 +397,8 @@ onMounted(async () => { turnstileSiteKey.value = settings.turnstile_site_key || '' siteName.value = settings.site_name || 'Sub2API' linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled + oidcOAuthEnabled.value = settings.oidc_oauth_enabled + oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC' registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist( settings.registration_email_suffix_whitelist || [] ) From 8e1a7bdfff2663f50ebe46013ee241683aee282a Mon Sep 17 00:00:00 2001 From: Glorhop <1150595033@qq.com> Date: Sat, 14 Mar 2026 14:45:43 +0000 Subject: [PATCH 36/59] fix: fixed an issue where OIDC login consistently used a synthetic email address --- backend/internal/handler/auth_oidc_oauth.go | 10 +++++++++- backend/internal/handler/auth_oidc_oauth_test.go | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/backend/internal/handler/auth_oidc_oauth.go b/backend/internal/handler/auth_oidc_oauth.go index f46fb8507c..9d24df88ab 100644 --- a/backend/internal/handler/auth_oidc_oauth.go +++ b/backend/internal/handler/auth_oidc_oauth.go @@ -306,7 +306,7 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) { } identityKey := oidcIdentityKey(issuer, subject) - email := oidcSyntheticEmailFromIdentityKey(identityKey) + email := oidcSelectLoginEmail(userInfoClaims.Email, idClaims.Email, identityKey) username := firstNonEmpty( userInfoClaims.Username, idClaims.PreferredUsername, @@ -831,6 +831,14 @@ func oidcSyntheticEmailFromIdentityKey(identityKey string) string { return "oidc-" + hex.EncodeToString(sum[:16]) + service.OIDCConnectSyntheticEmailDomain } +func oidcSelectLoginEmail(userInfoEmail, idTokenEmail, identityKey string) string { + email := strings.TrimSpace(firstNonEmpty(userInfoEmail, idTokenEmail)) + if email != "" { + return email + } + return oidcSyntheticEmailFromIdentityKey(identityKey) +} + func oidcFallbackUsername(subject string) string { subject = strings.TrimSpace(subject) if subject == "" { diff --git a/backend/internal/handler/auth_oidc_oauth_test.go b/backend/internal/handler/auth_oidc_oauth_test.go index 1f50dd49e8..a161aa77cf 100644 --- a/backend/internal/handler/auth_oidc_oauth_test.go +++ b/backend/internal/handler/auth_oidc_oauth_test.go @@ -30,6 +30,20 @@ func TestOIDCSyntheticEmailStableAndDistinct(t *testing.T) { require.Contains(t, e1, "@oidc-connect.invalid") } +func TestOIDCSelectLoginEmailPrefersRealEmail(t *testing.T) { + identityKey := oidcIdentityKey("https://issuer.example.com", "subject-a") + + email := oidcSelectLoginEmail("user@example.com", "idtoken@example.com", identityKey) + require.Equal(t, "user@example.com", email) + + email = oidcSelectLoginEmail("", "idtoken@example.com", identityKey) + require.Equal(t, "idtoken@example.com", email) + + email = oidcSelectLoginEmail("", "", identityKey) + require.Contains(t, email, "@oidc-connect.invalid") + require.Equal(t, oidcSyntheticEmailFromIdentityKey(identityKey), email) +} + func TestBuildOIDCAuthorizeURLIncludesNonceAndPKCE(t *testing.T) { cfg := config.OIDCConnectConfig{ AuthorizeURL: "https://issuer.example.com/auth", From 311f06745a83d244f8226e947f9e5ef579ea7dd5 Mon Sep 17 00:00:00 2001 From: Glorhop <1150595033@qq.com> Date: Thu, 9 Apr 2026 02:57:00 +0000 Subject: [PATCH 37/59] chore: clean up deprecated Sora settings after rebase --- backend/internal/handler/setting_handler.go | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 8b53687766..1db104c1bd 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -56,7 +56,6 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, OIDCOAuthEnabled: settings.OIDCOAuthEnabled, OIDCOAuthProviderName: settings.OIDCOAuthProviderName, - SoraClientEnabled: settings.SoraClientEnabled, BackendModeEnabled: settings.BackendModeEnabled, Version: h.version, }) From 23c4d592f852ef862d4357d2823934b59233cec7 Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Thu, 9 Apr 2026 12:29:28 +0800 Subject: [PATCH 38/59] =?UTF-8?q?feat(group):=20=E5=A2=9E=E5=8A=A0messages?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E6=A8=A1=E5=9E=8B=E6=98=A0=E5=B0=84=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/ent/group.go | 16 ++- backend/ent/group/group.go | 6 + backend/ent/group_create.go | 66 +++++++++++ backend/ent/group_update.go | 35 ++++++ backend/ent/migrate/schema.go | 1 + backend/ent/mutation.go | 56 ++++++++- backend/ent/runtime/runtime.go | 5 + backend/ent/schema/group.go | 4 + .../domain/openai_messages_dispatch.go | 10 ++ .../internal/handler/admin/group_handler.go | 20 ++-- backend/internal/handler/dto/mappers.go | 21 ++-- backend/internal/handler/dto/types.go | 9 +- backend/internal/repository/group_repo.go | 6 +- backend/internal/service/admin_service.go | 24 ++-- .../service/admin_service_group_test.go | 110 ++++++++++++++++++ backend/internal/service/group.go | 13 ++- .../service/openai_messages_dispatch.go | 100 ++++++++++++++++ .../service/openai_messages_dispatch_test.go | 27 +++++ ...d_group_messages_dispatch_model_config.sql | 2 + 19 files changed, 495 insertions(+), 36 deletions(-) create mode 100644 backend/internal/domain/openai_messages_dispatch.go create mode 100644 backend/internal/service/openai_messages_dispatch.go create mode 100644 backend/internal/service/openai_messages_dispatch_test.go create mode 100644 backend/migrations/091_add_group_messages_dispatch_model_config.sql diff --git a/backend/ent/group.go b/backend/ent/group.go index b15ac15dd9..f10b50c325 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -11,6 +11,7 @@ import ( "entgo.io/ent" "entgo.io/ent/dialect/sql" "github.com/Wei-Shaw/sub2api/ent/group" + "github.com/Wei-Shaw/sub2api/internal/domain" ) // Group is the model entity for the Group schema. @@ -76,6 +77,8 @@ type Group struct { RequirePrivacySet bool `json:"require_privacy_set,omitempty"` // 默认映射模型 ID,当账号级映射找不到时使用此值 DefaultMappedModel string `json:"default_mapped_model,omitempty"` + // OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型 + MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"` // Edges holds the relations/edges for other nodes in the graph. // The values are being populated by the GroupQuery when eager-loading is set. Edges GroupEdges `json:"edges"` @@ -182,7 +185,7 @@ func (*Group) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case group.FieldModelRouting, group.FieldSupportedModelScopes: + case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig: values[i] = new([]byte) case group.FieldIsExclusive, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet: values[i] = new(sql.NullBool) @@ -403,6 +406,14 @@ func (_m *Group) assignValues(columns []string, values []any) error { } else if value.Valid { _m.DefaultMappedModel = value.String } + case group.FieldMessagesDispatchModelConfig: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field messages_dispatch_model_config", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.MessagesDispatchModelConfig); err != nil { + return fmt.Errorf("unmarshal field messages_dispatch_model_config: %w", err) + } + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -585,6 +596,9 @@ func (_m *Group) String() string { builder.WriteString(", ") builder.WriteString("default_mapped_model=") builder.WriteString(_m.DefaultMappedModel) + builder.WriteString(", ") + builder.WriteString("messages_dispatch_model_config=") + builder.WriteString(fmt.Sprintf("%v", _m.MessagesDispatchModelConfig)) builder.WriteByte(')') return builder.String() } diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 21a7c2cb76..b1371630dd 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -8,6 +8,7 @@ import ( "entgo.io/ent" "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" + "github.com/Wei-Shaw/sub2api/internal/domain" ) const ( @@ -73,6 +74,8 @@ const ( FieldRequirePrivacySet = "require_privacy_set" // FieldDefaultMappedModel holds the string denoting the default_mapped_model field in the database. FieldDefaultMappedModel = "default_mapped_model" + // FieldMessagesDispatchModelConfig holds the string denoting the messages_dispatch_model_config field in the database. + FieldMessagesDispatchModelConfig = "messages_dispatch_model_config" // EdgeAPIKeys holds the string denoting the api_keys edge name in mutations. EdgeAPIKeys = "api_keys" // EdgeRedeemCodes holds the string denoting the redeem_codes edge name in mutations. @@ -177,6 +180,7 @@ var Columns = []string{ FieldRequireOauthOnly, FieldRequirePrivacySet, FieldDefaultMappedModel, + FieldMessagesDispatchModelConfig, } var ( @@ -252,6 +256,8 @@ var ( DefaultDefaultMappedModel string // DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save. DefaultMappedModelValidator func(string) error + // DefaultMessagesDispatchModelConfig holds the default value on creation for the "messages_dispatch_model_config" field. + DefaultMessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig ) // OrderOption defines the ordering options for the Group queries. diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index a8c30b184d..f412fa4070 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -18,6 +18,7 @@ import ( "github.com/Wei-Shaw/sub2api/ent/usagelog" "github.com/Wei-Shaw/sub2api/ent/user" "github.com/Wei-Shaw/sub2api/ent/usersubscription" + "github.com/Wei-Shaw/sub2api/internal/domain" ) // GroupCreate is the builder for creating a Group entity. @@ -410,6 +411,20 @@ func (_c *GroupCreate) SetNillableDefaultMappedModel(v *string) *GroupCreate { return _c } +// SetMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field. +func (_c *GroupCreate) SetMessagesDispatchModelConfig(v domain.OpenAIMessagesDispatchModelConfig) *GroupCreate { + _c.mutation.SetMessagesDispatchModelConfig(v) + return _c +} + +// SetNillableMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field if the given value is not nil. +func (_c *GroupCreate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMessagesDispatchModelConfig) *GroupCreate { + if v != nil { + _c.SetMessagesDispatchModelConfig(*v) + } + return _c +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_c *GroupCreate) AddAPIKeyIDs(ids ...int64) *GroupCreate { _c.mutation.AddAPIKeyIDs(ids...) @@ -611,6 +626,10 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultDefaultMappedModel _c.mutation.SetDefaultMappedModel(v) } + if _, ok := _c.mutation.MessagesDispatchModelConfig(); !ok { + v := group.DefaultMessagesDispatchModelConfig + _c.mutation.SetMessagesDispatchModelConfig(v) + } return nil } @@ -695,6 +714,9 @@ func (_c *GroupCreate) check() error { return &ValidationError{Name: "default_mapped_model", err: fmt.Errorf(`ent: validator failed for field "Group.default_mapped_model": %w`, err)} } } + if _, ok := _c.mutation.MessagesDispatchModelConfig(); !ok { + return &ValidationError{Name: "messages_dispatch_model_config", err: errors.New(`ent: missing required field "Group.messages_dispatch_model_config"`)} + } return nil } @@ -838,6 +860,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldDefaultMappedModel, field.TypeString, value) _node.DefaultMappedModel = value } + if value, ok := _c.mutation.MessagesDispatchModelConfig(); ok { + _spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value) + _node.MessagesDispatchModelConfig = value + } if nodes := _c.mutation.APIKeysIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -1462,6 +1488,18 @@ func (u *GroupUpsert) UpdateDefaultMappedModel() *GroupUpsert { return u } +// SetMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field. +func (u *GroupUpsert) SetMessagesDispatchModelConfig(v domain.OpenAIMessagesDispatchModelConfig) *GroupUpsert { + u.Set(group.FieldMessagesDispatchModelConfig, v) + return u +} + +// UpdateMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field to the value that was provided on create. +func (u *GroupUpsert) UpdateMessagesDispatchModelConfig() *GroupUpsert { + u.SetExcluded(group.FieldMessagesDispatchModelConfig) + return u +} + // UpdateNewValues updates the mutable fields using the new values that were set on create. // Using this option is equivalent to using: // @@ -2053,6 +2091,20 @@ func (u *GroupUpsertOne) UpdateDefaultMappedModel() *GroupUpsertOne { }) } +// SetMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field. +func (u *GroupUpsertOne) SetMessagesDispatchModelConfig(v domain.OpenAIMessagesDispatchModelConfig) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetMessagesDispatchModelConfig(v) + }) +} + +// UpdateMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateMessagesDispatchModelConfig() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateMessagesDispatchModelConfig() + }) +} + // Exec executes the query. func (u *GroupUpsertOne) Exec(ctx context.Context) error { if len(u.create.conflict) == 0 { @@ -2810,6 +2862,20 @@ func (u *GroupUpsertBulk) UpdateDefaultMappedModel() *GroupUpsertBulk { }) } +// SetMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field. +func (u *GroupUpsertBulk) SetMessagesDispatchModelConfig(v domain.OpenAIMessagesDispatchModelConfig) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetMessagesDispatchModelConfig(v) + }) +} + +// UpdateMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateMessagesDispatchModelConfig() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateMessagesDispatchModelConfig() + }) +} + // Exec executes the query. func (u *GroupUpsertBulk) Exec(ctx context.Context) error { if u.create.err != nil { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index aa1a83d421..7b6d619325 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -20,6 +20,7 @@ import ( "github.com/Wei-Shaw/sub2api/ent/usagelog" "github.com/Wei-Shaw/sub2api/ent/user" "github.com/Wei-Shaw/sub2api/ent/usersubscription" + "github.com/Wei-Shaw/sub2api/internal/domain" ) // GroupUpdate is the builder for updating Group entities. @@ -552,6 +553,20 @@ func (_u *GroupUpdate) SetNillableDefaultMappedModel(v *string) *GroupUpdate { return _u } +// SetMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field. +func (_u *GroupUpdate) SetMessagesDispatchModelConfig(v domain.OpenAIMessagesDispatchModelConfig) *GroupUpdate { + _u.mutation.SetMessagesDispatchModelConfig(v) + return _u +} + +// SetNillableMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMessagesDispatchModelConfig) *GroupUpdate { + if v != nil { + _u.SetMessagesDispatchModelConfig(*v) + } + return _u +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *GroupUpdate) AddAPIKeyIDs(ids ...int64) *GroupUpdate { _u.mutation.AddAPIKeyIDs(ids...) @@ -1012,6 +1027,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.DefaultMappedModel(); ok { _spec.SetField(group.FieldDefaultMappedModel, field.TypeString, value) } + if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok { + _spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value) + } if _u.mutation.APIKeysCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -1843,6 +1861,20 @@ func (_u *GroupUpdateOne) SetNillableDefaultMappedModel(v *string) *GroupUpdateO return _u } +// SetMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field. +func (_u *GroupUpdateOne) SetMessagesDispatchModelConfig(v domain.OpenAIMessagesDispatchModelConfig) *GroupUpdateOne { + _u.mutation.SetMessagesDispatchModelConfig(v) + return _u +} + +// SetNillableMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMessagesDispatchModelConfig) *GroupUpdateOne { + if v != nil { + _u.SetMessagesDispatchModelConfig(*v) + } + return _u +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *GroupUpdateOne) AddAPIKeyIDs(ids ...int64) *GroupUpdateOne { _u.mutation.AddAPIKeyIDs(ids...) @@ -2333,6 +2365,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if value, ok := _u.mutation.DefaultMappedModel(); ok { _spec.SetField(group.FieldDefaultMappedModel, field.TypeString, value) } + if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok { + _spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value) + } if _u.mutation.APIKeysCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 5400bf9319..a7ae4af056 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -407,6 +407,7 @@ var ( {Name: "require_oauth_only", Type: field.TypeBool, Default: false}, {Name: "require_privacy_set", Type: field.TypeBool, Default: false}, {Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""}, + {Name: "messages_dispatch_model_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}}, } // GroupsTable holds the schema information for the "groups" table. GroupsTable = &schema.Table{ diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index d206039af4..594e5199d4 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -8246,6 +8246,7 @@ type GroupMutation struct { require_oauth_only *bool require_privacy_set *bool default_mapped_model *string + messages_dispatch_model_config *domain.OpenAIMessagesDispatchModelConfig clearedFields map[string]struct{} api_keys map[int64]struct{} removedapi_keys map[int64]struct{} @@ -9798,6 +9799,42 @@ func (m *GroupMutation) ResetDefaultMappedModel() { m.default_mapped_model = nil } +// SetMessagesDispatchModelConfig sets the "messages_dispatch_model_config" field. +func (m *GroupMutation) SetMessagesDispatchModelConfig(damdmc domain.OpenAIMessagesDispatchModelConfig) { + m.messages_dispatch_model_config = &damdmc +} + +// MessagesDispatchModelConfig returns the value of the "messages_dispatch_model_config" field in the mutation. +func (m *GroupMutation) MessagesDispatchModelConfig() (r domain.OpenAIMessagesDispatchModelConfig, exists bool) { + v := m.messages_dispatch_model_config + if v == nil { + return + } + return *v, true +} + +// OldMessagesDispatchModelConfig returns the old "messages_dispatch_model_config" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldMessagesDispatchModelConfig(ctx context.Context) (v domain.OpenAIMessagesDispatchModelConfig, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldMessagesDispatchModelConfig is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldMessagesDispatchModelConfig requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldMessagesDispatchModelConfig: %w", err) + } + return oldValue.MessagesDispatchModelConfig, nil +} + +// ResetMessagesDispatchModelConfig resets all changes to the "messages_dispatch_model_config" field. +func (m *GroupMutation) ResetMessagesDispatchModelConfig() { + m.messages_dispatch_model_config = nil +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by ids. func (m *GroupMutation) AddAPIKeyIDs(ids ...int64) { if m.api_keys == nil { @@ -10156,7 +10193,7 @@ func (m *GroupMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *GroupMutation) Fields() []string { - fields := make([]string, 0, 29) + fields := make([]string, 0, 30) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -10244,6 +10281,9 @@ func (m *GroupMutation) Fields() []string { if m.default_mapped_model != nil { fields = append(fields, group.FieldDefaultMappedModel) } + if m.messages_dispatch_model_config != nil { + fields = append(fields, group.FieldMessagesDispatchModelConfig) + } return fields } @@ -10310,6 +10350,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.RequirePrivacySet() case group.FieldDefaultMappedModel: return m.DefaultMappedModel() + case group.FieldMessagesDispatchModelConfig: + return m.MessagesDispatchModelConfig() } return nil, false } @@ -10377,6 +10419,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldRequirePrivacySet(ctx) case group.FieldDefaultMappedModel: return m.OldDefaultMappedModel(ctx) + case group.FieldMessagesDispatchModelConfig: + return m.OldMessagesDispatchModelConfig(ctx) } return nil, fmt.Errorf("unknown Group field %s", name) } @@ -10589,6 +10633,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetDefaultMappedModel(v) return nil + case group.FieldMessagesDispatchModelConfig: + v, ok := value.(domain.OpenAIMessagesDispatchModelConfig) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetMessagesDispatchModelConfig(v) + return nil } return fmt.Errorf("unknown Group field %s", name) } @@ -10929,6 +10980,9 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldDefaultMappedModel: m.ResetDefaultMappedModel() return nil + case group.FieldMessagesDispatchModelConfig: + m.ResetMessagesDispatchModelConfig() + return nil } return fmt.Errorf("unknown Group field %s", name) } diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 803b7bc24b..792f056656 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -28,6 +28,7 @@ import ( "github.com/Wei-Shaw/sub2api/ent/userattributedefinition" "github.com/Wei-Shaw/sub2api/ent/userattributevalue" "github.com/Wei-Shaw/sub2api/ent/usersubscription" + "github.com/Wei-Shaw/sub2api/internal/domain" ) // The init function reads all schema descriptors with runtime code @@ -468,6 +469,10 @@ func init() { group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string) // group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save. group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error) + // groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field. + groupDescMessagesDispatchModelConfig := groupFields[26].Descriptor() + // group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field. + group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() idempotencyrecordMixinFields0 := idempotencyrecordMixin[0].Fields() _ = idempotencyrecordMixinFields0 diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 0eb89c181b..d78a68986d 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -141,6 +141,10 @@ func (Group) Fields() []ent.Field { MaxLen(100). Default(""). Comment("默认映射模型 ID,当账号级映射找不到时使用此值"), + field.JSON("messages_dispatch_model_config", domain.OpenAIMessagesDispatchModelConfig{}). + Default(domain.OpenAIMessagesDispatchModelConfig{}). + SchemaType(map[string]string{dialect.Postgres: "jsonb"}). + Comment("OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型"), } } diff --git a/backend/internal/domain/openai_messages_dispatch.go b/backend/internal/domain/openai_messages_dispatch.go new file mode 100644 index 0000000000..6b018f1c30 --- /dev/null +++ b/backend/internal/domain/openai_messages_dispatch.go @@ -0,0 +1,10 @@ +package domain + +// OpenAIMessagesDispatchModelConfig controls how Anthropic /v1/messages +// requests are mapped onto OpenAI/Codex models. +type OpenAIMessagesDispatchModelConfig struct { + OpusMappedModel string `json:"opus_mapped_model,omitempty"` + SonnetMappedModel string `json:"sonnet_mapped_model,omitempty"` + HaikuMappedModel string `json:"haiku_mapped_model,omitempty"` + ExactModelMappings map[string]string `json:"exact_model_mappings,omitempty"` +} diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 458ed35d47..8b6b056d6a 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -105,10 +105,11 @@ type CreateGroupRequest struct { // 支持的模型系列(仅 antigravity 平台使用) SupportedModelScopes []string `json:"supported_model_scopes"` // OpenAI Messages 调度配置(仅 openai 平台使用) - AllowMessagesDispatch bool `json:"allow_messages_dispatch"` - RequireOAuthOnly bool `json:"require_oauth_only"` - RequirePrivacySet bool `json:"require_privacy_set"` - DefaultMappedModel string `json:"default_mapped_model"` + AllowMessagesDispatch bool `json:"allow_messages_dispatch"` + RequireOAuthOnly bool `json:"require_oauth_only"` + RequirePrivacySet bool `json:"require_privacy_set"` + DefaultMappedModel string `json:"default_mapped_model"` + MessagesDispatchModelConfig service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"` // 从指定分组复制账号(创建后自动绑定) CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"` } @@ -139,10 +140,11 @@ type UpdateGroupRequest struct { // 支持的模型系列(仅 antigravity 平台使用) SupportedModelScopes *[]string `json:"supported_model_scopes"` // OpenAI Messages 调度配置(仅 openai 平台使用) - AllowMessagesDispatch *bool `json:"allow_messages_dispatch"` - RequireOAuthOnly *bool `json:"require_oauth_only"` - RequirePrivacySet *bool `json:"require_privacy_set"` - DefaultMappedModel *string `json:"default_mapped_model"` + AllowMessagesDispatch *bool `json:"allow_messages_dispatch"` + RequireOAuthOnly *bool `json:"require_oauth_only"` + RequirePrivacySet *bool `json:"require_privacy_set"` + DefaultMappedModel *string `json:"default_mapped_model"` + MessagesDispatchModelConfig *service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"` // 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号) CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"` } @@ -257,6 +259,7 @@ func (h *GroupHandler) Create(c *gin.Context) { RequireOAuthOnly: req.RequireOAuthOnly, RequirePrivacySet: req.RequirePrivacySet, DefaultMappedModel: req.DefaultMappedModel, + MessagesDispatchModelConfig: req.MessagesDispatchModelConfig, CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs, }) if err != nil { @@ -307,6 +310,7 @@ func (h *GroupHandler) Update(c *gin.Context) { RequireOAuthOnly: req.RequireOAuthOnly, RequirePrivacySet: req.RequirePrivacySet, DefaultMappedModel: req.DefaultMappedModel, + MessagesDispatchModelConfig: req.MessagesDispatchModelConfig, CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs, }) if err != nil { diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 2eab670e75..478600eb8c 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -133,16 +133,17 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup { return nil } out := &AdminGroup{ - Group: groupFromServiceBase(g), - ModelRouting: g.ModelRouting, - ModelRoutingEnabled: g.ModelRoutingEnabled, - MCPXMLInject: g.MCPXMLInject, - DefaultMappedModel: g.DefaultMappedModel, - SupportedModelScopes: g.SupportedModelScopes, - AccountCount: g.AccountCount, - ActiveAccountCount: g.ActiveAccountCount, - RateLimitedAccountCount: g.RateLimitedAccountCount, - SortOrder: g.SortOrder, + Group: groupFromServiceBase(g), + ModelRouting: g.ModelRouting, + ModelRoutingEnabled: g.ModelRoutingEnabled, + MCPXMLInject: g.MCPXMLInject, + DefaultMappedModel: g.DefaultMappedModel, + MessagesDispatchModelConfig: g.MessagesDispatchModelConfig, + SupportedModelScopes: g.SupportedModelScopes, + AccountCount: g.AccountCount, + ActiveAccountCount: g.ActiveAccountCount, + RateLimitedAccountCount: g.RateLimitedAccountCount, + SortOrder: g.SortOrder, } if len(g.AccountGroups) > 0 { out.AccountGroups = make([]AccountGroup, 0, len(g.AccountGroups)) diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 82065deb72..e026ca6551 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -1,6 +1,10 @@ package dto -import "time" +import ( + "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" +) type User struct { ID int64 `json:"id"` @@ -112,7 +116,8 @@ type AdminGroup struct { MCPXMLInject bool `json:"mcp_xml_inject"` // OpenAI Messages 调度配置(仅 openai 平台使用) - DefaultMappedModel string `json:"default_mapped_model"` + DefaultMappedModel string `json:"default_mapped_model"` + MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"` // 支持的模型系列(仅 antigravity 平台使用) SupportedModelScopes []string `json:"supported_model_scopes"` diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index a075b586c4..1803cf308f 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -58,7 +58,8 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch). SetRequireOauthOnly(groupIn.RequireOAuthOnly). SetRequirePrivacySet(groupIn.RequirePrivacySet). - SetDefaultMappedModel(groupIn.DefaultMappedModel) + SetDefaultMappedModel(groupIn.DefaultMappedModel). + SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig) // 设置模型路由配置 if groupIn.ModelRouting != nil { @@ -124,7 +125,8 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetAllowMessagesDispatch(groupIn.AllowMessagesDispatch). SetRequireOauthOnly(groupIn.RequireOAuthOnly). SetRequirePrivacySet(groupIn.RequirePrivacySet). - SetDefaultMappedModel(groupIn.DefaultMappedModel) + SetDefaultMappedModel(groupIn.DefaultMappedModel). + SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig) // 显式处理可空字段:nil 需要 clear,非 nil 需要 set。 if groupIn.DailyLimitUSD != nil { diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 8032f8717a..c2553eee1e 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -152,10 +152,11 @@ type CreateGroupInput struct { // 支持的模型系列(仅 antigravity 平台使用) SupportedModelScopes []string // OpenAI Messages 调度配置(仅 openai 平台使用) - AllowMessagesDispatch bool - DefaultMappedModel string - RequireOAuthOnly bool - RequirePrivacySet bool + AllowMessagesDispatch bool + DefaultMappedModel string + RequireOAuthOnly bool + RequirePrivacySet bool + MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig // 从指定分组复制账号(创建分组后在同一事务内绑定) CopyAccountsFromGroupIDs []int64 } @@ -186,10 +187,11 @@ type UpdateGroupInput struct { // 支持的模型系列(仅 antigravity 平台使用) SupportedModelScopes *[]string // OpenAI Messages 调度配置(仅 openai 平台使用) - AllowMessagesDispatch *bool - DefaultMappedModel *string - RequireOAuthOnly *bool - RequirePrivacySet *bool + AllowMessagesDispatch *bool + DefaultMappedModel *string + RequireOAuthOnly *bool + RequirePrivacySet *bool + MessagesDispatchModelConfig *OpenAIMessagesDispatchModelConfig // 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号) CopyAccountsFromGroupIDs []int64 } @@ -908,7 +910,9 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn RequireOAuthOnly: input.RequireOAuthOnly, RequirePrivacySet: input.RequirePrivacySet, DefaultMappedModel: input.DefaultMappedModel, + MessagesDispatchModelConfig: normalizeOpenAIMessagesDispatchModelConfig(input.MessagesDispatchModelConfig), } + sanitizeGroupMessagesDispatchFields(group) if err := s.groupRepo.Create(ctx, group); err != nil { return nil, err } @@ -1135,6 +1139,10 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.DefaultMappedModel != nil { group.DefaultMappedModel = *input.DefaultMappedModel } + if input.MessagesDispatchModelConfig != nil { + group.MessagesDispatchModelConfig = normalizeOpenAIMessagesDispatchModelConfig(*input.MessagesDispatchModelConfig) + } + sanitizeGroupMessagesDispatchFields(group) if err := s.groupRepo.Update(ctx, group); err != nil { return nil, err diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index 536be0b583..fa67660145 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -245,6 +245,116 @@ func TestAdminService_UpdateGroup_PartialImagePricing(t *testing.T) { require.Nil(t, repo.updated.ImagePrice4K) } +func TestAdminService_CreateGroup_NormalizesMessagesDispatchModelConfig(t *testing.T) { + repo := &groupRepoStubForAdmin{} + svc := &adminServiceImpl{groupRepo: repo} + + group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "dispatch-group", + Description: "dispatch config", + Platform: PlatformOpenAI, + RateMultiplier: 1.0, + MessagesDispatchModelConfig: OpenAIMessagesDispatchModelConfig{ + OpusMappedModel: " gpt-5.4-high ", + SonnetMappedModel: " gpt-5.3-codex ", + HaikuMappedModel: " gpt-5.4-mini-medium ", + ExactModelMappings: map[string]string{ + " claude-sonnet-4-5-20250929 ": " gpt-5.2-high ", + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, group) + require.NotNil(t, repo.created) + require.Equal(t, OpenAIMessagesDispatchModelConfig{ + OpusMappedModel: "gpt-5.4", + SonnetMappedModel: "gpt-5.3-codex", + HaikuMappedModel: "gpt-5.4-mini", + ExactModelMappings: map[string]string{ + "claude-sonnet-4-5-20250929": "gpt-5.2", + }, + }, repo.created.MessagesDispatchModelConfig) +} + +func TestAdminService_UpdateGroup_NormalizesMessagesDispatchModelConfig(t *testing.T) { + existingGroup := &Group{ + ID: 1, + Name: "existing-group", + Platform: PlatformOpenAI, + Status: StatusActive, + } + repo := &groupRepoStubForAdmin{getByID: existingGroup} + svc := &adminServiceImpl{groupRepo: repo} + + group, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{ + MessagesDispatchModelConfig: &OpenAIMessagesDispatchModelConfig{ + SonnetMappedModel: " gpt-5.4-medium ", + ExactModelMappings: map[string]string{ + " claude-haiku-4-5-20251001 ": " gpt-5.4-mini-high ", + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, group) + require.NotNil(t, repo.updated) + require.Equal(t, OpenAIMessagesDispatchModelConfig{ + SonnetMappedModel: "gpt-5.4", + ExactModelMappings: map[string]string{ + "claude-haiku-4-5-20251001": "gpt-5.4-mini", + }, + }, repo.updated.MessagesDispatchModelConfig) +} + +func TestAdminService_CreateGroup_ClearsMessagesDispatchFieldsForNonOpenAIPlatform(t *testing.T) { + repo := &groupRepoStubForAdmin{} + svc := &adminServiceImpl{groupRepo: repo} + + group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "anthropic-group", + Description: "non-openai", + Platform: PlatformAnthropic, + RateMultiplier: 1.0, + AllowMessagesDispatch: true, + DefaultMappedModel: "gpt-5.4", + MessagesDispatchModelConfig: OpenAIMessagesDispatchModelConfig{ + OpusMappedModel: "gpt-5.4", + }, + }) + require.NoError(t, err) + require.NotNil(t, group) + require.NotNil(t, repo.created) + require.False(t, repo.created.AllowMessagesDispatch) + require.Empty(t, repo.created.DefaultMappedModel) + require.Equal(t, OpenAIMessagesDispatchModelConfig{}, repo.created.MessagesDispatchModelConfig) +} + +func TestAdminService_UpdateGroup_ClearsMessagesDispatchFieldsWhenPlatformChangesAwayFromOpenAI(t *testing.T) { + existingGroup := &Group{ + ID: 1, + Name: "existing-openai-group", + Platform: PlatformOpenAI, + Status: StatusActive, + AllowMessagesDispatch: true, + DefaultMappedModel: "gpt-5.4", + MessagesDispatchModelConfig: OpenAIMessagesDispatchModelConfig{ + SonnetMappedModel: "gpt-5.3-codex", + }, + } + repo := &groupRepoStubForAdmin{getByID: existingGroup} + svc := &adminServiceImpl{groupRepo: repo} + + group, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{ + Platform: ptrString(PlatformAnthropic), + }) + require.NoError(t, err) + require.NotNil(t, group) + require.NotNil(t, repo.updated) + require.Equal(t, PlatformAnthropic, repo.updated.Platform) + require.False(t, repo.updated.AllowMessagesDispatch) + require.Empty(t, repo.updated.DefaultMappedModel) + require.Equal(t, OpenAIMessagesDispatchModelConfig{}, repo.updated.MessagesDispatchModelConfig) +} + func TestAdminService_ListGroups_WithSearch(t *testing.T) { // 测试: // 1. search 参数正常传递到 repository 层 diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index d59af9e1c0..1226261357 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -3,8 +3,12 @@ package service import ( "strings" "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" ) +type OpenAIMessagesDispatchModelConfig = domain.OpenAIMessagesDispatchModelConfig + type Group struct { ID int64 Name string @@ -49,10 +53,11 @@ type Group struct { SortOrder int // OpenAI Messages 调度配置(仅 openai 平台使用) - AllowMessagesDispatch bool - RequireOAuthOnly bool // 仅允许非 apikey 类型账号关联(OpenAI/Antigravity/Anthropic/Gemini) - RequirePrivacySet bool // 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini) - DefaultMappedModel string + AllowMessagesDispatch bool + RequireOAuthOnly bool // 仅允许非 apikey 类型账号关联(OpenAI/Antigravity/Anthropic/Gemini) + RequirePrivacySet bool // 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini) + DefaultMappedModel string + MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig CreatedAt time.Time UpdatedAt time.Time diff --git a/backend/internal/service/openai_messages_dispatch.go b/backend/internal/service/openai_messages_dispatch.go new file mode 100644 index 0000000000..f2c1ad3c1b --- /dev/null +++ b/backend/internal/service/openai_messages_dispatch.go @@ -0,0 +1,100 @@ +package service + +import "strings" + +const ( + defaultOpenAIMessagesDispatchOpusMappedModel = "gpt-5.4" + defaultOpenAIMessagesDispatchSonnetMappedModel = "gpt-5.3-codex" + defaultOpenAIMessagesDispatchHaikuMappedModel = "gpt-5.4-mini" +) + +func normalizeOpenAIMessagesDispatchMappedModel(model string) string { + model = NormalizeOpenAICompatRequestedModel(strings.TrimSpace(model)) + return strings.TrimSpace(model) +} + +func normalizeOpenAIMessagesDispatchModelConfig(cfg OpenAIMessagesDispatchModelConfig) OpenAIMessagesDispatchModelConfig { + out := OpenAIMessagesDispatchModelConfig{ + OpusMappedModel: normalizeOpenAIMessagesDispatchMappedModel(cfg.OpusMappedModel), + SonnetMappedModel: normalizeOpenAIMessagesDispatchMappedModel(cfg.SonnetMappedModel), + HaikuMappedModel: normalizeOpenAIMessagesDispatchMappedModel(cfg.HaikuMappedModel), + } + + if len(cfg.ExactModelMappings) > 0 { + out.ExactModelMappings = make(map[string]string, len(cfg.ExactModelMappings)) + for requestedModel, mappedModel := range cfg.ExactModelMappings { + requestedModel = strings.TrimSpace(requestedModel) + mappedModel = normalizeOpenAIMessagesDispatchMappedModel(mappedModel) + if requestedModel == "" || mappedModel == "" { + continue + } + out.ExactModelMappings[requestedModel] = mappedModel + } + if len(out.ExactModelMappings) == 0 { + out.ExactModelMappings = nil + } + } + + return out +} + +func claudeMessagesDispatchFamily(model string) string { + normalized := strings.ToLower(strings.TrimSpace(model)) + if !strings.HasPrefix(normalized, "claude") { + return "" + } + switch { + case strings.Contains(normalized, "opus"): + return "opus" + case strings.Contains(normalized, "sonnet"): + return "sonnet" + case strings.Contains(normalized, "haiku"): + return "haiku" + default: + return "" + } +} + +func (g *Group) ResolveMessagesDispatchModel(requestedModel string) string { + if g == nil { + return "" + } + requestedModel = strings.TrimSpace(requestedModel) + if requestedModel == "" { + return "" + } + + cfg := normalizeOpenAIMessagesDispatchModelConfig(g.MessagesDispatchModelConfig) + if mappedModel := strings.TrimSpace(cfg.ExactModelMappings[requestedModel]); mappedModel != "" { + return mappedModel + } + + switch claudeMessagesDispatchFamily(requestedModel) { + case "opus": + if mappedModel := strings.TrimSpace(cfg.OpusMappedModel); mappedModel != "" { + return mappedModel + } + return defaultOpenAIMessagesDispatchOpusMappedModel + case "sonnet": + if mappedModel := strings.TrimSpace(cfg.SonnetMappedModel); mappedModel != "" { + return mappedModel + } + return defaultOpenAIMessagesDispatchSonnetMappedModel + case "haiku": + if mappedModel := strings.TrimSpace(cfg.HaikuMappedModel); mappedModel != "" { + return mappedModel + } + return defaultOpenAIMessagesDispatchHaikuMappedModel + default: + return "" + } +} + +func sanitizeGroupMessagesDispatchFields(g *Group) { + if g == nil || g.Platform == PlatformOpenAI { + return + } + g.AllowMessagesDispatch = false + g.DefaultMappedModel = "" + g.MessagesDispatchModelConfig = OpenAIMessagesDispatchModelConfig{} +} diff --git a/backend/internal/service/openai_messages_dispatch_test.go b/backend/internal/service/openai_messages_dispatch_test.go new file mode 100644 index 0000000000..a625aaddd4 --- /dev/null +++ b/backend/internal/service/openai_messages_dispatch_test.go @@ -0,0 +1,27 @@ +package service + +import "testing" + +import "github.com/stretchr/testify/require" + +func TestNormalizeOpenAIMessagesDispatchModelConfig(t *testing.T) { + t.Parallel() + + cfg := normalizeOpenAIMessagesDispatchModelConfig(OpenAIMessagesDispatchModelConfig{ + OpusMappedModel: " gpt-5.4-high ", + SonnetMappedModel: "gpt-5.3-codex", + HaikuMappedModel: " gpt-5.4-mini-medium ", + ExactModelMappings: map[string]string{ + " claude-sonnet-4-5-20250929 ": " gpt-5.2-high ", + "": "gpt-5.4", + "claude-opus-4-6": " ", + }, + }) + + require.Equal(t, "gpt-5.4", cfg.OpusMappedModel) + require.Equal(t, "gpt-5.3-codex", cfg.SonnetMappedModel) + require.Equal(t, "gpt-5.4-mini", cfg.HaikuMappedModel) + require.Equal(t, map[string]string{ + "claude-sonnet-4-5-20250929": "gpt-5.2", + }, cfg.ExactModelMappings) +} diff --git a/backend/migrations/091_add_group_messages_dispatch_model_config.sql b/backend/migrations/091_add_group_messages_dispatch_model_config.sql new file mode 100644 index 0000000000..8ddfcb0f72 --- /dev/null +++ b/backend/migrations/091_add_group_messages_dispatch_model_config.sql @@ -0,0 +1,2 @@ +ALTER TABLE groups +ADD COLUMN IF NOT EXISTS messages_dispatch_model_config JSONB NOT NULL DEFAULT '{}'::jsonb; From 4de4823a65bccf199d1507af5dfd4a14655e8236 Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Thu, 9 Apr 2026 12:29:49 +0800 Subject: [PATCH 39/59] =?UTF-8?q?feat(openai):=20=E6=94=AF=E6=8C=81message?= =?UTF-8?q?s=E6=A8=A1=E5=9E=8B=E6=98=A0=E5=B0=84=E4=B8=8Einstructions?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/config/config.go | 14 +++ backend/internal/config/config_test.go | 19 ++++ .../handler/openai_gateway_handler.go | 43 +++----- .../handler/openai_gateway_handler_test.go | 41 ++++++- backend/internal/pkg/apicompat/types.go | 6 +- .../openai_codex_instructions_template.go | 55 ++++++++++ .../service/openai_compat_model_test.go | 101 ++++++++++++++++++ .../service/openai_gateway_messages.go | 18 ++++ deploy/codex-instructions.md.tmpl | 5 + deploy/config.example.yaml | 32 ++++-- deploy/docker-compose.yml | 39 +++++-- 11 files changed, 326 insertions(+), 47 deletions(-) create mode 100644 backend/internal/service/openai_codex_instructions_template.go create mode 100644 deploy/codex-instructions.md.tmpl diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index ad023dc10c..d3d2dd6d53 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -318,6 +318,12 @@ type GatewayConfig struct { // ForceCodexCLI: 强制将 OpenAI `/v1/responses` 请求按 Codex CLI 处理。 // 用于网关未透传/改写 User-Agent 时的兼容兜底(默认关闭,避免影响其他客户端)。 ForceCodexCLI bool `mapstructure:"force_codex_cli"` + // ForcedCodexInstructionsTemplateFile: 服务端强制附加到 Codex 顶层 instructions 的模板文件路径。 + // 模板渲染后会直接覆盖最终 instructions;若需要保留客户端 system 转换结果,请在模板中显式引用 {{ .ExistingInstructions }}。 + ForcedCodexInstructionsTemplateFile string `mapstructure:"forced_codex_instructions_template_file"` + // ForcedCodexInstructionsTemplate: 启动时从模板文件读取并缓存的模板内容。 + // 该字段不直接参与配置反序列化,仅用于请求热路径避免重复读盘。 + ForcedCodexInstructionsTemplate string `mapstructure:"-"` // OpenAIPassthroughAllowTimeoutHeaders: OpenAI 透传模式是否放行客户端超时头 // 关闭(默认)可避免 x-stainless-timeout 等头导致上游提前断流。 OpenAIPassthroughAllowTimeoutHeaders bool `mapstructure:"openai_passthrough_allow_timeout_headers"` @@ -983,6 +989,14 @@ func load(allowMissingJWTSecret bool) (*Config, error) { cfg.Log.Environment = strings.TrimSpace(cfg.Log.Environment) cfg.Log.StacktraceLevel = strings.ToLower(strings.TrimSpace(cfg.Log.StacktraceLevel)) cfg.Log.Output.FilePath = strings.TrimSpace(cfg.Log.Output.FilePath) + cfg.Gateway.ForcedCodexInstructionsTemplateFile = strings.TrimSpace(cfg.Gateway.ForcedCodexInstructionsTemplateFile) + if cfg.Gateway.ForcedCodexInstructionsTemplateFile != "" { + content, err := os.ReadFile(cfg.Gateway.ForcedCodexInstructionsTemplateFile) + if err != nil { + return nil, fmt.Errorf("read forced codex instructions template %q: %w", cfg.Gateway.ForcedCodexInstructionsTemplateFile, err) + } + cfg.Gateway.ForcedCodexInstructionsTemplate = string(content) + } // 兼容旧键 gateway.openai_ws.sticky_previous_response_ttl_seconds。 // 新键未配置(<=0)时回退旧键;新键优先。 diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 2de5451ee0..8cb23026e9 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -1,6 +1,8 @@ package config import ( + "os" + "path/filepath" "strings" "testing" "time" @@ -223,6 +225,23 @@ func TestLoadSchedulingConfigFromEnv(t *testing.T) { } } +func TestLoadForcedCodexInstructionsTemplate(t *testing.T) { + resetViperWithJWTSecret(t) + + tempDir := t.TempDir() + templatePath := filepath.Join(tempDir, "codex-instructions.md.tmpl") + configPath := filepath.Join(tempDir, "config.yaml") + + require.NoError(t, os.WriteFile(templatePath, []byte("server-prefix\n\n{{ .ExistingInstructions }}"), 0o644)) + require.NoError(t, os.WriteFile(configPath, []byte("gateway:\n forced_codex_instructions_template_file: \""+templatePath+"\"\n"), 0o644)) + t.Setenv("DATA_DIR", tempDir) + + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, templatePath, cfg.Gateway.ForcedCodexInstructionsTemplateFile) + require.Equal(t, "server-prefix\n\n{{ .ExistingInstructions }}", cfg.Gateway.ForcedCodexInstructionsTemplate) +} + func TestLoadDefaultSecurityToggles(t *testing.T) { resetViperWithJWTSecret(t) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 4747ccfe1e..5319b55d9d 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -47,6 +47,13 @@ func resolveOpenAIForwardDefaultMappedModel(apiKey *service.APIKey, fallbackMode return strings.TrimSpace(apiKey.Group.DefaultMappedModel) } +func resolveOpenAIMessagesDispatchMappedModel(apiKey *service.APIKey, requestedModel string) string { + if apiKey == nil || apiKey.Group == nil { + return "" + } + return strings.TrimSpace(apiKey.Group.ResolveMessagesDispatchModel(requestedModel)) +} + // NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler func NewOpenAIGatewayHandler( gatewayService *service.OpenAIGatewayService, @@ -551,6 +558,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { } reqModel := modelResult.String() routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel) + preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(apiKey, reqModel) reqStream := gjson.GetBytes(body, "stream").Bool() reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream)) @@ -609,17 +617,20 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { failedAccountIDs := make(map[int64]struct{}) sameAccountRetryCount := make(map[int64]int) var lastFailoverErr *service.UpstreamFailoverError + effectiveMappedModel := preferredMappedModel for { - // 清除上一次迭代的降级模型标记,避免残留影响本次迭代 - c.Set("openai_messages_fallback_model", "") + currentRoutingModel := routingModel + if effectiveMappedModel != "" { + currentRoutingModel = effectiveMappedModel + } reqLog.Debug("openai_messages.account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs))) selection, scheduleDecision, err := h.gatewayService.SelectAccountWithScheduler( c.Request.Context(), apiKey.GroupID, "", // no previous_response_id sessionHash, - routingModel, + currentRoutingModel, failedAccountIDs, service.OpenAIUpstreamTransportAny, ) @@ -628,29 +639,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { zap.Error(err), zap.Int("excluded_account_count", len(failedAccountIDs)), ) - // 首次调度失败 + 有默认映射模型 → 用默认模型重试 if len(failedAccountIDs) == 0 { - defaultModel := "" - if apiKey.Group != nil { - defaultModel = apiKey.Group.DefaultMappedModel - } - if defaultModel != "" && defaultModel != routingModel { - reqLog.Info("openai_messages.fallback_to_default_model", - zap.String("default_mapped_model", defaultModel), - ) - selection, scheduleDecision, err = h.gatewayService.SelectAccountWithScheduler( - c.Request.Context(), - apiKey.GroupID, - "", - sessionHash, - defaultModel, - failedAccountIDs, - service.OpenAIUpstreamTransportAny, - ) - if err == nil && selection != nil { - c.Set("openai_messages_fallback_model", defaultModel) - } - } if err != nil { h.anthropicStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted) return @@ -682,9 +671,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds()) forwardStart := time.Now() - // Forward 层需要始终拿到 group 默认映射模型,这样未命中账号级映射的 - // Claude 兼容模型才不会在后续 Codex 规范化中意外退化到 gpt-5.1。 - defaultMappedModel := resolveOpenAIForwardDefaultMappedModel(apiKey, c.GetString("openai_messages_fallback_model")) + defaultMappedModel := strings.TrimSpace(effectiveMappedModel) // 应用渠道模型映射到请求体 forwardBody := body if channelMappingMsg.Mapped { diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 7bbf94ecb3..d299fb81e3 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -360,7 +360,7 @@ func TestResolveOpenAIForwardDefaultMappedModel(t *testing.T) { require.Equal(t, "gpt-5.2", resolveOpenAIForwardDefaultMappedModel(apiKey, " gpt-5.2 ")) }) - t.Run("uses_group_default_on_normal_path", func(t *testing.T) { + t.Run("uses_group_default_when_explicit_fallback_absent", func(t *testing.T) { apiKey := &service.APIKey{ Group: &service.Group{DefaultMappedModel: "gpt-5.4"}, } @@ -376,6 +376,45 @@ func TestResolveOpenAIForwardDefaultMappedModel(t *testing.T) { }) } +func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) { + t.Run("exact_claude_model_override_wins", func(t *testing.T) { + apiKey := &service.APIKey{ + Group: &service.Group{ + MessagesDispatchModelConfig: service.OpenAIMessagesDispatchModelConfig{ + SonnetMappedModel: "gpt-5.2", + ExactModelMappings: map[string]string{ + "claude-sonnet-4-5-20250929": "gpt-5.4-mini-high", + }, + }, + }, + } + require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5-20250929")) + }) + + t.Run("uses_family_default_when_no_override", func(t *testing.T) { + apiKey := &service.APIKey{Group: &service.Group{}} + require.Equal(t, "gpt-5.4", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-opus-4-6")) + require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5-20250929")) + require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-haiku-4-5-20251001")) + }) + + t.Run("returns_empty_for_non_claude_or_missing_group", func(t *testing.T) { + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, "claude-sonnet-4-5-20250929")) + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(&service.APIKey{}, "claude-sonnet-4-5-20250929")) + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(&service.APIKey{Group: &service.Group{}}, "gpt-5.4")) + }) + + t.Run("does_not_fall_back_to_group_default_mapped_model", func(t *testing.T) { + apiKey := &service.APIKey{ + Group: &service.Group{ + DefaultMappedModel: "gpt-5.4", + }, + } + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(apiKey, "gpt-5.4")) + require.Equal(t, "gpt-5.3-codex", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5-20250929")) + }) +} + func TestOpenAIResponses_MissingDependencies_ReturnsServiceUnavailable(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index b724a5ed96..b383f8671a 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -28,7 +28,7 @@ type AnthropicRequest struct { // AnthropicOutputConfig controls output generation parameters. type AnthropicOutputConfig struct { - Effort string `json:"effort,omitempty"` // "low" | "medium" | "high" + Effort string `json:"effort,omitempty"` // "low" | "medium" | "high" | "max" } // AnthropicThinking configures extended thinking in the Anthropic API. @@ -167,7 +167,7 @@ type ResponsesRequest struct { // ResponsesReasoning configures reasoning effort in the Responses API. type ResponsesReasoning struct { - Effort string `json:"effort"` // "low" | "medium" | "high" + Effort string `json:"effort"` // "low" | "medium" | "high" | "xhigh" Summary string `json:"summary,omitempty"` // "auto" | "concise" | "detailed" } @@ -345,7 +345,7 @@ type ChatCompletionsRequest struct { StreamOptions *ChatStreamOptions `json:"stream_options,omitempty"` Tools []ChatTool `json:"tools,omitempty"` ToolChoice json.RawMessage `json:"tool_choice,omitempty"` - ReasoningEffort string `json:"reasoning_effort,omitempty"` // "low" | "medium" | "high" + ReasoningEffort string `json:"reasoning_effort,omitempty"` // "low" | "medium" | "high" | "xhigh" ServiceTier string `json:"service_tier,omitempty"` Stop json.RawMessage `json:"stop,omitempty"` // string or []string diff --git a/backend/internal/service/openai_codex_instructions_template.go b/backend/internal/service/openai_codex_instructions_template.go new file mode 100644 index 0000000000..5588c73cbb --- /dev/null +++ b/backend/internal/service/openai_codex_instructions_template.go @@ -0,0 +1,55 @@ +package service + +import ( + "bytes" + "fmt" + "strings" + "text/template" +) + +type forcedCodexInstructionsTemplateData struct { + ExistingInstructions string + OriginalModel string + NormalizedModel string + BillingModel string + UpstreamModel string +} + +func applyForcedCodexInstructionsTemplate( + reqBody map[string]any, + templateText string, + data forcedCodexInstructionsTemplateData, +) (bool, error) { + rendered, err := renderForcedCodexInstructionsTemplate(templateText, data) + if err != nil { + return false, err + } + if rendered == "" { + return false, nil + } + + existing, _ := reqBody["instructions"].(string) + if strings.TrimSpace(existing) == rendered { + return false, nil + } + + reqBody["instructions"] = rendered + return true, nil +} + +func renderForcedCodexInstructionsTemplate( + templateText string, + data forcedCodexInstructionsTemplateData, +) (string, error) { + tmpl, err := template.New("forced_codex_instructions").Option("missingkey=zero").Parse(templateText) + if err != nil { + return "", fmt.Errorf("parse forced codex instructions template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("render forced codex instructions template: %w", err) + } + + return strings.TrimSpace(buf.String()), nil +} diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index 32c646d490..4396c15fd0 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -6,9 +6,12 @@ import ( "io" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -127,3 +130,101 @@ func TestForwardAsAnthropic_NormalizesRoutingAndEffortForGpt54XHigh(t *testing.T t.Logf("upstream body: %s", string(upstream.lastBody)) t.Logf("response body: %s", rec.Body.String()) } + +func TestForwardAsAnthropic_ForcedCodexInstructionsTemplatePrependsRenderedInstructions(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + templateDir := t.TempDir() + templatePath := filepath.Join(templateDir, "codex-instructions.md.tmpl") + require.NoError(t, os.WriteFile(templatePath, []byte("server-prefix\n\n{{ .ExistingInstructions }}"), 0o644)) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.4","max_tokens":16,"system":"client-system","messages":[{"role":"user","content":"hello"}],"stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamBody := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`, + "", + "data: [DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_forced"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{ + ForcedCodexInstructionsTemplateFile: templatePath, + ForcedCodexInstructionsTemplate: "server-prefix\n\n{{ .ExistingInstructions }}", + }}, + httpUpstream: upstream, + } + account := &Account{ + ID: 1, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + } + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "server-prefix\n\nclient-system", gjson.GetBytes(upstream.lastBody, "instructions").String()) +} + +func TestForwardAsAnthropic_ForcedCodexInstructionsTemplateUsesCachedTemplateContent(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.4","max_tokens":16,"system":"client-system","messages":[{"role":"user","content":"hello"}],"stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamBody := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`, + "", + "data: [DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_forced_cached"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{ + ForcedCodexInstructionsTemplateFile: "/path/that/should/not/be/read.tmpl", + ForcedCodexInstructionsTemplate: "cached-prefix\n\n{{ .ExistingInstructions }}", + }}, + httpUpstream: upstream, + } + account := &Account{ + ID: 1, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + } + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "cached-prefix\n\nclient-system", gjson.GetBytes(upstream.lastBody, "instructions").String()) +} diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 6f53928b7d..7a4862d335 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -86,6 +86,24 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( return nil, fmt.Errorf("unmarshal for codex transform: %w", err) } codexResult := applyCodexOAuthTransform(reqBody, false, false) + forcedTemplateText := "" + if s.cfg != nil { + forcedTemplateText = s.cfg.Gateway.ForcedCodexInstructionsTemplate + } + templateUpstreamModel := upstreamModel + if codexResult.NormalizedModel != "" { + templateUpstreamModel = codexResult.NormalizedModel + } + existingInstructions, _ := reqBody["instructions"].(string) + if _, err := applyForcedCodexInstructionsTemplate(reqBody, forcedTemplateText, forcedCodexInstructionsTemplateData{ + ExistingInstructions: strings.TrimSpace(existingInstructions), + OriginalModel: originalModel, + NormalizedModel: normalizedModel, + BillingModel: billingModel, + UpstreamModel: templateUpstreamModel, + }); err != nil { + return nil, err + } if codexResult.NormalizedModel != "" { upstreamModel = codexResult.NormalizedModel } diff --git a/deploy/codex-instructions.md.tmpl b/deploy/codex-instructions.md.tmpl new file mode 100644 index 0000000000..87ad0a3d84 --- /dev/null +++ b/deploy/codex-instructions.md.tmpl @@ -0,0 +1,5 @@ +You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer. + +{{ if .ExistingInstructions }} +{{ .ExistingInstructions }} +{{ end }} diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 45440761db..6fd5fb8f5c 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -202,6 +202,32 @@ gateway: # # 注意:开启后会影响所有客户端的行为(不仅限于 VS Code / Codex CLI),请谨慎开启。 force_codex_cli: false + # Optional: template file used to build the final top-level Codex `instructions`. + # 可选:用于构建最终 Codex 顶层 `instructions` 的模板文件路径。 + # + # This is applied on the `/v1/messages -> Responses/Codex` conversion path, + # after Claude `system` has already been normalized into Codex `instructions`. + # 该模板作用于 `/v1/messages -> Responses/Codex` 转换链路,且发生在 Claude `system` + # 已经被归一化为 Codex `instructions` 之后。 + # + # The template can reference: + # 模板可引用: + # - {{ .ExistingInstructions }} : converted client instructions/system + # - {{ .OriginalModel }} : original requested model + # - {{ .NormalizedModel }} : normalized routing model + # - {{ .BillingModel }} : billing model + # - {{ .UpstreamModel }} : final upstream model + # + # If you want to preserve client system prompts, keep {{ .ExistingInstructions }} + # somewhere in the template. If omitted, the template output fully replaces it. + # 如需保留客户端 system 提示词,请在模板中显式包含 {{ .ExistingInstructions }}。 + # 若省略,则模板输出会完全覆盖它。 + # + # Docker users can mount a host file to /app/data/codex-instructions.md.tmpl + # and point this field there. + # Docker 用户可将宿主机文件挂载到 /app/data/codex-instructions.md.tmpl, + # 然后把本字段指向该路径。 + forced_codex_instructions_template_file: "" # OpenAI 透传模式是否放行客户端超时头(如 x-stainless-timeout) # 默认 false:过滤超时头,降低上游提前断流风险。 openai_passthrough_allow_timeout_headers: false @@ -347,12 +373,6 @@ gateway: # Enable batch load calculation for scheduling # 启用调度批量负载计算 load_batch_enabled: true - # Snapshot bucket MGET chunk size - # 调度快照分桶读取时的 MGET 分块大小 - snapshot_mget_chunk_size: 128 - # Snapshot bucket write chunk size - # 调度快照重建写入时的分块大小 - snapshot_write_chunk_size: 256 # Slot cleanup interval (duration) # 并发槽位清理周期(时间段) slot_cleanup_interval: 30s diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index a0bc1a6061..3a714260f2 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -31,6 +31,10 @@ services: # Optional: Mount custom config.yaml (uncomment and create the file first) # Copy config.example.yaml to config.yaml, modify it, then uncomment: # - ./config.yaml:/app/data/config.yaml + # Optional: Mount a custom Codex instructions template file, then point + # gateway.forced_codex_instructions_template_file at /app/data/codex-instructions.md.tmpl + # in config.yaml. + # - ./codex-instructions.md.tmpl:/app/data/codex-instructions.md.tmpl:ro environment: # ======================================================================= # Auto Setup (REQUIRED for Docker deployment) @@ -146,7 +150,17 @@ services: networks: - sub2api-network healthcheck: - test: ["CMD", "wget", "-q", "-T", "5", "-O", "/dev/null", "http://localhost:8080/health"] + test: + [ + "CMD", + "wget", + "-q", + "-T", + "5", + "-O", + "/dev/null", + "http://localhost:8080/health", + ] interval: 30s timeout: 10s retries: 3 @@ -177,11 +191,17 @@ services: networks: - sub2api-network healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-sub2api} -d ${POSTGRES_DB:-sub2api}"] + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-sub2api} -d ${POSTGRES_DB:-sub2api}", + ] interval: 10s timeout: 5s retries: 5 start_period: 10s + ports: + - 5432:5432 # 注意:不暴露端口到宿主机,应用通过内部网络连接 # 如需调试,可临时添加:ports: ["127.0.0.1:5433:5432"] @@ -199,12 +219,12 @@ services: volumes: - redis_data:/data command: > - sh -c ' - redis-server - --save 60 1 - --appendonly yes - --appendfsync everysec - ${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}' + sh -c ' + redis-server + --save 60 1 + --appendonly yes + --appendfsync everysec + ${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}' environment: - TZ=${TZ:-Asia/Shanghai} # REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag) @@ -217,7 +237,8 @@ services: timeout: 5s retries: 5 start_period: 5s - + ports: + - 6379:6379 # ============================================================================= # Volumes # ============================================================================= From d765359f4bed38ec1f3535365dd58ea367dfa5be Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Thu, 9 Apr 2026 12:30:06 +0800 Subject: [PATCH 40/59] =?UTF-8?q?test(admin):=20=E5=A2=9E=E5=8A=A0messages?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E8=A1=A8=E5=8D=95=E7=8A=B6=E6=80=81=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/types/index.ts | 10 ++ .../__tests__/groupsMessagesDispatch.spec.ts | 94 +++++++++++++++++++ .../src/views/admin/groupsMessagesDispatch.ts | 72 ++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 frontend/src/views/admin/__tests__/groupsMessagesDispatch.spec.ts create mode 100644 frontend/src/views/admin/groupsMessagesDispatch.ts diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 580126c894..9f2c275548 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -366,6 +366,13 @@ export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' export type SubscriptionType = 'standard' | 'subscription' +export interface OpenAIMessagesDispatchModelConfig { + opus_mapped_model?: string + sonnet_mapped_model?: string + haiku_mapped_model?: string + exact_model_mappings?: Record +} + export interface Group { id: number name: string @@ -388,6 +395,8 @@ export interface Group { fallback_group_id_on_invalid_request: number | null // OpenAI Messages 调度开关(用户侧需要此字段判断是否展示 Claude Code 教程) allow_messages_dispatch?: boolean + default_mapped_model?: string + messages_dispatch_model_config?: OpenAIMessagesDispatchModelConfig require_oauth_only: boolean require_privacy_set: boolean created_at: string @@ -414,6 +423,7 @@ export interface AdminGroup extends Group { // OpenAI Messages 调度配置(仅 openai 平台使用) default_mapped_model?: string + messages_dispatch_model_config?: OpenAIMessagesDispatchModelConfig // 分组排序 sort_order: number diff --git a/frontend/src/views/admin/__tests__/groupsMessagesDispatch.spec.ts b/frontend/src/views/admin/__tests__/groupsMessagesDispatch.spec.ts new file mode 100644 index 0000000000..15f422247a --- /dev/null +++ b/frontend/src/views/admin/__tests__/groupsMessagesDispatch.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; + +import { + createDefaultMessagesDispatchFormState, + messagesDispatchConfigToFormState, + messagesDispatchFormStateToConfig, + resetMessagesDispatchFormState, +} from "../groupsMessagesDispatch"; + +describe("groupsMessagesDispatch", () => { + it("returns the expected default form state", () => { + expect(createDefaultMessagesDispatchFormState()).toEqual({ + allow_messages_dispatch: false, + opus_mapped_model: "gpt-5.4", + sonnet_mapped_model: "gpt-5.3-codex", + haiku_mapped_model: "gpt-5.4-mini", + exact_model_mappings: [], + }); + }); + + it("sanitizes exact model mapping rows when converting to config", () => { + const config = messagesDispatchFormStateToConfig({ + allow_messages_dispatch: true, + opus_mapped_model: " gpt-5.4 ", + sonnet_mapped_model: "gpt-5.3-codex", + haiku_mapped_model: " gpt-5.4-mini ", + exact_model_mappings: [ + { + claude_model: " claude-sonnet-4-5-20250929 ", + target_model: " gpt-5.2 ", + }, + { claude_model: "", target_model: "gpt-5.4" }, + { claude_model: "claude-opus-4-6", target_model: " " }, + ], + }); + + expect(config).toEqual({ + opus_mapped_model: "gpt-5.4", + sonnet_mapped_model: "gpt-5.3-codex", + haiku_mapped_model: "gpt-5.4-mini", + exact_model_mappings: { + "claude-sonnet-4-5-20250929": "gpt-5.2", + }, + }); + }); + + it("hydrates form state from api config", () => { + expect( + messagesDispatchConfigToFormState({ + opus_mapped_model: "gpt-5.4", + sonnet_mapped_model: "gpt-5.2", + haiku_mapped_model: "gpt-5.4-mini", + exact_model_mappings: { + "claude-opus-4-6": "gpt-5.4", + "claude-haiku-4-5-20251001": "gpt-5.4-mini", + }, + }), + ).toEqual({ + allow_messages_dispatch: false, + opus_mapped_model: "gpt-5.4", + sonnet_mapped_model: "gpt-5.2", + haiku_mapped_model: "gpt-5.4-mini", + exact_model_mappings: [ + { + claude_model: "claude-haiku-4-5-20251001", + target_model: "gpt-5.4-mini", + }, + { claude_model: "claude-opus-4-6", target_model: "gpt-5.4" }, + ], + }); + }); + + it("resets mutable form state when platform switches away from openai", () => { + const state = { + allow_messages_dispatch: true, + opus_mapped_model: "gpt-5.2", + sonnet_mapped_model: "gpt-5.4", + haiku_mapped_model: "gpt-5.1", + exact_model_mappings: [ + { claude_model: "claude-opus-4-6", target_model: "gpt-5.4" }, + ], + }; + + resetMessagesDispatchFormState(state); + + expect(state).toEqual({ + allow_messages_dispatch: false, + opus_mapped_model: "gpt-5.4", + sonnet_mapped_model: "gpt-5.3-codex", + haiku_mapped_model: "gpt-5.4-mini", + exact_model_mappings: [], + }); + }); +}); diff --git a/frontend/src/views/admin/groupsMessagesDispatch.ts b/frontend/src/views/admin/groupsMessagesDispatch.ts new file mode 100644 index 0000000000..b367091cd7 --- /dev/null +++ b/frontend/src/views/admin/groupsMessagesDispatch.ts @@ -0,0 +1,72 @@ +import type { OpenAIMessagesDispatchModelConfig } from "@/types"; + +export interface MessagesDispatchMappingRow { + claude_model: string; + target_model: string; +} + +export interface MessagesDispatchFormState { + allow_messages_dispatch: boolean; + opus_mapped_model: string; + sonnet_mapped_model: string; + haiku_mapped_model: string; + exact_model_mappings: MessagesDispatchMappingRow[]; +} + +export function createDefaultMessagesDispatchFormState(): MessagesDispatchFormState { + return { + allow_messages_dispatch: false, + opus_mapped_model: "gpt-5.4", + sonnet_mapped_model: "gpt-5.3-codex", + haiku_mapped_model: "gpt-5.4-mini", + exact_model_mappings: [], + }; +} + +export function messagesDispatchConfigToFormState( + config?: OpenAIMessagesDispatchModelConfig | null, +): MessagesDispatchFormState { + const defaults = createDefaultMessagesDispatchFormState(); + const exactMappings = Object.entries(config?.exact_model_mappings || {}) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([claude_model, target_model]) => ({ claude_model, target_model })); + + return { + allow_messages_dispatch: false, + opus_mapped_model: + config?.opus_mapped_model?.trim() || defaults.opus_mapped_model, + sonnet_mapped_model: + config?.sonnet_mapped_model?.trim() || defaults.sonnet_mapped_model, + haiku_mapped_model: + config?.haiku_mapped_model?.trim() || defaults.haiku_mapped_model, + exact_model_mappings: exactMappings, + }; +} + +export function messagesDispatchFormStateToConfig( + state: MessagesDispatchFormState, +): OpenAIMessagesDispatchModelConfig { + const exactModelMappings = Object.fromEntries( + state.exact_model_mappings + .map((row) => [row.claude_model.trim(), row.target_model.trim()] as const) + .filter(([claudeModel, targetModel]) => claudeModel && targetModel), + ); + + return { + opus_mapped_model: state.opus_mapped_model.trim(), + sonnet_mapped_model: state.sonnet_mapped_model.trim(), + haiku_mapped_model: state.haiku_mapped_model.trim(), + exact_model_mappings: exactModelMappings, + }; +} + +export function resetMessagesDispatchFormState( + target: MessagesDispatchFormState, +): void { + const defaults = createDefaultMessagesDispatchFormState(); + target.allow_messages_dispatch = defaults.allow_messages_dispatch; + target.opus_mapped_model = defaults.opus_mapped_model; + target.sonnet_mapped_model = defaults.sonnet_mapped_model; + target.haiku_mapped_model = defaults.haiku_mapped_model; + target.exact_model_mappings = []; +} From de9b9c9dfb2f7cd67a222551e0ab4765fa15483e Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Thu, 9 Apr 2026 12:30:25 +0800 Subject: [PATCH 41/59] =?UTF-8?q?feat(admin):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=88=86=E7=BB=84=20messages=20=E8=B0=83=E5=BA=A6=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E9=85=8D=E7=BD=AE=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/views/admin/GroupsView.vue | 2789 ++++++++++++++++------- 1 file changed, 1952 insertions(+), 837 deletions(-) diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 5bfe62c311..99fc7c31d4 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -2,7 +2,9 @@ @@ -218,21 +289,23 @@ class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400" > - {{ t('common.edit') }} + {{ t("common.edit") }}
@@ -267,9 +340,13 @@ width="normal" @close="closeCreateModal" > - +
- +
- +
- + { + const val = Number((e.target as HTMLSelectElement).value); + if ( + val && + !createForm.copy_accounts_from_group_ids.includes(val) + ) { + createForm.copy_accounts_from_group_ids.push(val); + } + (e.target as HTMLSelectElement).value = ''; } - (e.target as HTMLSelectElement).value = '' - }" + " > - + -

{{ t('admin.groups.copyAccounts.hint') }}

+

{{ t("admin.groups.copyAccounts.hint") }}

- + -

{{ t('admin.groups.rateMultiplierHint') }}

+

{{ t("admin.groups.rateMultiplierHint") }}

-
+
@@ -388,20 +500,32 @@ class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400" /> -
-
-

{{ t('admin.groups.exclusiveTooltip.title') }}

+
+
+

+ {{ t("admin.groups.exclusiveTooltip.title") }} +

- {{ t('admin.groups.exclusiveTooltip.description') }} + {{ t("admin.groups.exclusiveTooltip.description") }}

- {{ t('admin.groups.exclusiveTooltip.example') }} - {{ t('admin.groups.exclusiveTooltip.exampleContent') }} + + {{ t("admin.groups.exclusiveTooltip.example") }} + {{ t("admin.groups.exclusiveTooltip.exampleContent") }}

-
+
@@ -412,18 +536,24 @@ @click="createForm.is_exclusive = !createForm.is_exclusive" :class="[ 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', - createForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600' + createForm.is_exclusive + ? 'bg-primary-500' + : 'bg-gray-300 dark:bg-dark-600', ]" > - {{ createForm.is_exclusive ? t('admin.groups.exclusive') : t('admin.groups.public') }} + {{ + createForm.is_exclusive + ? t("admin.groups.exclusive") + : t("admin.groups.public") + }}
@@ -431,9 +561,16 @@
- - +

+ {{ t("admin.groups.subscription.typeHint") }} +

@@ -442,7 +579,9 @@ class="space-y-4 border-l-2 border-primary-200 pl-4 dark:border-primary-800" >
- +
- +
- + -
-