diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index b0032849c8..a45be46276 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.2.7 +0.2.8 diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 06720610ba..06674251af 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -99,6 +99,7 @@ func provideCleanup( accountExpiry *service.AccountExpiryService, cnProviderBalanceCheck *service.CNProviderBalanceCheckService, codexVersionSync *service.OpenAICodexVersionSyncService, + claudeCodeVersionSync *service.ClaudeCodeVersionSyncService, proxyExpiry *service.ProxyExpiryService, subscriptionExpiry *service.SubscriptionExpiryService, usageCleanup *service.UsageCleanupService, @@ -124,6 +125,7 @@ func provideCleanup( quotaFlusher *service.UserPlatformQuotaUsageFlusher, upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, + opencodeGoUsage *service.OpenCodeGoUsageService, auditLog *service.AuditLogService, openAIAutoReset *service.OpenAIQuotaAutoResetService, promptAudit *securityaudit.PromptService, @@ -272,6 +274,10 @@ func provideCleanup( codexVersionSync.Stop() return nil }}, + {"ClaudeCodeVersionSyncService", func() error { + claudeCodeVersionSync.Stop() + return nil + }}, {"ProxyExpiryService", func() error { proxyExpiry.Stop() return nil @@ -380,6 +386,12 @@ func provideCleanup( } return nil }}, + {"OpenCodeGoUsageService", func() error { + if opencodeGoUsage != nil { + opencodeGoUsage.Stop() + } + return nil + }}, } infraSteps := []cleanupStep{ diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index d851509d92..d77f515e1a 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -287,7 +287,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { auditLogService := service.ProvideAuditLogService(auditLogRepository, settingService) auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService) upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService) + openCodeGoUsageService := service.ProvideOpenCodeGoUsageService(accountRepository, httpUpstream, settingService, leaderLockCache, db) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) @@ -341,6 +342,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { accountExpiryService := service.ProvideAccountExpiryService(accountRepository) cnProviderBalanceCheckService := service.ProvideCNProviderBalanceCheckService(accountRepository, cnProviderBalanceService, cnProviderQuotaService, configConfig) openAICodexVersionSyncService := service.ProvideOpenAICodexVersionSyncService(settingRepository, settingService, gitHubReleaseClient) + claudeCodeVersionSyncService := service.ProvideClaudeCodeVersionSyncService(settingRepository, settingService, gitHubReleaseClient) proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository) subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db) batchImageWorkerRuntime := service.ProvideBatchImageWorkerRuntime(batchImageRepository, accountRepository, batchImageQueue, usageBillingRepository, usageLogRepository, batchImageModelPricingResolver, apiKeyAuthCacheInvalidator, configConfig) @@ -350,7 +352,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, channelMonitorQuotaFetcher) channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService) userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService) - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager) application := &Application{ Server: httpServer, PromptAudit: promptService, @@ -405,6 +407,7 @@ func provideCleanup( accountExpiry *service.AccountExpiryService, cnProviderBalanceCheck *service.CNProviderBalanceCheckService, codexVersionSync *service.OpenAICodexVersionSyncService, + claudeCodeVersionSync *service.ClaudeCodeVersionSyncService, proxyExpiry *service.ProxyExpiryService, subscriptionExpiry *service.SubscriptionExpiryService, usageCleanup *service.UsageCleanupService, @@ -430,6 +433,7 @@ func provideCleanup( quotaFlusher *service.UserPlatformQuotaUsageFlusher, upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, + opencodeGoUsage *service.OpenCodeGoUsageService, auditLog *service.AuditLogService, openAIAutoReset *service.OpenAIQuotaAutoResetService, promptAudit *securityaudit.PromptService, @@ -577,6 +581,10 @@ func provideCleanup( codexVersionSync.Stop() return nil }}, + {"ClaudeCodeVersionSyncService", func() error { + claudeCodeVersionSync.Stop() + return nil + }}, {"ProxyExpiryService", func() error { proxyExpiry.Stop() return nil @@ -685,6 +693,12 @@ func provideCleanup( } return nil }}, + {"OpenCodeGoUsageService", func() error { + if opencodeGoUsage != nil { + opencodeGoUsage.Stop() + } + return nil + }}, } infraSteps := []cleanupStep{ diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index b3581641ea..d14ca19db6 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -41,6 +41,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { ) accountExpirySvc := service.NewAccountExpiryService(nil, time.Second) codexVersionSyncSvc := service.NewOpenAICodexVersionSyncService(nil, nil, nil, time.Second) + claudeCodeVersionSyncSvc := service.NewClaudeCodeVersionSyncService(nil, nil, nil, time.Second) proxyExpirySvc := service.NewProxyExpiryService(nil, time.Second) subscriptionExpirySvc := service.NewSubscriptionExpiryService(nil, time.Second) pricingSvc := service.NewPricingService(cfg, nil) @@ -68,6 +69,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { accountExpirySvc, nil, // cnProviderBalanceCheck codexVersionSyncSvc, + claudeCodeVersionSyncSvc, proxyExpirySvc, subscriptionExpirySvc, &service.UsageCleanupService{}, @@ -93,6 +95,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { nil, // quotaFlusher nil, // upstreamBillingProbe nil, // ollamaCloudUsage + nil, // opencodeGoUsage nil, // auditLog nil, // openAIAutoReset nil, // promptAudit diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 52500c4222..8780fcded3 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -66,6 +66,7 @@ type AccountHandler struct { upstreamBillingProbe *service.UpstreamBillingProbeService ollamaCloudUsage *service.OllamaCloudUsageService cfg *config.Config + opencodeGoUsage *service.OpenCodeGoUsageService } // SetUpstreamBillingProbeService attaches the optional remote billing probe service. @@ -77,6 +78,10 @@ func (h *AccountHandler) SetOllamaCloudUsageService(usage *service.OllamaCloudUs h.ollamaCloudUsage = usage } +func (h *AccountHandler) SetOpenCodeGoUsageService(usage *service.OpenCodeGoUsageService) { + h.opencodeGoUsage = usage +} + // NewAccountHandler creates a new admin account handler func NewAccountHandler( adminService service.AdminService, @@ -674,14 +679,22 @@ func (h *AccountHandler) List(c *gin.Context) { response.ErrorFrom(c, err) return } - if h.ollamaCloudUsage != nil && len(accounts) > 0 { + if len(accounts) > 0 { accountPointers := make([]*service.Account, len(accounts)) for index := range accounts { accountPointers[index] = &accounts[index] } - if err := h.ollamaCloudUsage.ResolveAccounts(c.Request.Context(), accountPointers); err != nil { - response.ErrorFrom(c, err) - return + if h.ollamaCloudUsage != nil { + if err := h.ollamaCloudUsage.ResolveAccounts(c.Request.Context(), accountPointers); err != nil { + response.ErrorFrom(c, err) + return + } + } + if h.opencodeGoUsage != nil { + if err := h.opencodeGoUsage.ResolveOpenCodeGoUsageAccounts(c.Request.Context(), accountPointers); err != nil { + response.ErrorFrom(c, err) + return + } } } @@ -942,6 +955,12 @@ func (h *AccountHandler) GetByID(c *gin.Context) { return } } + if h.opencodeGoUsage != nil { + if err := h.opencodeGoUsage.ResolveOpenCodeGoUsageAccounts(c.Request.Context(), []*service.Account{account}); err != nil { + response.ErrorFrom(c, err) + return + } + } response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) } diff --git a/backend/internal/handler/admin/account_opencode_go_usage.go b/backend/internal/handler/admin/account_opencode_go_usage.go new file mode 100644 index 0000000000..ee19b0129d --- /dev/null +++ b/backend/internal/handler/admin/account_opencode_go_usage.go @@ -0,0 +1,121 @@ +package admin + +import ( + "strconv" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +type openCodeGoUsageAutoRefreshRequest struct { + Enabled *bool `json:"enabled" binding:"required"` +} + +func (h *AccountHandler) GetOpenCodeGoUsageSettings(c *gin.Context) { + if h.opencodeGoUsage == nil { + response.ErrorFrom(c, service.ErrOpenCodeGoUsageUnavailable) + return + } + settings, err := h.opencodeGoUsage.GetSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, settings) +} + +func (h *AccountHandler) UpdateOpenCodeGoUsageSettings(c *gin.Context) { + if h.opencodeGoUsage == nil { + response.ErrorFrom(c, service.ErrOpenCodeGoUsageUnavailable) + return + } + var req service.OpenCodeGoUsageSettings + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if err := h.opencodeGoUsage.UpdateSettings(c.Request.Context(), &req); err != nil { + response.ErrorFrom(c, err) + return + } + settings, err := h.opencodeGoUsage.GetSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, settings) +} + +func (h *AccountHandler) GetOpenCodeGoUsage(c *gin.Context) { + if !h.requireOpenCodeGoUsage(c) { + return + } + accountID, ok := openCodeGoUsageAccountID(c) + if !ok { + return + } + state, err := h.opencodeGoUsage.GetState(c.Request.Context(), accountID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, state) +} + +func (h *AccountHandler) SetOpenCodeGoUsageAutoRefresh(c *gin.Context) { + if !h.requireOpenCodeGoUsage(c) { + return + } + accountID, ok := openCodeGoUsageAccountID(c) + if !ok { + return + } + var req openCodeGoUsageAutoRefreshRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + state, err := h.opencodeGoUsage.SetAutoRefresh(c.Request.Context(), accountID, *req.Enabled) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, state) +} + +func (h *AccountHandler) RefreshOpenCodeGoUsage(c *gin.Context) { + if !h.requireOpenCodeGoUsage(c) { + return + } + accountID, ok := openCodeGoUsageAccountID(c) + if !ok { + return + } + state, err := h.opencodeGoUsage.Refresh(c.Request.Context(), accountID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, state) +} + +func (h *AccountHandler) requireOpenCodeGoUsage(c *gin.Context) bool { + if h != nil && h.opencodeGoUsage != nil { + return true + } + response.ErrorFrom(c, service.ErrOpenCodeGoUsageUnavailable) + return false +} + +func openCodeGoUsageAccountID(c *gin.Context) (int64, bool) { + if c == nil { + return 0, false + } + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || accountID <= 0 { + response.BadRequest(c, "Invalid account ID") + return 0, false + } + return accountID, true +} diff --git a/backend/internal/handler/admin/account_opencode_go_usage_test.go b/backend/internal/handler/admin/account_opencode_go_usage_test.go new file mode 100644 index 0000000000..b763367d27 --- /dev/null +++ b/backend/internal/handler/admin/account_opencode_go_usage_test.go @@ -0,0 +1,206 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type openCodeGoUsageHandlerTestRepo struct { + service.AccountRepository + account *service.Account + accounts []*service.Account +} + +func (r *openCodeGoUsageHandlerTestRepo) GetByID(_ context.Context, id int64) (*service.Account, error) { + if r.account != nil && r.account.ID == id { + return r.account, nil + } + for _, account := range r.accounts { + if account.ID == id { + return account, nil + } + } + return nil, service.ErrAccountNotFound +} + +func (r *openCodeGoUsageHandlerTestRepo) ListOpenCodeGoUsageGroupAccounts(_ context.Context, anchors []*service.Account) ([]service.Account, error) { + wanted := make(map[string]struct{}, len(anchors)) + for _, anchor := range anchors { + if apiKey, ok := openCodeGoUsageHandlerTestAPIKey(anchor); ok { + wanted[apiKey] = struct{}{} + } + } + result := make([]service.Account, 0, len(r.accounts)+1) + if r.account != nil { + if apiKey, ok := openCodeGoUsageHandlerTestAPIKey(r.account); ok { + if _, match := wanted[apiKey]; match { + result = append(result, *r.account) + } + } + } + for _, account := range r.accounts { + if apiKey, ok := openCodeGoUsageHandlerTestAPIKey(account); ok { + if _, match := wanted[apiKey]; match { + result = append(result, *account) + } + } + } + return result, nil +} + +func openCodeGoUsageHandlerTestAPIKey(account *service.Account) (string, bool) { + if account == nil || account.Credentials == nil { + return "", false + } + apiKey, ok := account.Credentials["api_key"].(string) + return apiKey, ok && apiKey != "" +} + +func (r *openCodeGoUsageHandlerTestRepo) SetOpenCodeGoUsageAutoRefresh(context.Context, *service.Account, bool) error { + return nil +} +func (r *openCodeGoUsageHandlerTestRepo) UpdateOpenCodeGoUsageSnapshot(context.Context, *service.Account, *service.OpenCodeGoUsageSnapshot) error { + return nil +} +func (r *openCodeGoUsageHandlerTestRepo) DisableOpenCodeGoUsageAutoRefresh(context.Context, *service.Account) error { + return nil +} +func (r *openCodeGoUsageHandlerTestRepo) ListDueOpenCodeGoUsageAccounts(context.Context, time.Time, time.Duration, time.Duration, int) ([]service.Account, error) { + return nil, nil +} + +func newOpenCodeGoUsageHandlerTestService(t *testing.T) *service.OpenCodeGoUsageService { + t.Helper() + svc := service.NewOpenCodeGoUsageService(nil, nil, nil) + t.Cleanup(svc.Stop) + return svc +} + +func newOpenCodeGoUsageHandlerContext(method, target, body, id string) (*gin.Context, *httptest.ResponseRecorder) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(method, target, bytes.NewBufferString(body)) + request.Header.Set("Content-Type", "application/json") + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = request + if id != "" { + ctx.Params = gin.Params{{Key: "id", Value: id}} + } + return ctx, recorder +} + +func TestOpenCodeGoUsageHandlersValidateRequestsAndDependencies(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newOpenCodeGoUsageHandlerTestService(t) + + t.Run("invalid account id", func(t *testing.T) { + ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodGet, "/admin/accounts/not-an-id/opencode-go-usage", "", "not-an-id") + (&AccountHandler{opencodeGoUsage: svc}).GetOpenCodeGoUsage(ctx) + require.Equal(t, http.StatusBadRequest, recorder.Code) + }) + + t.Run("missing enabled", func(t *testing.T) { + ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodPut, "/admin/accounts/7/opencode-go-usage/auto-refresh", `{}`, "7") + (&AccountHandler{opencodeGoUsage: svc}).SetOpenCodeGoUsageAutoRefresh(ctx) + require.Equal(t, http.StatusBadRequest, recorder.Code) + }) + + t.Run("service unavailable", func(t *testing.T) { + ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodGet, "/admin/accounts/7/opencode-go-usage", "", "7") + (&AccountHandler{}).GetOpenCodeGoUsage(ctx) + require.Equal(t, http.StatusServiceUnavailable, recorder.Code) + require.Contains(t, recorder.Body.String(), "OPENCODE_GO_USAGE_UNAVAILABLE") + }) +} + +func TestGetOpenCodeGoUsageSettingsHandlerSuccess(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodGet, "/admin/accounts/opencode-go-usage/settings", "", "") + handler := &AccountHandler{opencodeGoUsage: newOpenCodeGoUsageHandlerTestService(t)} + + handler.GetOpenCodeGoUsageSettings(ctx) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Contains(t, recorder.Body.String(), `"enabled":false`) + require.Contains(t, recorder.Body.String(), `"interval_minutes":15`) +} + +func TestOpenCodeGoUsageStateEmbeddedInListAndDetail(t *testing.T) { + gin.SetMode(gin.TestMode) + now := time.Now().UTC() + account := &service.Account{ + ID: 7, Name: "opencode", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://opencode.ai/zen/go/v1", "api_key": "test-key"}, + Extra: map[string]any{ + service.OpenCodeGoUsageAutoRefreshExtraKey: true, + service.OpenCodeGoUsageSnapshotExtraKey: &service.OpenCodeGoUsageSnapshot{ + Status: service.OpenCodeGoUsageStatusOK, Data: &service.OpenCodeGoUsageData{ + Rolling: service.OpenCodeGoUsageWindow{Status: "ok", Percent: 6}, + }, + LastAttemptAt: now, NextRefreshAt: now.Add(time.Hour), + }, + }, + Status: service.StatusActive, + } + repo := &openCodeGoUsageHandlerTestRepo{account: account} + adminService := newStubAdminService() + adminService.accounts = []service.Account{*account} + adminService.getAccountResult = account + usageService := service.NewOpenCodeGoUsageService(repo, nil, nil) + t.Cleanup(usageService.Stop) + handler := NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + handler.SetOpenCodeGoUsageService(usageService) + router := gin.New() + router.GET("/accounts", handler.List) + router.GET("/accounts/:id", handler.GetByID) + router.GET("/accounts/:id/opencode-go-usage", handler.GetOpenCodeGoUsage) + + listRecorder := httptest.NewRecorder() + router.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, "/accounts?page=1&page_size=20", nil)) + require.Equal(t, http.StatusOK, listRecorder.Code) + var listPayload struct { + Data struct { + Items []struct { + OpenCodeGoUsage *service.OpenCodeGoUsageState `json:"opencode_go_usage"` + } `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(listRecorder.Body.Bytes(), &listPayload)) + require.Len(t, listPayload.Data.Items, 1) + require.NotNil(t, listPayload.Data.Items[0].OpenCodeGoUsage) + require.True(t, listPayload.Data.Items[0].OpenCodeGoUsage.AutoRefreshEnabled) + require.Equal(t, 6.0, listPayload.Data.Items[0].OpenCodeGoUsage.Snapshot.Data.Rolling.Percent) + + detailRecorder := httptest.NewRecorder() + router.ServeHTTP(detailRecorder, httptest.NewRequest(http.MethodGet, "/accounts/7", nil)) + require.Equal(t, http.StatusOK, detailRecorder.Code) + var detailPayload struct { + Data struct { + OpenCodeGoUsage *service.OpenCodeGoUsageState `json:"opencode_go_usage"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(detailRecorder.Body.Bytes(), &detailPayload)) + require.NotNil(t, detailPayload.Data.OpenCodeGoUsage) + require.Equal(t, 6.0, detailPayload.Data.OpenCodeGoUsage.Snapshot.Data.Rolling.Percent) + + stateRecorder := httptest.NewRecorder() + router.ServeHTTP(stateRecorder, httptest.NewRequest(http.MethodGet, "/accounts/7/opencode-go-usage", nil)) + require.Equal(t, http.StatusOK, stateRecorder.Code) + var statePayload struct { + Data service.OpenCodeGoUsageState `json:"data"` + } + require.NoError(t, json.Unmarshal(stateRecorder.Body.Bytes(), &statePayload)) + require.Equal(t, statePayload.Data.Snapshot, detailPayload.Data.OpenCodeGoUsage.Snapshot) + + for _, body := range []string{listRecorder.Body.String(), detailRecorder.Body.String(), stateRecorder.Body.String()} { + require.NotContains(t, body, "test-key") + } +} diff --git a/backend/internal/handler/admin/affiliate_handler.go b/backend/internal/handler/admin/affiliate_handler.go index d443d344d2..a5ed9e1cc0 100644 --- a/backend/internal/handler/admin/affiliate_handler.go +++ b/backend/internal/handler/admin/affiliate_handler.go @@ -201,6 +201,42 @@ func (h *AffiliateHandler) GetUserOverview(c *gin.Context) { response.Success(c, overview) } +// WithdrawQuotaRequest 是登记线下提现的请求体。Amount 是已在站外打款给 +// 用户的金额,由 AffiliateService 校验。 +type WithdrawQuotaRequest struct { + Amount float64 `json:"amount"` +} + +// WithdrawQuota records an offline withdrawal paid outside the site and +// deducts it from the user's available affiliate quota. The required +// Idempotency-Key header identifies one registration: a repeated request with +// the same key deducts nothing and returns the first result with +// X-Idempotency-Replayed: true. +// POST /api/v1/admin/affiliates/users/:user_id/withdraw +func (h *AffiliateHandler) WithdrawQuota(c *gin.Context) { + userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64) + if err != nil || userID <= 0 { + response.BadRequest(c, "Invalid user_id") + return + } + + var req WithdrawQuotaRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + result, err := h.affiliateService.AdminWithdrawQuota(c.Request.Context(), userID, req.Amount, c.GetHeader("Idempotency-Key")) + if err != nil { + response.ErrorFrom(c, err) + return + } + if result != nil && result.Replayed { + c.Header("X-Idempotency-Replayed", "true") + } + response.Success(c, result) +} + // ListInviteRecords returns all inviter-invitee relationships. // GET /api/v1/admin/affiliates/invites func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) { @@ -214,7 +250,8 @@ func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) { response.Paginated(c, items, total, filter.Page, filter.PageSize) } -// ListRebateRecords returns all order-level affiliate rebate records. +// ListRebateRecords returns every affiliate rebate accrual, with order details +// when the rebate came from a payment order. // GET /api/v1/admin/affiliates/rebates func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) { page, pageSize := response.ParsePagination(c) @@ -227,7 +264,8 @@ func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) { response.Paginated(c, items, total, filter.Page, filter.PageSize) } -// ListTransferRecords returns all affiliate quota-to-balance transfer records. +// ListTransferRecords returns affiliate quota outflows: user transfers into +// balance and admin-recorded offline withdrawals. // GET /api/v1/admin/affiliates/transfers func (h *AffiliateHandler) ListTransferRecords(c *gin.Context) { page, pageSize := response.ParsePagination(c) diff --git a/backend/internal/handler/admin/affiliate_handler_withdraw_test.go b/backend/internal/handler/admin/affiliate_handler_withdraw_test.go new file mode 100644 index 0000000000..0d4344fde5 --- /dev/null +++ b/backend/internal/handler/admin/affiliate_handler_withdraw_test.go @@ -0,0 +1,172 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +const testWithdrawIdempotencyKey = "affiliate-withdraw-1-5d0c7a2e" + +type withdrawQuotaCall struct { + userID int64 + amount float64 + operationID string +} + +type withdrawQuotaRepoStub struct { + service.AffiliateRepository + calls []withdrawQuotaCall + result *service.AffiliateWithdrawResult + err error +} + +func (s *withdrawQuotaRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64, operationID string) (*service.AffiliateWithdrawResult, error) { + s.calls = append(s.calls, withdrawQuotaCall{userID: userID, amount: amount, operationID: operationID}) + return s.result, s.err +} + +type withdrawQuotaResponse struct { + Code int `json:"code"` + Reason string `json:"reason"` + Data json.RawMessage `json:"data"` +} + +func performWithdrawQuota(t *testing.T, repo *withdrawQuotaRepoStub, userID, idempotencyKey, body string) (*httptest.ResponseRecorder, withdrawQuotaResponse) { + t.Helper() + gin.SetMode(gin.TestMode) + router := gin.New() + handler := NewAffiliateHandler(service.NewAffiliateService(repo, nil, nil, nil), nil) + router.POST("/api/v1/admin/affiliates/users/:user_id/withdraw", handler.WithdrawQuota) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/affiliates/users/"+userID+"/withdraw", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + if idempotencyKey != "" { + req.Header.Set("Idempotency-Key", idempotencyKey) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + var resp withdrawQuotaResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + return rec, resp +} + +// TestAffiliateHandlerWithdrawQuota_Success 验证登记线下提现把路径中的用户、 +// 舍入后的金额与幂等键派生的 operation_id 交给仓储,并返回登记结果。 +func TestAffiliateHandlerWithdrawQuota_Success(t *testing.T) { + repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 12.34567891}} + rec, resp := performWithdrawQuota(t, repo, "42", testWithdrawIdempotencyKey, `{"amount":12.3456789149}`) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, 0, resp.Code) + var data service.AffiliateWithdrawResult + require.NoError(t, json.Unmarshal(resp.Data, &data)) + require.Equal(t, int64(9), data.LedgerID) + require.Empty(t, rec.Header().Get("X-Idempotency-Replayed")) + + require.Len(t, repo.calls, 1) + require.Equal(t, int64(42), repo.calls[0].userID) + require.Equal(t, 12.34567891, repo.calls[0].amount) + require.Regexp(t, `^[0-9a-f]{64}$`, repo.calls[0].operationID) +} + +// TestAffiliateHandlerWithdrawQuota_SameKeyMapsToSameOperation 验证重试时带同一个 +// Idempotency-Key 的请求落到同一个 operation_id,换一个键则是另一笔登记。 +func TestAffiliateHandlerWithdrawQuota_SameKeyMapsToSameOperation(t *testing.T) { + repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 10}} + for _, key := range []string{testWithdrawIdempotencyKey, testWithdrawIdempotencyKey, testWithdrawIdempotencyKey + "-next"} { + rec, _ := performWithdrawQuota(t, repo, "42", key, `{"amount":10}`) + require.Equal(t, http.StatusOK, rec.Code) + } + + require.Len(t, repo.calls, 3) + require.Equal(t, repo.calls[0].operationID, repo.calls[1].operationID) + require.NotEqual(t, repo.calls[0].operationID, repo.calls[2].operationID) +} + +// TestAffiliateHandlerWithdrawQuota_ReplayReturnsFirstResult 验证命中已有登记时 +// 原样返回首次登记的结果,并以 X-Idempotency-Replayed 标明是重放。 +func TestAffiliateHandlerWithdrawQuota_ReplayReturnsFirstResult(t *testing.T) { + repo := &withdrawQuotaRepoStub{result: &service.AffiliateWithdrawResult{ + LedgerID: 9, + UserID: 42, + Amount: 10, + AvailableQuotaAfter: 90, + HistoryQuotaAfter: 100, + Replayed: true, + }} + rec, resp := performWithdrawQuota(t, repo, "42", testWithdrawIdempotencyKey, `{"amount":10}`) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "true", rec.Header().Get("X-Idempotency-Replayed")) + require.JSONEq(t, `{ + "ledger_id": 9, + "user_id": 42, + "amount": 10, + "available_quota_after": 90, + "frozen_quota_after": 0, + "history_quota_after": 100 + }`, string(resp.Data)) +} + +// TestAffiliateHandlerWithdrawQuota_RejectsBadRequests 验证非法用户 ID、非法 JSON、 +// 缺少或非法的幂等键与非法金额在进入仓储前以 400 拒绝。 +func TestAffiliateHandlerWithdrawQuota_RejectsBadRequests(t *testing.T) { + cases := []struct { + name string + userID string + key string + body string + wantReason string + }{ + {name: "non-numeric user id", userID: "abc", key: testWithdrawIdempotencyKey, body: `{"amount":1}`}, + {name: "non-positive user id", userID: "0", key: testWithdrawIdempotencyKey, body: `{"amount":1}`}, + {name: "malformed json", userID: "42", key: testWithdrawIdempotencyKey, body: `{"amount":`}, + {name: "missing idempotency key", userID: "42", body: `{"amount":1}`, wantReason: "IDEMPOTENCY_KEY_REQUIRED"}, + {name: "invalid idempotency key", userID: "42", key: "has space", body: `{"amount":1}`, wantReason: "IDEMPOTENCY_KEY_INVALID"}, + {name: "zero amount", userID: "42", key: testWithdrawIdempotencyKey, body: `{"amount":0}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"}, + {name: "missing amount", userID: "42", key: testWithdrawIdempotencyKey, body: `{}`, wantReason: "AFFILIATE_WITHDRAW_AMOUNT_INVALID"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := &withdrawQuotaRepoStub{} + rec, resp := performWithdrawQuota(t, repo, tc.userID, tc.key, tc.body) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Equal(t, tc.wantReason, resp.Reason) + require.Empty(t, repo.calls) + }) + } +} + +// TestAffiliateHandlerWithdrawQuota_RepositoryRejections 验证额度不足以 400、 +// 同一幂等键换了用户或金额以 409 返回,前端据错误码提示。 +func TestAffiliateHandlerWithdrawQuota_RepositoryRejections(t *testing.T) { + cases := []struct { + err error + wantStatus int + wantReason string + }{ + {err: service.ErrAffiliateQuotaInsufficient, wantStatus: http.StatusBadRequest, wantReason: "AFFILIATE_QUOTA_INSUFFICIENT"}, + {err: service.ErrIdempotencyKeyConflict, wantStatus: http.StatusConflict, wantReason: "IDEMPOTENCY_KEY_CONFLICT"}, + } + for _, tc := range cases { + t.Run(tc.wantReason, func(t *testing.T) { + repo := &withdrawQuotaRepoStub{err: tc.err} + rec, resp := performWithdrawQuota(t, repo, "42", testWithdrawIdempotencyKey, `{"amount":5}`) + + require.Equal(t, tc.wantStatus, rec.Code) + require.Equal(t, tc.wantReason, resp.Reason) + require.Empty(t, rec.Header().Get("X-Idempotency-Replayed")) + require.Len(t, repo.calls, 1) + }) + } +} diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 14e80ac56e..036fbbfca2 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -303,6 +303,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { OpenAICodexClientVersion: settings.OpenAICodexClientVersion, OpenAICodexClientVersionSynced: settings.OpenAICodexClientVersionSynced, OpenAICodexVersionAutoSyncEnabled: settings.OpenAICodexVersionAutoSyncEnabled, + ClaudeCodeClientVersion: settings.ClaudeCodeClientVersion, + ClaudeCodeClientVersionSynced: settings.ClaudeCodeClientVersionSynced, + ClaudeCodeVersionAutoSyncEnabled: settings.ClaudeCodeVersionAutoSyncEnabled, MinCodexVersion: settings.MinCodexVersion, MaxCodexVersion: settings.MaxCodexVersion, CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist, diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index edeeba61d0..b67eb88c49 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -491,6 +491,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.OpenAICodexVersionAutoSyncEnabled != after.OpenAICodexVersionAutoSyncEnabled { changed = append(changed, "openai_codex_version_auto_sync_enabled") } + if before.ClaudeCodeClientVersion != after.ClaudeCodeClientVersion { + changed = append(changed, "claude_code_client_version") + } + if before.ClaudeCodeVersionAutoSyncEnabled != after.ClaudeCodeVersionAutoSyncEnabled { + changed = append(changed, "claude_code_version_auto_sync_enabled") + } if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource { changed = append(changed, "payment_visible_method_alipay_source") } diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 2143cb1f28..8ca112e7f7 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -257,6 +257,8 @@ type UpdateSettingsRequest struct { OpenAICodexUserAgent *string `json:"openai_codex_user_agent"` OpenAICodexClientVersion *string `json:"openai_codex_client_version"` OpenAICodexVersionAutoSyncEnabled *bool `json:"openai_codex_version_auto_sync_enabled"` + ClaudeCodeClientVersion *string `json:"claude_code_client_version"` + ClaudeCodeVersionAutoSyncEnabled *bool `json:"claude_code_version_auto_sync_enabled"` // codex_cli_only 加固(global-only) MinCodexVersion string `json:"min_codex_version"` @@ -1455,6 +1457,15 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } req.OpenAICodexClientVersion = &normalized } + if req.ClaudeCodeClientVersion != nil { + // 该值会被拼进出站 User-Agent 与 billing attribution,必须是合法版本号;空串表示跟随自动同步。 + normalized := strings.TrimSpace(*req.ClaudeCodeClientVersion) + if normalized != "" && service.NormalizeClaudeCodeClientVersion(normalized) == "" { + response.Error(c, http.StatusBadRequest, "claude_code_client_version must be empty or a valid version (e.g. 2.1.258)") + return + } + req.ClaudeCodeClientVersion = &normalized + } // codex_cli_only 加固:最低/最高 Codex 版本(空=禁用,或合法 semver;max>=min) if req.MinCodexVersion != "" && !semverPattern.MatchString(req.MinCodexVersion) { @@ -1768,6 +1779,20 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.OpenAICodexVersionAutoSyncEnabled }(), + ClaudeCodeClientVersion: func() string { + if req.ClaudeCodeClientVersion != nil { + return *req.ClaudeCodeClientVersion + } + return previousSettings.ClaudeCodeClientVersion + }(), + // 同步值由自动同步任务独占写入,面板保存时原样带回,避免被清空。 + ClaudeCodeClientVersionSynced: previousSettings.ClaudeCodeClientVersionSynced, + ClaudeCodeVersionAutoSyncEnabled: func() bool { + if req.ClaudeCodeVersionAutoSyncEnabled != nil { + return *req.ClaudeCodeVersionAutoSyncEnabled + } + return previousSettings.ClaudeCodeVersionAutoSyncEnabled + }(), MinCodexVersion: strings.TrimSpace(req.MinCodexVersion), MaxCodexVersion: strings.TrimSpace(req.MaxCodexVersion), CodexCLIOnlyBlacklist: strings.TrimSpace(req.CodexCLIOnlyBlacklist), @@ -2311,6 +2336,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { OpenAICodexClientVersion: updatedSettings.OpenAICodexClientVersion, OpenAICodexClientVersionSynced: updatedSettings.OpenAICodexClientVersionSynced, OpenAICodexVersionAutoSyncEnabled: updatedSettings.OpenAICodexVersionAutoSyncEnabled, + ClaudeCodeClientVersion: updatedSettings.ClaudeCodeClientVersion, + ClaudeCodeClientVersionSynced: updatedSettings.ClaudeCodeClientVersionSynced, + ClaudeCodeVersionAutoSyncEnabled: updatedSettings.ClaudeCodeVersionAutoSyncEnabled, MinCodexVersion: updatedSettings.MinCodexVersion, MaxCodexVersion: updatedSettings.MaxCodexVersion, CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist, diff --git a/backend/internal/handler/auth_email_oauth_test.go b/backend/internal/handler/auth_email_oauth_test.go index 493b509989..0a6b653ec6 100644 --- a/backend/internal/handler/auth_email_oauth_test.go +++ b/backend/internal/handler/auth_email_oauth_test.go @@ -407,6 +407,10 @@ func (r *oauthEmailAffiliateRepoStub) TransferQuotaToBalance(context.Context, in panic("unexpected TransferQuotaToBalance call") } +func (r *oauthEmailAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64, string) (*service.AffiliateWithdrawResult, error) { + panic("unexpected WithdrawQuota call") +} + func (r *oauthEmailAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]service.AffiliateInvitee, error) { panic("unexpected ListInvitees call") } diff --git a/backend/internal/handler/dto/account_list_item_opencode_usage_test.go b/backend/internal/handler/dto/account_list_item_opencode_usage_test.go new file mode 100644 index 0000000000..74997e8e2a --- /dev/null +++ b/backend/internal/handler/dto/account_list_item_opencode_usage_test.go @@ -0,0 +1,57 @@ +package dto + +import ( + "encoding/json" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +// 账号列表页固定传 lite=1,lite 投影(AccountListItemFromAccount)必须与完整 +// Account DTO 一样携带 opencode_go_usage,否则前端用量单元格会退回「余额 --」。 +func TestAccountListItemFromAccount_CarriesOpenCodeGoUsage(t *testing.T) { + now := time.Now().UTC() + // 生产账号 164 的形态:deepseek 平台 + OpenCode Go 官方基址 + apikey + eligible := &service.Account{ + ID: 164, Name: "DeepSeek OpenCode", Platform: service.PlatformDeepseek, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://opencode.ai/zen/go/v1", "api_key": "test-key"}, + Extra: map[string]any{ + service.OpenCodeGoUsageAutoRefreshExtraKey: true, + service.OpenCodeGoUsageSnapshotExtraKey: &service.OpenCodeGoUsageSnapshot{ + Status: service.OpenCodeGoUsageStatusOK, + Data: &service.OpenCodeGoUsageData{Rolling: service.OpenCodeGoUsageWindow{Status: "ok", Percent: 6}}, + LastAttemptAt: now, NextRefreshAt: now.Add(time.Hour), + }, + }, + Status: service.StatusActive, + } + + item := AccountListItemFromAccount(AccountFromServiceShallow(eligible)) + require.NotNil(t, item.OpenCodeGoUsage) + require.True(t, item.OpenCodeGoUsage.Eligible) + require.True(t, item.OpenCodeGoUsage.AutoRefreshEnabled) + require.NotNil(t, item.OpenCodeGoUsage.Snapshot) + require.NotNil(t, item.OpenCodeGoUsage.Snapshot.Data) + require.Equal(t, 6.0, item.OpenCodeGoUsage.Snapshot.Data.Rolling.Percent) + + raw, err := json.Marshal(item) + require.NoError(t, err) + require.Contains(t, string(raw), `"opencode_go_usage"`) + require.Contains(t, string(raw), `"eligible":true`) + + // 对照:base_url 指向 api.deepseek.com 的普通 deepseek 账号不参与用量窗口 + ineligible := &service.Account{ + ID: 165, Name: "DeepSeek Direct", Platform: service.PlatformDeepseek, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://api.deepseek.com", "api_key": "test-key-2"}, + Status: service.StatusActive, + } + + plainItem := AccountListItemFromAccount(AccountFromServiceShallow(ineligible)) + require.Nil(t, plainItem.OpenCodeGoUsage) + + plainRaw, err := json.Marshal(plainItem) + require.NoError(t, err) + require.NotContains(t, string(plainRaw), `"opencode_go_usage"`) +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index bb29600c67..42a1d82594 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -241,6 +241,10 @@ func AccountFromServiceShallow(a *service.Account) *Account { if state := service.OllamaCloudUsageStateFromAccount(a); state.Eligible { ollamaCloudUsage = state } + var openCodeGoUsage *service.OpenCodeGoUsageState + if state := service.OpenCodeGoUsageStateFromAccount(a); state.Eligible { + openCodeGoUsage = state + } out := &Account{ ID: a.ID, Name: a.Name, @@ -251,6 +255,7 @@ func AccountFromServiceShallow(a *service.Account) *Account { CredentialsStatus: credsStatus, Extra: extra, OllamaCloudUsage: ollamaCloudUsage, + OpenCodeGoUsage: openCodeGoUsage, ProxyID: a.ProxyID, ProxyFallbackOriginID: a.ProxyFallbackOriginID, ProxyFallbackOriginName: a.ProxyFallbackOriginName, @@ -417,7 +422,9 @@ func redactAccountManagedExtra(extra map[string]any) map[string]any { switch key { case service.OllamaCloudUsageSessionExtraKey, service.OllamaCloudUsageAutoRefreshExtraKey, - service.OllamaCloudUsageSnapshotExtraKey: + service.OllamaCloudUsageSnapshotExtraKey, + service.OpenCodeGoUsageAutoRefreshExtraKey, + service.OpenCodeGoUsageSnapshotExtraKey: continue default: redacted[key] = value @@ -458,8 +465,8 @@ func AccountListItemFromAccount(a *Account) *AccountListItem { return &AccountListItem{ ID: a.ID, Name: a.Name, Notes: a.Notes, Platform: a.Platform, Type: a.Type, Credentials: a.Credentials, CredentialsStatus: a.CredentialsStatus, Extra: a.Extra, - OllamaCloudUsage: a.OllamaCloudUsage, - ProxyID: a.ProxyID, ProxyFallbackOriginID: a.ProxyFallbackOriginID, ProxyFallbackOriginName: a.ProxyFallbackOriginName, + OllamaCloudUsage: a.OllamaCloudUsage, OpenCodeGoUsage: a.OpenCodeGoUsage, + ProxyID: a.ProxyID, ProxyFallbackOriginID: a.ProxyFallbackOriginID, ProxyFallbackOriginName: a.ProxyFallbackOriginName, Concurrency: a.Concurrency, LoadFactor: a.LoadFactor, Priority: a.Priority, RateMultiplier: a.RateMultiplier, Status: a.Status, ErrorMessage: a.ErrorMessage, LastUsedAt: a.LastUsedAt, ExpiresAt: a.ExpiresAt, AutoPauseOnExpired: a.AutoPauseOnExpired, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index cac990e520..72f2a56cbc 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -217,6 +217,9 @@ type SystemSettings struct { OpenAICodexClientVersion string `json:"openai_codex_client_version"` OpenAICodexClientVersionSynced string `json:"openai_codex_client_version_synced"` OpenAICodexVersionAutoSyncEnabled bool `json:"openai_codex_version_auto_sync_enabled"` + ClaudeCodeClientVersion string `json:"claude_code_client_version"` + ClaudeCodeClientVersionSynced string `json:"claude_code_client_version_synced"` + ClaudeCodeVersionAutoSyncEnabled bool `json:"claude_code_version_auto_sync_enabled"` // codex_cli_only 加固 MinCodexVersion string `json:"min_codex_version"` diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 7c952af6d2..73c02a7af7 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -216,6 +216,7 @@ type Account struct { CredentialsStatus map[string]bool `json:"credentials_status,omitempty"` Extra map[string]any `json:"extra"` OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage,omitempty"` + OpenCodeGoUsage *service.OpenCodeGoUsageState `json:"opencode_go_usage,omitempty"` ProxyID *int64 `json:"proxy_id"` ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"` ProxyFallbackOriginName *string `json:"proxy_fallback_origin_name,omitempty"` @@ -340,6 +341,7 @@ type AccountListItem struct { CredentialsStatus map[string]bool `json:"credentials_status,omitempty"` Extra map[string]any `json:"extra,omitempty"` OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage,omitempty"` + OpenCodeGoUsage *service.OpenCodeGoUsageState `json:"opencode_go_usage,omitempty"` ProxyID *int64 `json:"proxy_id"` ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"` diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 81df742e9b..5231f4c095 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -1479,3 +1479,34 @@ func TestGatewayModels_CodexGeminiGroupListsAntigravityGeminiMappings(t *testing require.True(t, strings.HasPrefix(slug, "gemini-"), "unexpected non-gemini model on gemini group: %s", slug) } } + +func TestGatewayModels_GPT6SolLunaDiscoveryRespectsGroupAndAccountRestrictions(t *testing.T) { + for _, tc := range []struct { + name string + selected []string + restricted bool + want []string + }{ + {"selected and ordered", []string{"gpt-6-luna", "gpt-6-sol"}, false, []string{"gpt-6-luna", "gpt-6-sol"}}, + {"group excludes new models", []string{"gpt-5.6-sol"}, false, []string{"gpt-5.6-sol"}}, + {"account restricts new models", []string{"gpt-6-sol", "gpt-6-luna", "gpt-5.6-sol"}, true, []string{"gpt-5.6-sol"}}, + } { + t.Run(tc.name, func(t *testing.T) { + groupID := int64(25) + account := service.Account{ID: 1, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey} + if tc.restricted { + account.Credentials = map[string]any{"model_mapping": map[string]any{"gpt-5.6-sol": "gpt-5.6-sol"}} + } + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{groupID: {account}}}) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: tc.selected}}}) + h.Models(c) + require.Equal(t, http.StatusOK, rec.Code) + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, tc.want, modelIDsForTest(got.Data)) + }) + } +} diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index d35245ecbf..bac8e0a3a0 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -49,9 +49,11 @@ func ProvideAdminHandlers( auditLogHandler *admin.AuditLogHandler, upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, + opencodeGoUsage *service.OpenCodeGoUsageService, ) *AdminHandlers { accountHandler.SetUpstreamBillingProbeService(upstreamBillingProbe) accountHandler.SetOllamaCloudUsageService(ollamaCloudUsage) + accountHandler.SetOpenCodeGoUsageService(opencodeGoUsage) return &AdminHandlers{ Dashboard: dashboardHandler, User: userHandler, diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index 539ccda0d7..d823393cb2 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -1819,6 +1819,72 @@ func TestAnthropicEventToResponses_CacheTokensFromMessageDelta(t *testing.T) { assert.Equal(t, 11, completed.Response.Usage.InputTokensDetails.CachedTokens) } +func TestOpus55ResponsesAdaptiveThinkingAndToolChoice(t *testing.T) { + for _, effort := range []string{"", "low", "medium", "high", "xhigh", "max"} { + req := &ResponsesRequest{Model: "claude-opus-5-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: effort}} + out, err := ResponsesToAnthropicRequest(req) + require.NoError(t, err) + require.Equal(t, "adaptive", out.Thinking.Type) + require.Zero(t, out.Thinking.BudgetTokens) + if effort == "" { + effort = "medium" + } + require.Equal(t, effort, out.OutputConfig.Effort) + } + for _, choice := range []string{`"required"`, `{"type":"function","name":"lookup"}`} { + _, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-opus-5-5", Input: json.RawMessage(`"hello"`), ToolChoice: json.RawMessage(choice)}) + require.ErrorContains(t, err, "forced tool_choice") + } + _, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-opus-5-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: "none"}}) + require.ErrorContains(t, err, "reasoning effort") + old, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-opus-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: "xhigh"}}) + require.NoError(t, err) + require.Equal(t, "max", old.OutputConfig.Effort) + require.Equal(t, "enabled", old.Thinking.Type) +} + +func TestOpus55SignedThinkingResponsesRoundTrip(t *testing.T) { + block := AnthropicContentBlock{Type: "thinking", Thinking: "", Signature: "upstream-signed-block"} + response := AnthropicToResponsesResponse(&AnthropicResponse{Model: "claude-opus-5-5", Content: []AnthropicContentBlock{block, {Type: "tool_use", ID: "toolu_1", Name: "lookup", Input: json.RawMessage(`{}`)}}}) + require.Len(t, response.Output, 2) + require.NotEmpty(t, response.Output[0].EncryptedContent) + raw, err := json.Marshal(response.Output) + require.NoError(t, err) + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(raw, &items)) + items = append(items, ResponsesInputItem{Type: "function_call_output", CallID: response.Output[1].CallID, Output: "ok"}) + raw, err = json.Marshal(items) + require.NoError(t, err) + converted, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-opus-5-5", Input: raw}) + require.NoError(t, err) + require.Len(t, converted.Messages, 2) + var blocks []AnthropicContentBlock + require.NoError(t, json.Unmarshal(converted.Messages[0].Content, &blocks)) + require.Equal(t, block, blocks[0]) + require.Equal(t, "tool_use", blocks[1].Type) + // Arbitrary OpenAI ciphertext must never be treated as an Anthropic signature. + _, _, err = convertResponsesInputToAnthropic("", json.RawMessage(`[{"type":"reasoning","encrypted_content":"anthropic-thinking-v1:!"}]`), true) + require.Error(t, err) +} + +func TestGPT6ChatSamplingAndCacheFields(t *testing.T) { + temperature := 0.7 + for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { + for _, effort := range []string{"", "none", "medium", "max"} { + out, err := ChatCompletionsToResponses(&ChatCompletionsRequest{Model: model, ReasoningEffort: effort, Temperature: &temperature, TopP: &temperature, PromptCacheOptions: json.RawMessage(`{"mode":"explicit","ttl":"30m"}`), Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`[{"type":"text","text":"hello","prompt_cache_breakpoint":{"mode":"explicit"}}]`)}}}) + require.NoError(t, err) + if effort == "none" { + require.NotNil(t, out.Temperature) + } else { + require.Nil(t, out.Temperature) + require.Nil(t, out.TopP) + } + require.JSONEq(t, `{"mode":"explicit","ttl":"30m"}`, string(out.PromptCacheOptions)) + require.Contains(t, string(out.Input), "prompt_cache_breakpoint") + } + } +} + func TestMessageStartSSE_StopReasonIsJSONNull(t *testing.T) { state := NewResponsesEventToAnthropicState() state.Model = "grok-4.5" diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses.go b/backend/internal/pkg/apicompat/anthropic_to_responses.go index dfbe7b5e05..23acc0bea8 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" ) // AnthropicToResponses converts an Anthropic Messages request directly into @@ -470,7 +472,7 @@ func boolPtr(v bool) *bool { // All gpt-5.x models are reasoning-only; the Responses API returns // "Unsupported parameter: temperature" if these fields are present. func isReasoningModel(model string) bool { - return strings.HasPrefix(model, "gpt-5") + return strings.HasPrefix(model, "gpt-5") || openai.IsGPT6SolOrLunaModelSpelling(model) } // normalizeToolParameters ensures the tool parameter schema is valid for diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go index f270050d43..f4c52fcef6 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go @@ -2,16 +2,32 @@ package apicompat import ( "crypto/rand" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" ) // --------------------------------------------------------------------------- // Non-streaming: AnthropicResponse → ResponsesResponse // --------------------------------------------------------------------------- +const anthropicThinkingEnvelopePrefix = "anthropic-thinking-v1:" + +func encodeAnthropicThinking(block AnthropicContentBlock) string { + // Only fields belonging to the signed thinking block are retained. + payload, _ := json.Marshal(struct { + Type string `json:"type"` + Thinking string `json:"thinking"` + Signature string `json:"signature,omitempty"` + Data string `json:"data,omitempty"` + }{block.Type, block.Thinking, block.Signature, block.Data}) + return anthropicThinkingEnvelopePrefix + base64.RawStdEncoding.EncodeToString(payload) +} + // AnthropicToResponsesResponse converts an Anthropic Messages response into a // Responses API response. This is the reverse of ResponsesToAnthropic and // enables Anthropic upstream responses to be returned in OpenAI Responses format. @@ -35,7 +51,15 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse { for _, block := range resp.Content { switch block.Type { - case "thinking": + case "thinking", "redacted_thinking": + if claude.IsOpus55(resp.Model) && (block.Signature != "" || block.Data != "") { + item := ResponsesOutput{Type: "reasoning", ID: generateItemID(), EncryptedContent: encodeAnthropicThinking(block)} + if block.Thinking != "" { + item.Summary = []ResponsesSummary{{Type: "summary_text", Text: block.Thinking}} + } + outputs = append(outputs, item) + continue + } if block.Thinking != "" { outputs = append(outputs, ResponsesOutput{ Type: "reasoning", @@ -47,6 +71,10 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse { }) } case "text": + if claude.IsOpus55(resp.Model) && block.Text != "" { + outputs = append(outputs, ResponsesOutput{Type: "message", ID: generateItemID(), Role: "assistant", Status: "completed", Content: []ResponsesContentPart{{Type: "output_text", Text: block.Text}}}) + continue + } if block.Text != "" { msgParts = append(msgParts, ResponsesContentPart{ Type: "output_text", @@ -164,9 +192,11 @@ type AnthropicEventToResponsesState struct { CurrentName string // Content of the currently open item, folded into Outputs when it closes. - CurrentContent []ResponsesContentPart // message - CurrentArgs string // function_call - CurrentSummary string // reasoning + CurrentContent []ResponsesContentPart // message + CurrentArgs string // function_call + CurrentSummary string // reasoning + CurrentThinking AnthropicContentBlock + PreserveThinkingSignatures bool // Outputs accumulates every closed output item so that response.completed // can carry the full output list. The OpenAI SDK's get_final_response() @@ -248,6 +278,7 @@ func ResponsesEventToSSE(evt ResponsesStreamEvent) (string, error) { func anthToResHandleMessageStart(evt *AnthropicStreamEvent, state *AnthropicEventToResponsesState) []ResponsesStreamEvent { if evt.Message != nil { state.ResponseID = evt.Message.ID + state.PreserveThinkingSignatures = state.PreserveThinkingSignatures || claude.IsOpus55(evt.Message.Model) if state.Model == "" { state.Model = evt.Message.Model } @@ -279,7 +310,7 @@ func anthToResHandleContentBlockStart(evt *AnthropicStreamEvent, state *Anthropi var events []ResponsesStreamEvent switch evt.ContentBlock.Type { - case "thinking": + case "thinking", "redacted_thinking": // 开新 item 前必须先关掉在开的那个,与下面的 tool_use 分支一致。 // 一个 message item 在它的 text 块 content_block_stop 时是刻意保持打开的 // (同一 item 里可能还有后续 text 块),所以 thinking 块到来时它仍然开着: @@ -292,6 +323,8 @@ func anthToResHandleContentBlockStart(evt *AnthropicStreamEvent, state *Anthropi state.CurrentItemID = generateItemID() state.CurrentItemType = "reasoning" + state.CurrentThinking = *evt.ContentBlock + state.CurrentSummary = evt.ContentBlock.Thinking state.ContentIndex = 0 events = append(events, makeResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{ @@ -409,7 +442,10 @@ func anthToResHandleContentBlockDelta(evt *AnthropicStreamEvent, state *Anthropi })} case "signature_delta": - // Anthropic signature deltas have no Responses equivalent; skip + // Keep signatures in the opaque bridge envelope, never in visible text. + if state.PreserveThinkingSignatures { + state.CurrentThinking.Signature += evt.Delta.Signature + } return nil } @@ -552,6 +588,10 @@ func closeCurrentResponsesItem(state *AnthropicEventToResponsesState) []Response } item.Arguments = args case "reasoning": + if state.PreserveThinkingSignatures && (state.CurrentThinking.Signature != "" || state.CurrentThinking.Data != "") { + state.CurrentThinking.Thinking = state.CurrentSummary + item.EncryptedContent = encodeAnthropicThinking(state.CurrentThinking) + } if state.CurrentSummary != "" { item.Summary = []ResponsesSummary{{Type: "summary_text", Text: state.CurrentSummary}} } @@ -566,6 +606,7 @@ func closeCurrentResponsesItem(state *AnthropicEventToResponsesState) []Response state.CurrentContent = nil state.CurrentArgs = "" state.CurrentSummary = "" + state.CurrentThinking = AnthropicContentBlock{} state.TextAccum = "" state.OutputIndex++ state.ContentIndex = 0 diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index f72b5faf6d..773b12b69a 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" ) type chatMessageContent struct { @@ -27,18 +29,19 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, } out := &ResponsesRequest{ - Model: req.Model, - Instructions: req.Instructions, - Input: inputJSON, - Stream: true, // upstream always streams - Include: []string{"reasoning.encrypted_content"}, - ServiceTier: req.ServiceTier, - ParallelToolCalls: req.ParallelToolCalls, + Model: req.Model, + Instructions: req.Instructions, + Input: inputJSON, + Stream: true, // upstream always streams + Include: []string{"reasoning.encrypted_content"}, + ServiceTier: req.ServiceTier, + PromptCacheOptions: req.PromptCacheOptions, + ParallelToolCalls: req.ParallelToolCalls, } // Reasoning models (gpt-5.x) do not accept sampling parameters. // See isReasoningModel in anthropic_to_responses.go. - if !isReasoningModel(req.Model) { + if !isReasoningModel(req.Model) || (openai.IsGPT6SolOrLunaModelSpelling(req.Model) && req.ReasoningEffort == "none") { out.Temperature = req.Temperature out.TopP = req.TopP } @@ -365,26 +368,29 @@ func convertChatContentPartsToResponses(parts []ChatContentPart) []ResponsesCont for _, p := range parts { switch p.Type { case "text": - if p.Text != "" { + if p.Text != "" || len(p.PromptCacheBreakpoint) > 0 { responseParts = append(responseParts, ResponsesContentPart{ - Type: "input_text", - Text: p.Text, + PromptCacheBreakpoint: p.PromptCacheBreakpoint, + Type: "input_text", + Text: p.Text, }) } case "image_url": if p.ImageURL != nil && p.ImageURL.URL != "" && !isEmptyBase64DataURI(p.ImageURL.URL) { responseParts = append(responseParts, ResponsesContentPart{ - Type: "input_image", - ImageURL: p.ImageURL.URL, + PromptCacheBreakpoint: p.PromptCacheBreakpoint, + Type: "input_image", + ImageURL: p.ImageURL.URL, }) } case "file": if p.File != nil && (p.File.FileData != "" || p.File.FileID != "") { responseParts = append(responseParts, ResponsesContentPart{ - Type: "input_file", - Filename: p.File.Filename, - FileData: p.File.FileData, - FileID: p.File.FileID, + PromptCacheBreakpoint: p.PromptCacheBreakpoint, + Type: "input_file", + Filename: p.File.Filename, + FileData: p.File.FileData, + FileID: p.File.FileID, }) } } diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go index b63787cd44..4c113fefb2 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_instructions_test.go @@ -80,7 +80,7 @@ func TestConvertResponsesInputToAnthropic_DeveloperRole(t *testing.T) { {"role":"user","content":"review this code"} ]` - system, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input)) + system, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input), false) require.NoError(t, err) var systemText string @@ -97,7 +97,7 @@ func TestConvertResponsesInputToAnthropic_DeveloperRole(t *testing.T) { {"role":"user","content":"hi"} ]` - _, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input)) + _, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input), false) require.NoError(t, err) for _, m := range messages { @@ -116,7 +116,7 @@ func TestConvertResponsesInputToAnthropic_DeveloperRole(t *testing.T) { {"role":"user","content":"hello"} ]` - system, _, err := convertResponsesInputToAnthropic("Main instruction.", json.RawMessage(input)) + system, _, err := convertResponsesInputToAnthropic("Main instruction.", json.RawMessage(input), false) require.NoError(t, err) var systemText string diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go index 484fa98dba..06d9669d29 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go @@ -1,9 +1,12 @@ package apicompat import ( + "encoding/base64" "encoding/json" "fmt" "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" ) // ResponsesToAnthropicRequest converts a Responses API request into an @@ -11,7 +14,7 @@ import ( // enables Anthropic platform groups to accept OpenAI Responses API requests // by converting them to the native /v1/messages format before forwarding upstream. func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, error) { - system, messages, err := convertResponsesInputToAnthropic(req.Instructions, req.Input) + system, messages, err := convertResponsesInputToAnthropic(req.Instructions, req.Input, claude.IsOpus55(req.Model)) if err != nil { return nil, err } @@ -51,6 +54,34 @@ func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, erro out.ToolChoice = tc } + // Opus 5.5 always uses adaptive thinking. Resolve the upstream model before + // conversion, because client aliases need not identify a Claude model. + if claude.IsOpus55(req.Model) { + var choice struct { + Type string `json:"type"` + } + if len(out.ToolChoice) > 0 { + if err := json.Unmarshal(out.ToolChoice, &choice); err != nil { + return nil, fmt.Errorf("invalid tool_choice: %w", err) + } + } + if choice.Type == "any" || choice.Type == "tool" { + return nil, fmt.Errorf("claude-opus-5-5 does not support forced tool_choice; use auto or none") + } + effort := "medium" + if req.Reasoning != nil && req.Reasoning.Effort != "" { + effort = req.Reasoning.Effort + } + switch effort { + case "low", "medium", "high", "xhigh", "max": + default: + return nil, fmt.Errorf("claude-opus-5-5 does not support reasoning effort %q; use low, medium, high, xhigh or max", effort) + } + out.Thinking = &AnthropicThinking{Type: "adaptive"} + out.OutputConfig = &AnthropicOutputConfig{Effort: effort} + return out, nil + } + // reasoning.effort → output_config.effort + thinking if req.Reasoning != nil && req.Reasoning.Effort != "" { effort := mapResponsesEffortToAnthropic(req.Reasoning.Effort) @@ -100,7 +131,7 @@ func mapResponsesEffortToAnthropic(effort string) string { // convertResponsesInputToAnthropic extracts system prompt and messages from // a Responses API instructions + input array. Returns the system as raw JSON // (for Anthropic's polymorphic system field) and a list of Anthropic messages. -func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMessage) (json.RawMessage, []AnthropicMessage, error) { +func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMessage, preserveThinking bool) (json.RawMessage, []AnthropicMessage, error) { var systemParts []string if strings.TrimSpace(instructions) != "" { systemParts = append(systemParts, strings.TrimSpace(instructions)) @@ -165,11 +196,26 @@ func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMess }) case item.Type == "reasoning": - // Anthropic 无法摄入 OpenAI 的 reasoning:encrypted_content 是不透明的, - // 而 thinking 块的重放需要 Anthropic 自己签发的 signature,无法伪造。 - // Codex 常见形态(只带 summary + encrypted_content)本来就会被丢弃, - // 这里让带 content 数组的形态保持同样行为——否则 reasoning_text 块会被 - // 原样塞进 Anthropic 请求体,上游直接回 400。 + // Only decode marked Anthropic bridge envelopes, not arbitrary + // OpenAI ciphertext. The upstream remains responsible for signature validation. + if preserveThinking && strings.HasPrefix(item.EncryptedContent, anthropicThinkingEnvelopePrefix) { + raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(item.EncryptedContent, anthropicThinkingEnvelopePrefix)) + if err != nil { + return nil, nil, fmt.Errorf("invalid Anthropic thinking envelope: %w", err) + } + var block AnthropicContentBlock + if err := json.Unmarshal(raw, &block); err != nil { + return nil, nil, fmt.Errorf("invalid Anthropic thinking block: %w", err) + } + if (block.Type != "thinking" || block.Signature == "") && (block.Type != "redacted_thinking" || block.Data == "") { + return nil, nil, fmt.Errorf("invalid Anthropic signed thinking block") + } + content, err := json.Marshal([]AnthropicContentBlock{block}) + if err != nil { + return nil, nil, err + } + messages = append(messages, AnthropicMessage{Role: "assistant", Content: content}) + } case item.Role == "user": content, err := convertResponsesUserToAnthropicContent(item.Content) diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go b/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go index 42dd2d0cc5..db87f2058c 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic_tool_pairing_test.go @@ -58,7 +58,7 @@ func hasToolResult(blocks []AnthropicContentBlock, toolUseID string) bool { func convertAnthropic(t *testing.T, input string) []AnthropicMessage { t.Helper() - _, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input)) + _, messages, err := convertResponsesInputToAnthropic("", json.RawMessage(input), false) require.NoError(t, err) assertAnthropicPairing(t, messages) return messages diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index 5d89769b15..44a5b4c79c 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -66,6 +66,7 @@ type AnthropicContentBlock struct { // Signature carries provider encrypted reasoning (e.g. xAI encrypted_content) // so multi-turn Claude clients can round-trip it back on subsequent turns. Signature string `json:"signature,omitempty"` + Data string `json:"data,omitempty"` // redacted_thinking // type=image Source *AnthropicImageSource `json:"source,omitempty"` @@ -226,6 +227,7 @@ type AnthropicDelta struct { // ResponsesRequest is the request body for POST /v1/responses. type ResponsesRequest struct { + PromptCacheOptions json.RawMessage `json:"prompt_cache_options,omitempty"` Model string `json:"model"` Instructions string `json:"instructions,omitempty"` Input json.RawMessage `json:"input"` // string or []ResponsesInputItem @@ -309,9 +311,10 @@ func (i *ResponsesInputItem) UnmarshalJSON(data []byte) error { // ResponsesContentPart is a typed content part in a Responses message. type ResponsesContentPart struct { - Type string `json:"type"` // "input_text" | "output_text" | "input_image" | "input_file" - Text string `json:"text,omitempty"` - ImageURL string `json:"image_url,omitempty"` // data URI for input_image + PromptCacheBreakpoint json.RawMessage `json:"prompt_cache_breakpoint,omitempty"` + Type string `json:"type"` // "input_text" | "output_text" | "input_image" | "input_file" + Text string `json:"text,omitempty"` + ImageURL string `json:"image_url,omitempty"` // data URI for input_image // input_file fields. Filename string `json:"filename,omitempty"` @@ -655,6 +658,7 @@ type ResponsesStreamEvent struct { // ChatCompletionsRequest is the request body for POST /v1/chat/completions. type ChatCompletionsRequest struct { + PromptCacheOptions json.RawMessage `json:"prompt_cache_options,omitempty"` Model string `json:"model"` Messages []ChatMessage `json:"messages"` Instructions string `json:"instructions,omitempty"` // OpenAI Responses API compat @@ -698,10 +702,11 @@ type ChatMessage struct { // ChatContentPart is a typed content part in a multi-modal message. type ChatContentPart struct { - Type string `json:"type"` // "text" | "image_url" | "file" - Text string `json:"text,omitempty"` - ImageURL *ChatImageURL `json:"image_url,omitempty"` - File *ChatFile `json:"file,omitempty"` + PromptCacheBreakpoint json.RawMessage `json:"prompt_cache_breakpoint,omitempty"` + Type string `json:"type"` // "text" | "image_url" | "file" + Text string `json:"text,omitempty"` + ImageURL *ChatImageURL `json:"image_url,omitempty"` + File *ChatFile `json:"file,omitempty"` } // ChatImageURL contains the URL for an image content part. diff --git a/backend/internal/pkg/claude/cli_version_runtime.go b/backend/internal/pkg/claude/cli_version_runtime.go new file mode 100644 index 0000000000..df0a2948d3 --- /dev/null +++ b/backend/internal/pkg/claude/cli_version_runtime.go @@ -0,0 +1,43 @@ +package claude + +import "sync/atomic" + +// cliVersionResolver 返回运行期生效的 Claude CLI 版本号(管理员面板手动值 → +// 后台同步值)。由 SettingService 在装配时注入(wire.go),解析器内部自带 +// TTL 缓存,热路径不触库。 +// +// 未注入时(所有不经过 wire 的单元测试)EffectiveCLIVersion() 与 CLIVersion() +// 完全等价,行为不变。 +type cliVersionResolverFunc func() string + +var cliVersionResolver atomic.Pointer[cliVersionResolverFunc] + +// SetCLIVersionResolver 注入运行期版本解析器;传 nil 可撤销注入。 +func SetCLIVersionResolver(resolver func() string) { + if resolver == nil { + cliVersionResolver.Store(nil) + return + } + r := cliVersionResolverFunc(resolver) + cliVersionResolver.Store(&r) +} + +// EffectiveCLIVersion 返回当前生效的 Claude CLI 版本号。 +// resolver 返回值必须通过 IsSupportedCLIVersion 校验(严格三段 semver 且不低于 +// 内置基线),否则回退 CLIVersion()(环境变量覆盖 → 内置基线)。 +// 运行期值恒 >= 内置基线,identity_service 的"只升不降"语义不受影响。 +func EffectiveCLIVersion() string { + if r := cliVersionResolver.Load(); r != nil { + if v := (*r)(); IsSupportedCLIVersion(v) { + return v + } + } + return CLIVersion() +} + +// DefaultUserAgent 返回 Claude Code CLI 伪装 User-Agent。 +// 所有出站路径都必须经本函数取 UA,保证 User-Agent 头与请求体 billing +// attribution 块中的 cc_version 始终源自同一个版本号。 +func DefaultUserAgent() string { + return "claude-cli/" + EffectiveCLIVersion() + " (external, cli)" +} diff --git a/backend/internal/pkg/claude/cli_version_runtime_test.go b/backend/internal/pkg/claude/cli_version_runtime_test.go new file mode 100644 index 0000000000..01ba5d0778 --- /dev/null +++ b/backend/internal/pkg/claude/cli_version_runtime_test.go @@ -0,0 +1,73 @@ +package claude + +import ( + "strings" + "testing" +) + +// withCLIVersionResolver 注入测试解析器并在用例结束时还原,避免用例间污染。 +func withCLIVersionResolver(t *testing.T, resolver func() string) { + t.Helper() + SetCLIVersionResolver(resolver) + t.Cleanup(func() { SetCLIVersionResolver(nil) }) +} + +// a. resolver 未注入时,EffectiveCLIVersion() 必须等于 CLIVersion(), +// 且 DefaultHeaders()["User-Agent"] 与改造前的 init 固化值完全一致。 +func TestEffectiveCLIVersionDefaultsToCLIVersion(t *testing.T) { + SetCLIVersionResolver(nil) + if got := EffectiveCLIVersion(); got != CLIVersion() { + t.Fatalf("EffectiveCLIVersion() = %q, want CLIVersion() = %q", got, CLIVersion()) + } + wantUA := "claude-cli/" + CLIVersion() + " (external, cli)" + if got := DefaultHeaders()["User-Agent"]; got != wantUA { + t.Fatalf("DefaultHeaders()[User-Agent] = %q, want %q", got, wantUA) + } +} + +// b. 注入返回更高版本的 resolver 后,DefaultHeaders()["User-Agent"] 跟随变化。 +func TestEffectiveCLIVersionFollowsResolver(t *testing.T) { + const upgraded = "9.9.9" + withCLIVersionResolver(t, func() string { return upgraded }) + + if got := EffectiveCLIVersion(); got != upgraded { + t.Fatalf("EffectiveCLIVersion() = %q, want %q", got, upgraded) + } + wantUA := "claude-cli/" + upgraded + " (external, cli)" + if got := DefaultHeaders()["User-Agent"]; got != wantUA { + t.Fatalf("DefaultHeaders()[User-Agent] = %q, want %q", got, wantUA) + } + if got := DefaultHeaders()["X-App"]; got != "cli" { + t.Fatalf("DefaultHeaders()[X-App] = %q, want cli", got) + } +} + +// c. resolver 返回非法值(空串、非 semver、低于内置基线)时回退 CLIVersion()。 +func TestEffectiveCLIVersionFallsBackOnInvalidResolverValues(t *testing.T) { + for name, invalid := range map[string]string{ + "empty": "", + "not semver": "abc", + "below baseline": "1.0.0", + "whitespace": " ", + } { + t.Run(name, func(t *testing.T) { + withCLIVersionResolver(t, func() string { return invalid }) + if got := EffectiveCLIVersion(); got != CLIVersion() { + t.Fatalf("resolver %q: EffectiveCLIVersion() = %q, want fallback %q", invalid, got, CLIVersion()) + } + ua := DefaultHeaders()["User-Agent"] + if !strings.HasPrefix(ua, "claude-cli/"+CLIVersion()+" ") { + t.Fatalf("UA %q does not carry fallback version %q", ua, CLIVersion()) + } + }) + } +} + +// 撤销注入(传 nil)后回到 CLIVersion()。 +func TestSetCLIVersionResolverNilRestoresFallback(t *testing.T) { + withCLIVersionResolver(t, func() string { return "9.9.9" }) + SetCLIVersionResolver(nil) + if got := EffectiveCLIVersion(); got != CLIVersion() { + t.Fatalf("after nil resolver: EffectiveCLIVersion() = %q, want %q", got, CLIVersion()) + } +} diff --git a/backend/internal/pkg/claude/cli_version_test.go b/backend/internal/pkg/claude/cli_version_test.go index 89f8db33a8..72ec94cfd5 100644 --- a/backend/internal/pkg/claude/cli_version_test.go +++ b/backend/internal/pkg/claude/cli_version_test.go @@ -60,7 +60,7 @@ func TestResolveCLIVersion(t *testing.T) { // 两者由不同代码路径写入同一个请求,不一致会被上游判为非正版客户端。 func TestDefaultHeadersUserAgentMatchesCLIVersion(t *testing.T) { want := "claude-cli/" + CLIVersion() + " (external, cli)" - if got := DefaultHeaders["User-Agent"]; got != want { + if got := DefaultHeaders()["User-Agent"]; got != want { t.Fatalf("DefaultHeaders[User-Agent] = %q, want %q", got, want) } } diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index ee2efcd29f..c6b13f4ed1 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -108,21 +108,26 @@ func FullClaudeCodeMimicryBetas() []string { } // DefaultHeaders 是 Claude Code 客户端默认请求头。 -var DefaultHeaders = map[string]string{ - // Keep these in sync with recent Claude CLI traffic to reduce the chance - // that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage. - // 版本参考:对齐 Parrot (src/transform/cc_mimicry.py:49) 的 CLI_USER_AGENT。 - "User-Agent": "claude-cli/" + CLIVersion() + " (external, cli)", - "X-Stainless-Lang": "js", - "X-Stainless-Package-Version": "0.94.0", - "X-Stainless-OS": "Linux", - "X-Stainless-Arch": "arm64", - "X-Stainless-Runtime": "node", - "X-Stainless-Runtime-Version": "v24.3.0", - "X-Stainless-Retry-Count": "0", - "X-Stainless-Timeout": "600", - "X-App": "cli", - "Anthropic-Dangerous-Direct-Browser-Access": "true", +// 每次调用现构造:User-Agent 走 DefaultUserAgent()(运行期可变版本号), +// 不再在包 init 时固化。同一次请求内应只取一次 UA 字符串并在出站头与 +// billing 两条路径间复用,避免版本缓存翻转瞬间头/体不一致。 +func DefaultHeaders() map[string]string { + return map[string]string{ + // Keep these in sync with recent Claude CLI traffic to reduce the chance + // that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage. + // 版本参考:对齐 Parrot (src/transform/cc_mimicry.py:49) 的 CLI_USER_AGENT。 + "User-Agent": DefaultUserAgent(), + "X-Stainless-Lang": "js", + "X-Stainless-Package-Version": "0.94.0", + "X-Stainless-OS": "Linux", + "X-Stainless-Arch": "arm64", + "X-Stainless-Runtime": "node", + "X-Stainless-Runtime-Version": "v24.3.0", + "X-Stainless-Retry-Count": "0", + "X-Stainless-Timeout": "600", + "X-App": "cli", + "Anthropic-Dangerous-Direct-Browser-Access": "true", + } } // Model 表示一个 Claude 模型 @@ -171,6 +176,12 @@ var DefaultModels = []Model{ DisplayName: "Claude Opus 4.8", CreatedAt: "2026-05-29T00:00:00Z", }, + { + ID: "claude-opus-5-5", + Type: "model", + DisplayName: "Claude Opus 5.5", + CreatedAt: "2026-09-22T00:00:00Z", + }, { ID: "claude-opus-5", Type: "model", diff --git a/backend/internal/pkg/claude/constants_cli_version_test.go b/backend/internal/pkg/claude/constants_cli_version_test.go index 91bd12833f..0a638cab02 100644 --- a/backend/internal/pkg/claude/constants_cli_version_test.go +++ b/backend/internal/pkg/claude/constants_cli_version_test.go @@ -21,7 +21,7 @@ const fable51MinCLIVersion = "2.1.251" func TestCLICurrentVersionMatchesDefaultUserAgent(t *testing.T) { t.Parallel() - ua := DefaultHeaders["User-Agent"] + ua := DefaultHeaders()["User-Agent"] const prefix = "claude-cli/" require.True(t, strings.HasPrefix(ua, prefix), "unexpected User-Agent: %q", ua) diff --git a/backend/internal/pkg/claude/constants_model_test.go b/backend/internal/pkg/claude/constants_model_test.go index 6cfa265bdc..941a72d486 100644 --- a/backend/internal/pkg/claude/constants_model_test.go +++ b/backend/internal/pkg/claude/constants_model_test.go @@ -18,3 +18,15 @@ func TestDefaultModelsContainsClaudeFable51(t *testing.T) { } t.Fatal("claude-fable-5-1 missing from DefaultModels") } + +func TestDefaultModelsContainsOpus55(t *testing.T) { + for _, model := range DefaultModels { + if model.ID == "claude-opus-5-5" { + if model.DisplayName != "Claude Opus 5.5" || model.CreatedAt != "2026-09-22T00:00:00Z" { + t.Fatalf("unexpected Opus 5.5 descriptor: %+v", model) + } + return + } + } + t.Fatal("claude-opus-5-5 missing") +} diff --git a/backend/internal/pkg/claude/effort_catalog.go b/backend/internal/pkg/claude/effort_catalog.go index ab587a4c01..00fd337917 100644 --- a/backend/internal/pkg/claude/effort_catalog.go +++ b/backend/internal/pkg/claude/effort_catalog.go @@ -24,6 +24,7 @@ var effortFamilies = []struct { {family: "claude-opus-4-7", levels: effortLowMediumHighXHighMax}, {family: "claude-opus-4-6", levels: effortLowMediumHighMax}, {family: "claude-opus-4-5", levels: effortLowMediumHigh}, + {family: "claude-opus-5-5", levels: effortLowMediumHighXHighMax}, {family: "claude-opus-5", levels: effortLowMediumHighXHighMax}, } @@ -39,6 +40,11 @@ func EffortLevelsForModel(model string) []string { return nil } +// IsOpus55 identifies the fixed Opus 5.5 ID after provider/local suffix normalization. +func IsOpus55(model string) bool { + return normalizeEffortModelID(model) == "claude-opus-5-5" +} + func normalizeEffortModelID(model string) string { id := strings.ToLower(strings.TrimSpace(model)) id = strings.TrimPrefix(id, "models/") diff --git a/backend/internal/pkg/openai/constants.go b/backend/internal/pkg/openai/constants.go index 5c7cad3674..01f244390d 100644 --- a/backend/internal/pkg/openai/constants.go +++ b/backend/internal/pkg/openai/constants.go @@ -23,6 +23,8 @@ var DefaultModels = []Model{ {ID: "gpt-5.6", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 (Sol)"}, {ID: "gpt-5.6-terra", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 Terra"}, {ID: "gpt-5.6-luna", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 Luna"}, + {ID: "gpt-6-sol", Object: "model", Created: 1790035200, OwnedBy: "openai", Type: "model", DisplayName: "GPT-6 Sol"}, + {ID: "gpt-6-luna", Object: "model", Created: 1790035200, OwnedBy: "openai", Type: "model", DisplayName: "GPT-6 Luna"}, {ID: "gpt-6-astra", Object: "model", Created: 1788480000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-6 Astra"}, {ID: "gpt-5.5", Object: "model", Created: 1776873600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.5"}, {ID: "gpt-5.4", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.4"}, @@ -157,3 +159,21 @@ func CodexBaseInstructionsForModel(model string) string { } return latestCodexInstructions() } + +// IsGPT6SolOrLunaModelSpelling recognizes official IDs and existing local effort/compact suffixes. +func IsGPT6SolOrLunaModelSpelling(model string) bool { + canonical := CanonicalizeOpenAIModelAliasSpelling(model) + for _, base := range []string{"gpt-6-sol", "gpt-6-luna"} { + if canonical == base { + return true + } + suffix, ok := strings.CutPrefix(canonical, base+"-") + if ok { + switch suffix { + case "none", "low", "medium", "high", "xhigh", "max", "openai-compact": + return true + } + } + } + return false +} diff --git a/backend/internal/pkg/openai/constants_test.go b/backend/internal/pkg/openai/constants_test.go index 1cc7003083..523202d8b3 100644 --- a/backend/internal/pkg/openai/constants_test.go +++ b/backend/internal/pkg/openai/constants_test.go @@ -32,3 +32,13 @@ func TestDefaultModelsIncludeGPTImage25(t *testing.T) { require.Contains(t, DefaultModelIDs(), "gpt-image-2.5-flare") require.Contains(t, DefaultModelIDs(), "gpt-image-2.5-sunburst") } + +func TestGPT6SolLunaModelIdentity(t *testing.T) { + for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { + require.Contains(t, DefaultModelIDs(), model) + require.True(t, IsGPT6SolOrLunaModelSpelling(model)) + } + require.False(t, IsGPT6SolOrLunaModelSpelling("gpt-6-astra")) + require.False(t, IsGPT6SolOrLunaModelSpelling("gpt-6-solitude")) + require.False(t, IsGPT6SolOrLunaModelSpelling("gpt-6-luna-preview")) +} diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 84e8fc156a..781928aea2 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -648,7 +648,29 @@ func lockAndMergeAccountProbeExtra( extra -> 'upstream_billing_probe', extra -> 'ollama_cloud_usage_session', extra -> 'ollama_cloud_usage_auto_refresh', - extra -> 'ollama_cloud_usage_snapshot' + extra -> 'ollama_cloud_usage_snapshot', + COALESCE( + ( + -- opencode_go 平台分支:新旧都是 opencode_go Go 订阅(account_mode + -- 未设置/非 zen 均为 Go,与 GetOpenCodeAccountMode 默认兼容一致), + -- 不校验 base_url。 + (platform = 'opencode_go' AND $2 = 'opencode_go' + AND COALESCE(btrim(credentials ->> 'account_mode') <> 'zen', true) + AND COALESCE(btrim($4::jsonb ->> 'account_mode') <> 'zen', true)) + -- 挂载白名单分支:新旧平台都在白名单内且 base_url 均为官方 + -- OpenCode Go 基址(与 Ollama 同范式,允许白名单内跨平台)。 + OR (platform IN (`+opencodeGoUsageMountPlatformsSQL+`) + AND $2 IN (`+opencodeGoUsageMountPlatformsSQL+`) + AND `+opencodeGoBaseURLMatchSQLPrefix+`credentials ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+` + AND `+opencodeGoBaseURLMatchSQLPrefix+`$4::jsonb ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+`) + ) + AND type = 'apikey' + AND $3 = 'apikey' + AND credentials -> 'api_key' IS NOT DISTINCT FROM $4::jsonb -> 'api_key', + false + ), + extra -> 'opencode_go_usage_auto_refresh', + extra -> 'opencode_go_usage_snapshot' FROM accounts WHERE id = $1 AND deleted_at IS NULL FOR NO KEY UPDATE @@ -665,15 +687,18 @@ func lockAndMergeAccountProbeExtra( } var ( - identityUnchanged bool - ollamaGroupIdentityUnchanged bool - ollamaProxyIdentityUnchanged bool - currentEnabled []byte - currentRateSyncEnabled []byte - currentSnapshot []byte - currentOllamaSession []byte - currentOllamaAutoRefresh []byte - currentOllamaSnapshot []byte + identityUnchanged bool + ollamaGroupIdentityUnchanged bool + ollamaProxyIdentityUnchanged bool + opencodeGroupIdentityUnchanged bool + currentEnabled []byte + currentRateSyncEnabled []byte + currentSnapshot []byte + currentOllamaSession []byte + currentOllamaAutoRefresh []byte + currentOllamaSnapshot []byte + currentOpenCodeAutoRefresh []byte + currentOpenCodeSnapshot []byte ) if err := rows.Scan( &identityUnchanged, @@ -685,6 +710,9 @@ func lockAndMergeAccountProbeExtra( ¤tOllamaSession, ¤tOllamaAutoRefresh, ¤tOllamaSnapshot, + &opencodeGroupIdentityUnchanged, + ¤tOpenCodeAutoRefresh, + ¤tOpenCodeSnapshot, ); err != nil { return nil, err } @@ -700,6 +728,8 @@ func lockAndMergeAccountProbeExtra( service.OllamaCloudUsageSessionExtraKey, service.OllamaCloudUsageAutoRefreshExtraKey, service.OllamaCloudUsageSnapshotExtraKey, + service.OpenCodeGoUsageAutoRefreshExtraKey, + service.OpenCodeGoUsageSnapshotExtraKey, } { delete(extra, key) } @@ -777,6 +807,25 @@ func lockAndMergeAccountProbeExtra( } } } + // OpenCode Go 受管状态与 Ollama 同范式:组身份(opencode_go Go 订阅,或挂载 + // 白名单平台 + 官方 OpenCode Go 基址,且 api_key 不变)未变时从锁定的 DB 行 + // 回填,客户端 DTO 脱敏或并发 refresh 写入都无法覆盖/丢失;身份改变或不再 + // eligible 时既不回填,写出的 extra 即清除旧状态。 + // 代理不属于组身份,但 snapshot 外呼与 CAS 经代理,故代理变化时快照失效而开关保留。 + if service.IsOpenCodeGoUsageAccount(account) && opencodeGroupIdentityUnchanged { + if value, ok, err := decodeAccountExtraJSON(currentOpenCodeAutoRefresh); err != nil { + return nil, err + } else if ok { + extra[service.OpenCodeGoUsageAutoRefreshExtraKey] = value + } + if ollamaProxyIdentityUnchanged { + if snapshot, ok, err := decodeAccountExtraJSON(currentOpenCodeSnapshot); err != nil { + return nil, err + } else if ok { + extra[service.OpenCodeGoUsageSnapshotExtraKey] = snapshot + } + } + } return extra, nil } @@ -819,6 +868,44 @@ func (r *accountRepository) UpdateCredentials(ctx context.Context, id int64, cre SET credentials = $1::jsonb, extra = CASE + -- 正确性依赖(非防御):OpenCode 分支必须先于 Ollama 分支求值。两分支 + -- 的 WHEN 并不互斥:Ollama 分支的守卫是宽谓词——NOT(ollamaMatch(old) + -- AND ollamaMatch(new)) 在旧行不匹配 ollama.com 基址时恒真,且两侧 + -- 平台白名单完全相同,因此挂载行(白名单平台 + 官方 OpenCode Go 基址) + -- 会同时满足两分支的 WHEN。若把 Ollama 分支前移,挂载行的 api_key 变化 + -- 会先命中 Ollama 分支,opencode_go_usage_snapshot / + -- opencode_go_usage_auto_refresh 残留,陈旧快照跟着新 api_key 走, + -- 造成跨 key 组污染。互斥的只是两侧身份谓词(opencode.ai 基址正则 vs + -- ollama.com 基址正则):Ollama 行不会被 OpenCode 分支遮蔽; + -- opencode_go 平台行不在 Ollama 白名单内,与 Ollama 分支真互斥。 + WHEN type = 'apikey' + AND credentials IS DISTINCT FROM $1::jsonb + AND ( + -- 旧行按新谓词属于 OpenCode 用量身份 + (platform = 'opencode_go' + AND COALESCE(btrim(credentials ->> 'account_mode') <> 'zen', true)) + OR (platform IN (`+opencodeGoUsageMountPlatformsSQL+`) + AND `+opencodeGoBaseURLMatchSQLPrefix+`credentials ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+`) + ) + AND ( + credentials -> 'api_key' IS DISTINCT FROM $1::jsonb -> 'api_key' + -- 或组身份不再成立:opencode_go 转 Zen(COALESCE 恒非 NULL, + -- IS NOT TRUE 等价于判定为假;平台条件必须在 IS NOT TRUE 作用 + -- 域之外,否则挂载行的 FALSE IS NOT TRUE 会误判为已变化); + -- 挂载行 base_url 不再指向官方 Go 基址。NULL-safe: + -- regex(NULL) 为 NULL 时 IS NOT TRUE 把 NULL 视为不匹配。 + OR (platform = 'opencode_go' + AND COALESCE(btrim($1::jsonb ->> 'account_mode') <> 'zen', true) IS NOT TRUE) + OR (platform IN (`+opencodeGoUsageMountPlatformsSQL+`) + AND (`+opencodeGoBaseURLMatchSQLPrefix+`$1::jsonb ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+`) IS NOT TRUE) + ) + THEN COALESCE(extra, '{}'::jsonb) + - 'upstream_billing_probe' + - 'opencode_go_usage_auto_refresh' + - 'opencode_go_usage_snapshot' + - 'ollama_cloud_usage_session' + - 'ollama_cloud_usage_auto_refresh' + - 'ollama_cloud_usage_snapshot' -- 凭证整体未变化 ⇒ Ollama 组身份必然未变化;顶层 DISTINCT 守卫防止 -- 非 Ollama 账号的无变化持久化误清探测快照或重写 NULL extra。 WHEN platform IN (`+ollamaCloudUsagePlatformsSQL+`) @@ -3009,7 +3096,39 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates " AND "+ollamaCloudBaseURLMatchesSQL(credentialPlaceholder+"::jsonb ->> 'base_url'")+")") } - if len(updates.Extra) > 0 || len(ollamaGroupIdentityChanges) > 0 || ollamaProxyIdentityChanged != "" || updates.EnsureCodexFingerprintSeed { + // OpenCode Go 身份清理与 Ollama 同范式但更精确:仅当旧行按新谓词属于 OpenCode + // 用量身份(opencode_go Go 订阅,或挂载白名单平台 + 官方 OpenCode Go 基址)时 + // 才可能持有受管键,且只在该组身份(api_key / normalized base URL / + // account_mode)真正变化或不再 eligible 时清除,避免与 Ollama 分支交叉误清。 + opencodeGroupIdentityChanges := make([]string, 0, 3) + opencodeOldBaseURL := opencodeGoBaseURLMatchSQLPrefix + "credentials ->> 'base_url'" + opencodeGoBaseURLMatchSQLSuffix + // 旧行 OpenCode 用量身份谓词(与 opencodeGoUsageEligibleSQL 的行侧条件镜像; + // type 由 opencodeEligibleAccount 统一携带)。account_mode 未设置/为 null/非 + // zen 均视为 Go 订阅,与 GetOpenCodeAccountMode 默认兼容一致。 + opencodeOldUsageIdentity := "((platform = 'opencode_go' AND COALESCE(btrim(credentials ->> 'account_mode') <> 'zen', true))" + + " OR (platform IN (" + opencodeGoUsageMountPlatformsSQL + ") AND " + opencodeOldBaseURL + "))" + if _, ok := updates.Credentials["api_key"]; ok { + opencodeGroupIdentityChanges = append(opencodeGroupIdentityChanges, + opencodeOldUsageIdentity+" AND credentials -> 'api_key' IS DISTINCT FROM "+credentialPlaceholder+"::jsonb -> 'api_key'") + } + if _, ok := updates.Credentials["base_url"]; ok { + // 仅挂载行身份钉在官方基址上(opencode_go 行的 base_url 不参与资格,两个 + // 官方变体都合法,不由此子句清理)。NULL-safe:新 base_url 缺失/为 null 时 + // regex(NULL) 为 NULL,NOT NULL 仍为 NULL 会令 WHEN 不命中而残留 OpenCode + // 状态;IS NOT TRUE 把 NULL 视为不匹配。 + opencodeGroupIdentityChanges = append(opencodeGroupIdentityChanges, + "platform IN ("+opencodeGoUsageMountPlatformsSQL+") AND "+opencodeOldBaseURL+ + " AND ("+opencodeGoBaseURLMatchSQLPrefix+credentialPlaceholder+"::jsonb ->> 'base_url'"+opencodeGoBaseURLMatchSQLSuffix+") IS NOT TRUE") + } + if _, ok := updates.Credentials["account_mode"]; ok { + // 仅 opencode_go 行身份钉在 Go 订阅上(挂载行的 account_mode 与 OpenCode + // 资格无关,不由此子句清理)。模式转 Zen 即不再 eligible。 + opencodeGroupIdentityChanges = append(opencodeGroupIdentityChanges, + "platform = 'opencode_go' AND COALESCE(btrim(credentials ->> 'account_mode') <> 'zen', true)"+ + " AND COALESCE(btrim("+credentialPlaceholder+"::jsonb ->> 'account_mode') <> 'zen', true) IS NOT TRUE") + } + + if len(updates.Extra) > 0 || len(ollamaGroupIdentityChanges) > 0 || len(opencodeGroupIdentityChanges) > 0 || ollamaProxyIdentityChanged != "" || updates.EnsureCodexFingerprintSeed { extraExpression := "COALESCE(extra, '{}'::jsonb)" if len(updates.Extra) > 0 { payload, err := json.Marshal(updates.Extra) @@ -3040,13 +3159,54 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates snapshotIdentityChanged = "(" + snapshotIdentityChanged + " OR " + proxyChanged + ")" } } + // 代理不属于 OpenCode 组身份,但 snapshot 外呼与 CAS 经代理: + // 组身份变化清除开关+快照,仅代理变化清除快照而保留开关。 + // eligible 判定必须包含旧行 OpenCode 身份(opencode_go Go 订阅,或挂载 + // 白名单平台 + opencode 基址):否则白名单平台的 Ollama 行在代理变化时会 + // 先命中 OpenCode 分支(CASE 按序求值)而遮蔽 Ollama 分支的快照清理。 + // 注意 OpenCode 与 Ollama 两套分支的 WHEN 并不互斥:eligibleAccount 只看 + // platform 白名单 + type='apikey',根本不含 base_url,且与 OpenCode 挂载 + // 白名单完全相同,因此挂载行(白名单平台 + 官方 OpenCode Go 基址)会同时 + // 满足两套分支的 WHEN;互斥的只是两侧身份谓词(opencode.ai 基址正则 vs + // ollama.com 基址正则,host 不同)。因此下方 caseBranches 必须先拼 OpenCode + // 分支再拼 Ollama 分支——这是正确性要求而非防御:若 Ollama 分支在前, + // 挂载行的 api_key 变化会先命中 Ollama 分支,opencode_go_usage_snapshot / + // opencode_go_usage_auto_refresh 残留,陈旧快照跟着新 api_key 走,跨 key + // 组污染。反方向安全:Ollama 行(ollama.com 基址)不满足 OpenCode 身份 + // 谓词;opencode_go 平台行不在 Ollama 白名单内,与 Ollama 分支真互斥。 + opencodeEligibleAccount := "type = 'apikey' AND " + opencodeOldUsageIdentity + opencodeGroupIdentityChanged := "" + if len(opencodeGroupIdentityChanges) > 0 { + opencodeGroupIdentityChanged = "(" + opencodeEligibleAccount + " AND (" + joinClauses(opencodeGroupIdentityChanges, " OR ") + "))" + } + opencodeSnapshotIdentityChanged := opencodeGroupIdentityChanged + if ollamaProxyIdentityChanged != "" { + opencodeProxyChanged := "(" + opencodeEligibleAccount + " AND " + ollamaProxyIdentityChanged + ")" + if opencodeSnapshotIdentityChanged == "" { + opencodeSnapshotIdentityChanged = opencodeProxyChanged + } else { + opencodeSnapshotIdentityChanged = "(" + opencodeSnapshotIdentityChanged + " OR " + opencodeProxyChanged + ")" + } + } + caseBranches := make([]string, 0, 4) + if opencodeGroupIdentityChanged != "" { + caseBranches = append(caseBranches, + " WHEN "+opencodeGroupIdentityChanged+" THEN ("+extraExpression+") - 'opencode_go_usage_auto_refresh' - 'opencode_go_usage_snapshot'") + } + if opencodeSnapshotIdentityChanged != "" { + caseBranches = append(caseBranches, + " WHEN "+opencodeSnapshotIdentityChanged+" THEN ("+extraExpression+") - 'opencode_go_usage_snapshot'") + } if groupIdentityChanged != "" { - extraExpression = "CASE" + - " WHEN " + groupIdentityChanged + " THEN (" + extraExpression + ") - 'ollama_cloud_usage_session' - 'ollama_cloud_usage_auto_refresh' - 'ollama_cloud_usage_snapshot'" + - " WHEN " + snapshotIdentityChanged + " THEN (" + extraExpression + ") - 'ollama_cloud_usage_snapshot'" + - " ELSE " + extraExpression + " END" - } else if snapshotIdentityChanged != "" { - extraExpression = "CASE WHEN " + snapshotIdentityChanged + " THEN (" + extraExpression + ") - 'ollama_cloud_usage_snapshot' ELSE " + extraExpression + " END" + caseBranches = append(caseBranches, + " WHEN "+groupIdentityChanged+" THEN ("+extraExpression+") - 'ollama_cloud_usage_session' - 'ollama_cloud_usage_auto_refresh' - 'ollama_cloud_usage_snapshot'") + } + if snapshotIdentityChanged != "" { + caseBranches = append(caseBranches, + " WHEN "+snapshotIdentityChanged+" THEN ("+extraExpression+") - 'ollama_cloud_usage_snapshot'") + } + if len(caseBranches) > 0 { + extraExpression = "CASE" + strings.Join(caseBranches, "") + " ELSE " + extraExpression + " END" } if updates.EnsureCodexFingerprintSeed { extraExpression = ensureCodexFingerprintSeedSQL(extraExpression) diff --git a/backend/internal/repository/account_repo_opencode_go_usage.go b/backend/internal/repository/account_repo_opencode_go_usage.go new file mode 100644 index 0000000000..1568e70695 --- /dev/null +++ b/backend/internal/repository/account_repo_opencode_go_usage.go @@ -0,0 +1,516 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/lib/pq" +) + +const ( + // 与 service.isOpenCodeGoBaseURL 对齐:Go 侧接受显式默认端口 :443(parsed.Host == + // hostname+":443")与两个官方基址变体(CC/Responses 的 /zen/go/v1 与 Anthropic + // 的 /zen/go),SQL 正则必须同样接受,否则同一 base_url 在 Go 判定 eligible 而 + // SQL 判定不 eligible,身份清理与组查询会漏行。只做 parity,不扩域名/路径; + // Zen 基址(/zen、/zen/v1)必须拒绝。 + opencodeGoBaseURLRegexSQL = `^[hH][tT][tT][pP][sS]://[oO][pP][eE][nN][cC][oO][dD][eE]\.[aA][iI](:[4][4][3])?/[zZ][eE][nN]/[gG][oO](/[vV]1)?/?$` + opencodeGoBaseURLMatchSQLPrefix = "btrim(" + opencodeGoBaseURLMatchSQLSuffix = ") ~ '" + opencodeGoBaseURLRegexSQL + "'" + // opencodeGoUsageMountPlatformsSQL 是 service.isOpenCodeGoUsageMountPlatform 的 + // SQL 镜像:OpenCode Go key 允许挂在 openai/anthropic 与国产 OpenAI 兼容平台 + // 下复用,与 ollama 的 ollamaCloudUsagePlatformsSQL 完全一致。opencode_go + // 平台本身不在名单内——平台账号走下方 account_mode 分支。所有平台白名单 SQL + // 只允许引用本常量,不得各处重写字面量,防止漂移。 + opencodeGoUsageMountPlatformsSQL = "'openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax'" + // opencodeGoUsageEligibleSQL 与 service.IsOpenCodeGoUsageAccount 互为镜像: + // - opencode_go 平台:account_mode 存储于 credentials(domain/constants.go), + // 未设置/为 null/非 "zen" 一律视为 Go 订阅,与 GetOpenCodeAccountMode 的 + // 默认兼容逻辑一致;COALESCE 把 <> 对 NULL 的结果归一为 true。不校验 + // base_url(平台字段已是权威来源)。 + // - 挂载白名单平台:base_url 必须匹配官方 OpenCode Go 基址正则。 + // + // 已知且可接受的差异:btrim 只去空格,Go 侧 strings.TrimSpace 还会去 + // \t\n\v\f\r 等空白;account_mode 等凭证字段实际不会出现这类空白,且改用 + // btrim(x, E' \t\n\r\f\v') 会在 parity 关键的 SQL 字符串里引入转义脆弱性, + // 故保持现状不改行为。 + opencodeGoUsageEligibleSQL = ` + ( + (platform = 'opencode_go' + AND COALESCE(btrim(credentials ->> 'account_mode') <> 'zen', true)) + OR (platform IN (` + opencodeGoUsageMountPlatformsSQL + `) + AND ` + opencodeGoBaseURLMatchSQLPrefix + `credentials ->> 'base_url'` + opencodeGoBaseURLMatchSQLSuffix + `) + ) + AND type = 'apikey' + AND jsonb_typeof(credentials -> 'api_key') = 'string' +` +) + +// ListOpenCodeGoUsageGroupAccounts resolves every sibling for all supplied +// identities with one ID query and one batch hydration. API keys are query +// parameters only; no derived shared key is persisted. +func (r *accountRepository) ListOpenCodeGoUsageGroupAccounts(ctx context.Context, accounts []*service.Account) ([]service.Account, error) { + if r == nil || r.sql == nil { + return nil, service.ErrOpenCodeGoUsageUnavailable + } + keys := make([]string, 0, len(accounts)) + seen := make(map[string]struct{}, len(accounts)) + for _, account := range accounts { + if !service.IsOpenCodeGoUsageAccount(account) || account.Credentials == nil { + continue + } + apiKey, ok := account.Credentials["api_key"].(string) + if !ok || apiKey == "" { + continue + } + if _, duplicate := seen[apiKey]; duplicate { + continue + } + seen[apiKey] = struct{}{} + keys = append(keys, apiKey) + } + if len(keys) == 0 { + return []service.Account{}, nil + } + rows, err := r.sql.QueryContext(ctx, ` + SELECT id + FROM accounts + WHERE deleted_at IS NULL + AND `+opencodeGoUsageEligibleSQL+` + AND credentials ->> 'api_key' = ANY($1) + ORDER BY id + `, pq.Array(keys)) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + ids := make([]int64, 0, len(keys)) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + hydrated, err := r.GetByIDs(ctx, ids) + if err != nil { + return nil, err + } + result := make([]service.Account, 0, len(hydrated)) + for _, account := range hydrated { + if account != nil { + result = append(result, *account) + } + } + return result, nil +} + +// SetOpenCodeGoUsageAutoRefresh persists the group-scoped auto-refresh switch. +func (r *accountRepository) SetOpenCodeGoUsageAutoRefresh(ctx context.Context, account *service.Account, enabled bool) error { + if account == nil { + return service.ErrAccountNilInput + } + if r == nil || r.client == nil || !service.IsOpenCodeGoUsageAccount(account) { + return service.ErrOpenCodeGoUsageUnavailable + } + return r.updateOpenCodeGoUsageGroup(ctx, account, map[string]any{ + service.OpenCodeGoUsageAutoRefreshExtraKey: enabled, + }, nil, true) +} + +// UpdateOpenCodeGoUsageSnapshot persists the group-scoped usage snapshot. +func (r *accountRepository) UpdateOpenCodeGoUsageSnapshot(ctx context.Context, account *service.Account, snapshot *service.OpenCodeGoUsageSnapshot) error { + if account == nil || snapshot == nil { + return service.ErrAccountNilInput + } + if r == nil || r.client == nil || !service.IsOpenCodeGoUsageAccount(account) { + return service.ErrOpenCodeGoUsageUnavailable + } + return r.updateOpenCodeGoUsageGroup(ctx, account, map[string]any{ + service.OpenCodeGoUsageSnapshotExtraKey: snapshot, + }, nil, true) +} + +// DisableOpenCodeGoUsageAutoRefresh is group-scoped and retains the loaded +// identity CAS. It cannot disable a new group after the account changes key. +func (r *accountRepository) DisableOpenCodeGoUsageAutoRefresh(ctx context.Context, account *service.Account) error { + if account == nil { + return service.ErrAccountNilInput + } + if r == nil || r.client == nil || !service.IsOpenCodeGoUsageAccount(account) { + return service.ErrOpenCodeGoUsageUnavailable + } + payload := openCodeGoUsageManagedPayload(account) + payload[service.OpenCodeGoUsageAutoRefreshExtraKey] = false + delete(payload, service.OpenCodeGoUsageSnapshotExtraKey) + return r.updateOpenCodeGoUsageGroup(ctx, account, payload, + []string{service.OpenCodeGoUsageSnapshotExtraKey}, true) +} + +func openCodeGoUsageManagedPayload(account *service.Account) map[string]any { + payload := make(map[string]any, 2) + if account == nil || account.Extra == nil { + return payload + } + for _, key := range []string{ + service.OpenCodeGoUsageAutoRefreshExtraKey, + service.OpenCodeGoUsageSnapshotExtraKey, + } { + if value, ok := account.Extra[key]; ok { + payload[key] = value + } + } + return payload +} + +type lockedOpenCodeGoUsageMember struct { + id int64 + anchorMatches bool + autoJSON string + snapshotJSON string +} + +// updateOpenCodeGoUsageGroup locks every member of the exact api_key group and +// merges the payload onto each member's extra. The merge is pure +// (COALESCE(extra,'{}') || payload) unless deleteKeys is non-empty, in which +// case those keys are removed first — used only by the group-level disable path +// to clear the stale snapshot. Managed keys are never removed on ordinary +// writes, so a snapshot write can never wipe the auto-refresh switch or vice versa. +func (r *accountRepository) updateOpenCodeGoUsageGroup( + ctx context.Context, + account *service.Account, + payload map[string]any, + deleteKeys []string, + requireExpectedState bool, +) error { + if account == nil { + return service.ErrAccountNilInput + } + if r == nil || r.client == nil || !service.IsOpenCodeGoUsageAccount(account) { + return service.ErrOpenCodeGoUsageUnavailable + } + apiKey, ok := account.Credentials["api_key"].(string) + if !ok || apiKey == "" { + return service.ErrOpenCodeGoUsageAccountInvalid + } + apply := func(txCtx context.Context, client *dbent.Client) error { + matchesProxy, err := lockAndMatchProbeProxyIdentity(txCtx, client, account) + if err != nil { + return err + } + if !matchesProxy { + return service.ErrOpenCodeGoUsageIdentityChanged + } + members, err := lockOpenCodeGoUsageGroup(txCtx, client, account, apiKey) + if err != nil { + return err + } + anchorMatches := false + for _, member := range members { + anchorMatches = anchorMatches || member.anchorMatches + } + if !anchorMatches { + return service.ErrOpenCodeGoUsageIdentityChanged + } + if requireExpectedState { + expectedAuto, err := canonicalAccountExtraJSON(account, service.OpenCodeGoUsageAutoRefreshExtraKey) + if err != nil { + return err + } + expectedSnapshot, err := canonicalAccountExtraJSON(account, service.OpenCodeGoUsageSnapshotExtraKey) + if err != nil { + return err + } + stateMatches := false + for _, member := range members { + if canonicalJSON(member.autoJSON) == expectedAuto && + canonicalJSON(member.snapshotJSON) == expectedSnapshot { + stateMatches = true + break + } + } + if !stateMatches { + return service.ErrOpenCodeGoUsageIdentityChanged + } + } + encoded, err := json.Marshal(payload) + if err != nil { + return err + } + memberIDs := make([]int64, len(members)) + for index := range members { + memberIDs[index] = members[index].id + } + mergeExpr := "COALESCE(extra, '{}'::jsonb)" + for _, key := range deleteKeys { + mergeExpr += " - " + pq.QuoteLiteral(key) + } + mergeExpr += " || $1::jsonb" + result, err := client.ExecContext(txCtx, ` + UPDATE accounts + SET extra = `+mergeExpr+`, + updated_at = NOW() + WHERE deleted_at IS NULL + AND `+opencodeGoUsageEligibleSQL+` + AND credentials ->> 'api_key' = $2 + AND id = ANY($3) + `, string(encoded), apiKey, pq.Array(memberIDs)) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected != int64(len(members)) { + return service.ErrOpenCodeGoUsageIdentityChanged + } + return nil + } + if dbent.TxFromContext(ctx) != nil { + return apply(ctx, clientFromContext(ctx, r.client)) + } + tx, err := r.client.Tx(ctx) + if errors.Is(err, dbent.ErrTxStarted) { + return apply(ctx, r.client) + } + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + txCtx := dbent.NewTxContext(ctx, tx) + if err := apply(txCtx, tx.Client()); err != nil { + return err + } + return tx.Commit() +} + +func lockOpenCodeGoUsageGroup( + ctx context.Context, + client *dbent.Client, + account *service.Account, + apiKey string, +) ([]lockedOpenCodeGoUsageMember, error) { + credentials, err := json.Marshal(normalizeJSONMap(account.Credentials)) + if err != nil { + return nil, err + } + var proxyID any + if account.ProxyID != nil { + proxyID = *account.ProxyID + } + rows, err := client.QueryContext(ctx, ` + SELECT + id, + id = $2 + AND platform = $3 + AND type = $4 + AND credentials = $5::jsonb + AND proxy_id IS NOT DISTINCT FROM $6, + COALESCE((extra -> 'opencode_go_usage_auto_refresh')::text, 'null'), + COALESCE((extra -> 'opencode_go_usage_snapshot')::text, 'null') + FROM accounts + WHERE deleted_at IS NULL + AND `+opencodeGoUsageEligibleSQL+` + AND credentials ->> 'api_key' = $1 + ORDER BY id + FOR NO KEY UPDATE + `, apiKey, account.ID, account.Platform, account.Type, string(credentials), proxyID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + members := make([]lockedOpenCodeGoUsageMember, 0, 1) + for rows.Next() { + var member lockedOpenCodeGoUsageMember + if err := rows.Scan(&member.id, &member.anchorMatches, &member.autoJSON, &member.snapshotJSON); err != nil { + return nil, err + } + members = append(members, member) + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(members) == 0 { + return nil, service.ErrOpenCodeGoUsageIdentityChanged + } + return members, nil +} + +// ListDueOpenCodeGoUsageAccounts returns at most one truly-due activity-driven +// candidate per exact API key. Due timing (debounce, max-wait, failure backoff) +// is evaluated in SQL before LIMIT so non-due active groups cannot starve due ones. +// Account.LastUsedAt is stamped with the group MAX(last_used_at) for a service +// pure-function recheck against races between list and refresh. +// +// Rules mirror service.openCodeGoUsageAutoRefreshDueAt (keep both in sync): +// - missing/invalid snapshot or times → fail-open first due +// - success: activity after fetched_at; +// due_at = GREATEST(LEAST(last_used+debounce, fetched+maxWait), fetched+minFetchInterval) +// - failed/unauthorized: activity after last_attempt; activity_due = LEAST(...); +// final due_at is not earlier than a valid next_refresh_at (invalid/missing fail-open) +func (r *accountRepository) ListDueOpenCodeGoUsageAccounts( + ctx context.Context, + now time.Time, + debounce, maxWait time.Duration, + limit int, +) ([]service.Account, error) { + if limit <= 0 { + return []service.Account{}, nil + } + if r == nil || r.sql == nil { + return nil, errors.New("account repository SQL executor not configured") + } + if debounce <= 0 { + debounce = time.Minute + } + if maxWait <= 0 { + maxWait = 15 * time.Minute + } + debounceSeconds := debounce.Seconds() + maxWaitSeconds := maxWait.Seconds() + minFetchIntervalSeconds := service.OpenCodeGoUsageMinFetchInterval.Seconds() + rows, err := r.sql.QueryContext(ctx, ` + WITH eligible AS ( + SELECT id, + credentials ->> 'api_key' AS api_key, + last_used_at, + extra -> 'opencode_go_usage_snapshot' AS snapshot + FROM accounts + WHERE deleted_at IS NULL + AND status = 'active' + AND `+opencodeGoUsageEligibleSQL+` + AND extra @> '{"opencode_go_usage_auto_refresh": true}'::jsonb + ), group_activity AS ( + SELECT credentials ->> 'api_key' AS api_key, + MAX(last_used_at) AS group_last_used_at + FROM accounts + WHERE deleted_at IS NULL + AND `+opencodeGoUsageEligibleSQL+` + AND jsonb_typeof(credentials -> 'api_key') = 'string' + GROUP BY credentials ->> 'api_key' + ), joined AS ( + SELECT e.id, e.api_key, e.snapshot, g.group_last_used_at, + e.snapshot #>> '{status}' AS status, + e.snapshot #>> '{fetched_at}' AS fetched_at, + e.snapshot #>> '{last_attempt_at}' AS last_attempt_at, + e.snapshot #>> '{next_refresh_at}' AS next_refresh_at + FROM eligible e + JOIN group_activity g ON g.api_key = e.api_key + ), parsed AS MATERIALIZED ( + SELECT id, api_key, snapshot, group_last_used_at, status, + `+ollamaCloudUsageParseRFC3339SQL("fetched_at")+` AS parsed_fetched_at, + `+ollamaCloudUsageParseRFC3339SQL("last_attempt_at")+` AS parsed_last_attempt_at, + `+ollamaCloudUsageParseRFC3339SQL("next_refresh_at")+` AS parsed_next_refresh_at + FROM joined + ), timed AS ( + SELECT *, + CASE + WHEN status = 'ok' + AND parsed_fetched_at IS NOT NULL + AND group_last_used_at IS NOT NULL + AND group_last_used_at > parsed_fetched_at::timestamptz + THEN GREATEST( + LEAST( + group_last_used_at + make_interval(secs => $2::double precision), + parsed_fetched_at::timestamptz + make_interval(secs => $3::double precision) + ), + parsed_fetched_at::timestamptz + make_interval(secs => $5::double precision) + ) + WHEN status IN ('failed', 'unauthorized') + AND parsed_last_attempt_at IS NOT NULL + AND group_last_used_at IS NOT NULL + AND group_last_used_at > parsed_last_attempt_at::timestamptz + THEN GREATEST( + LEAST( + group_last_used_at + make_interval(secs => $2::double precision), + parsed_last_attempt_at::timestamptz + make_interval(secs => $3::double precision) + ), + COALESCE(parsed_next_refresh_at::timestamptz, '-infinity'::timestamptz) + ) + ELSE NULL + END AS activity_due_at + FROM parsed + ), candidates AS ( + SELECT *, + CASE + WHEN snapshot IS NULL OR snapshot = 'null'::jsonb OR status IS NULL + OR status NOT IN ('ok', 'failed', 'unauthorized') THEN 0 + WHEN status = 'ok' AND parsed_fetched_at IS NULL THEN 0 + WHEN status IN ('failed', 'unauthorized') AND parsed_last_attempt_at IS NULL THEN 0 + WHEN activity_due_at IS NOT NULL AND $1 >= activity_due_at THEN 1 + ELSE NULL + END AS due_class, + activity_due_at AS due_at + FROM timed + ), ranked AS ( + SELECT id, api_key, group_last_used_at, due_class, due_at, + row_number() OVER ( + PARTITION BY api_key + ORDER BY due_class, + due_at NULLS FIRST, + id + ) AS group_rank + FROM candidates + WHERE due_class IS NOT NULL + ) + SELECT id, group_last_used_at + FROM ranked + WHERE group_rank = 1 + ORDER BY due_class, due_at NULLS FIRST, id + LIMIT $4 + `, now.UTC(), debounceSeconds, maxWaitSeconds, limit, minFetchIntervalSeconds) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + type dueRow struct { + id int64 + groupLastUsed *time.Time + } + rowsOut := make([]dueRow, 0, limit) + ids := make([]int64, 0, limit) + for rows.Next() { + var row dueRow + if err := rows.Scan(&row.id, &row.groupLastUsed); err != nil { + return nil, err + } + rowsOut = append(rowsOut, row) + ids = append(ids, row.id) + } + if err := rows.Err(); err != nil { + return nil, err + } + accounts, err := r.GetByIDs(ctx, ids) + if err != nil { + return nil, err + } + byID := make(map[int64]*service.Account, len(accounts)) + for _, account := range accounts { + if account != nil { + byID[account.ID] = account + } + } + result := make([]service.Account, 0, len(rowsOut)) + for _, row := range rowsOut { + account := byID[row.id] + if account == nil { + continue + } + // Stamp group MAX(last_used_at) for service due evaluation. + if row.groupLastUsed != nil { + ts := row.groupLastUsed.UTC() + account.LastUsedAt = &ts + } else { + account.LastUsedAt = nil + } + result = append(result, *account) + } + return result, nil +} diff --git a/backend/internal/repository/account_repo_opencode_go_usage_integration_test.go b/backend/internal/repository/account_repo_opencode_go_usage_integration_test.go new file mode 100644 index 0000000000..cd21900829 --- /dev/null +++ b/backend/internal/repository/account_repo_opencode_go_usage_integration_test.go @@ -0,0 +1,371 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +// openCodeGoManagedExtra 构造同时携带探测 / Ollama / OpenCode 受管键的 extra, +// 用于验证各身份清理分支只清自己该清的键、不跨身份误清。 +func openCodeGoManagedExtra(now time.Time) map[string]any { + return map[string]any{ + service.UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"}, + service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture", + service.OllamaCloudUsageAutoRefreshExtraKey: true, + service.OllamaCloudUsageSnapshotExtraKey: map[string]any{ + "status": service.OllamaCloudUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour), + }, + service.OpenCodeGoUsageAutoRefreshExtraKey: true, + service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{ + "status": service.OpenCodeGoUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour), + }, + } +} + +// F1:混合 OpenCode + openai Ollama + anthropic Ollama 行的批量代理变化与 +// 代理+key 变化,验证 OpenCode/Ollama CASE 分支互斥、各自只清自己该清的键。 +// 回归点:OpenCode eligible 判定若不含旧行 opencode base URL,OpenAI+Ollama 行 +// 在代理变化时会先命中 OpenCode 分支而遮蔽 Ollama 快照清理。 +func TestBulkUpdateOpenCodeGoMixedPlatformIdentityCleanup(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + now := time.Now().UTC() + + create := func(name, platform, baseURL string, extra map[string]any) *service.Account { + t.Helper() + return mustCreateAccount(t, tx.Client(), &service.Account{ + Name: name, Platform: platform, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "shared-key", "base_url": baseURL}, + Extra: extra, + }) + } + ollamaExtra := func() map[string]any { + return map[string]any{ + service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture", + service.OllamaCloudUsageAutoRefreshExtraKey: true, + service.OllamaCloudUsageSnapshotExtraKey: map[string]any{ + "status": service.OllamaCloudUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour), + }, + } + } + opencode := create("mixed-opencode", service.PlatformOpenAI, "https://opencode.ai/zen/go/v1", map[string]any{ + service.OpenCodeGoUsageAutoRefreshExtraKey: true, + service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{ + "status": service.OpenCodeGoUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour), + }, + }) + openaiOllama := create("mixed-openai-ollama", service.PlatformOpenAI, "https://ollama.com", ollamaExtra()) + anthropicOllama := create("mixed-anthropic-ollama", service.PlatformAnthropic, "https://ollama.com", ollamaExtra()) + proxy := mustCreateProxy(t, tx.Client(), &service.Proxy{ + Name: "mixed-proxy", Protocol: "http", Host: "127.0.0.1", Port: 3128, + Username: "user", Password: "pass", Status: service.StatusActive, + }) + proxy2 := mustCreateProxy(t, tx.Client(), &service.Proxy{ + Name: "mixed-proxy-2", Protocol: "http", Host: "127.0.0.2", Port: 3128, + Username: "user", Password: "pass", Status: service.StatusActive, + }) + + // 只改 proxy:OpenCode 行清快照留开关;Ollama 行清快照留 session/开关。 + rows, err := repo.BulkUpdate(ctx, []int64{opencode.ID, openaiOllama.ID, anthropicOllama.ID}, service.AccountBulkUpdate{ + ProxyID: &proxy.ID, + }) + require.NoError(t, err) + require.Equal(t, int64(3), rows) + + opencodeLoaded, err := repo.GetByID(ctx, opencode.ID) + require.NoError(t, err) + require.Equal(t, true, opencodeLoaded.Extra[service.OpenCodeGoUsageAutoRefreshExtraKey]) + require.NotContains(t, opencodeLoaded.Extra, service.OpenCodeGoUsageSnapshotExtraKey) + + for _, id := range []int64{openaiOllama.ID, anthropicOllama.ID} { + loaded, err := repo.GetByID(ctx, id) + require.NoError(t, err) + require.Equal(t, "cipher:wos-session=fixture", loaded.Extra[service.OllamaCloudUsageSessionExtraKey]) + require.Equal(t, true, loaded.Extra[service.OllamaCloudUsageAutoRefreshExtraKey]) + require.NotContains(t, loaded.Extra, service.OllamaCloudUsageSnapshotExtraKey, + "OpenAI+Ollama 行的快照必须由 Ollama 分支清理,不能被 OpenCode 分支遮蔽") + } + + // 同时改 proxy + api_key:OpenCode 行清开关+快照;Ollama 行清 session/开关/快照。 + rows, err = repo.BulkUpdate(ctx, []int64{opencode.ID, openaiOllama.ID, anthropicOllama.ID}, service.AccountBulkUpdate{ + ProxyID: &proxy2.ID, + Credentials: map[string]any{"api_key": "rotated-key"}, + }) + require.NoError(t, err) + require.Equal(t, int64(3), rows) + + opencodeLoaded, err = repo.GetByID(ctx, opencode.ID) + require.NoError(t, err) + require.NotContains(t, opencodeLoaded.Extra, service.OpenCodeGoUsageAutoRefreshExtraKey) + require.NotContains(t, opencodeLoaded.Extra, service.OpenCodeGoUsageSnapshotExtraKey) + + for _, id := range []int64{openaiOllama.ID, anthropicOllama.ID} { + loaded, err := repo.GetByID(ctx, id) + require.NoError(t, err) + require.NotContains(t, loaded.Extra, service.OllamaCloudUsageSessionExtraKey) + require.NotContains(t, loaded.Extra, service.OllamaCloudUsageAutoRefreshExtraKey) + require.NotContains(t, loaded.Extra, service.OllamaCloudUsageSnapshotExtraKey) + } +} + +// F2:UpdateCredentials 的 OpenCode 分支必须与通用身份变化不变量一致—— +// 清 probe、OpenCode 两键以及不应跨身份残留的 Ollama session/auto/snapshot。 +func TestUpdateCredentialsOpenCodeGoClearsAllManagedStateOnIdentityChange(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + now := time.Now().UTC() + + account := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "opencode-credentials", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "old-key", "base_url": "https://opencode.ai/zen/go/v1"}, + Extra: openCodeGoManagedExtra(now), + }) + + require.NoError(t, repo.UpdateCredentials(ctx, account.ID, map[string]any{ + "api_key": "new-key", "base_url": "https://opencode.ai/zen/go/v1", + })) + loaded, err := repo.GetByID(ctx, account.ID) + require.NoError(t, err) + require.NotContains(t, loaded.Extra, service.UpstreamBillingProbeExtraKey) + require.NotContains(t, loaded.Extra, service.OpenCodeGoUsageAutoRefreshExtraKey) + require.NotContains(t, loaded.Extra, service.OpenCodeGoUsageSnapshotExtraKey) + require.NotContains(t, loaded.Extra, service.OllamaCloudUsageSessionExtraKey) + require.NotContains(t, loaded.Extra, service.OllamaCloudUsageAutoRefreshExtraKey) + require.NotContains(t, loaded.Extra, service.OllamaCloudUsageSnapshotExtraKey) +} + +// F3:新 credentials 缺 base_url(NULL)时 OpenCode 分支必须仍命中(NULL-safe), +// 否则 OpenCode 受管状态会残留。api_key 保持不变,专门压 NOT regex(NULL) 路径。 +func TestUpdateCredentialsOpenCodeGoMissingBaseURLClearsManagedState(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + now := time.Now().UTC() + + account := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "opencode-missing-base-url", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "old-key", "base_url": "https://opencode.ai/zen/go/v1"}, + Extra: map[string]any{ + service.OpenCodeGoUsageAutoRefreshExtraKey: true, + service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{ + "status": service.OpenCodeGoUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour), + }, + }, + }) + + require.NoError(t, repo.UpdateCredentials(ctx, account.ID, map[string]any{ + "api_key": "old-key", + })) + loaded, err := repo.GetByID(ctx, account.ID) + require.NoError(t, err) + require.NotContains(t, loaded.Extra, service.OpenCodeGoUsageAutoRefreshExtraKey) + require.NotContains(t, loaded.Extra, service.OpenCodeGoUsageSnapshotExtraKey) +} + +// F3 延伸:BulkUpdate 移除 base_url(null)时 opencode 受管键必须清除, +// 否则 NOT regex(NULL) 为 NULL 会令 WHEN 不命中而残留。 +func TestBulkUpdateOpenCodeGoRemovedBaseURLClearsManagedState(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + now := time.Now().UTC() + + account := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "opencode-bulk-remove-base-url", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "old-key", "base_url": "https://opencode.ai/zen/go/v1"}, + Extra: map[string]any{ + service.OpenCodeGoUsageAutoRefreshExtraKey: true, + service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{ + "status": service.OpenCodeGoUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour), + }, + }, + }) + + rows, err := repo.BulkUpdate(ctx, []int64{account.ID}, service.AccountBulkUpdate{ + Credentials: map[string]any{"base_url": nil}, + }) + require.NoError(t, err) + require.Equal(t, int64(1), rows) + loaded, err := repo.GetByID(ctx, account.ID) + require.NoError(t, err) + require.NotContains(t, loaded.Extra, service.OpenCodeGoUsageAutoRefreshExtraKey) + require.NotContains(t, loaded.Extra, service.OpenCodeGoUsageSnapshotExtraKey) +} + +// F6:SQL eligible 判定与 Go 侧对齐,接受显式默认端口 :443。 +func TestOpenCodeGoUsageEligibleSQLAcceptsDefaultPort443(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + + account := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "opencode-port-443", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "port-key", "base_url": "https://opencode.ai:443/zen/go/v1"}, + Extra: map[string]any{}, + }) + + // ListOpenCodeGoUsageGroupAccounts 走 opencodeGoUsageEligibleSQL,能解析到 + // :443 行即证明 SQL 正则与 Go 判定一致。 + groups, err := repo.ListOpenCodeGoUsageGroupAccounts(ctx, []*service.Account{account}) + require.NoError(t, err) + require.Len(t, groups, 1) + require.Equal(t, account.ID, groups[0].ID) +} + +// 资格泛化回归:opencode_go 平台 Go 订阅账号(不依赖 base_url,未设置 +// account_mode 默认 Go)与 Anthropic 基址(/zen/go)挂载行都必须命中 +// opencodeGoUsageEligibleSQL;同 key 的 Zen 模式行必须被 SQL 排除。 +func TestOpenCodeGoUsageEligibleSQLOpencodePlatformAndAnthropicBaseURL(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + + platformGo := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "opencode-platform-go", Platform: service.PlatformOpenCodeGo, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "platform-key"}, // 无 base_url、无 account_mode(默认 Go) + Extra: map[string]any{}, + }) + // 同 key 的 Zen 行:SQL 必须排除(account_mode = zen)。 + platformZen := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "opencode-platform-zen", Platform: service.PlatformOpenCodeGo, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "platform-key", "account_mode": service.AccountModeZen}, + Extra: map[string]any{}, + }) + anthropicMount := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "opencode-anthropic-mount", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "mount-key", "base_url": "https://opencode.ai/zen/go"}, + Extra: map[string]any{}, + }) + + groups, err := repo.ListOpenCodeGoUsageGroupAccounts(ctx, []*service.Account{platformGo, anthropicMount}) + require.NoError(t, err) + require.Len(t, groups, 2) + ids := make(map[int64]struct{}, len(groups)) + for _, account := range groups { + ids[account.ID] = struct{}{} + } + require.Contains(t, ids, platformGo.ID) + require.Contains(t, ids, anthropicMount.ID) + require.NotContains(t, ids, platformZen.ID, "同 key 的 Zen 模式行必须被 SQL 谓词排除") +} + +// 资格泛化回归:UpdateCredentials 的 OpenCode 清理分支里,IS NOT TRUE 只能作用于 +// 新凭证的检查本身,平台条件必须在作用域外——否则挂载行 (FALSE AND …) IS NOT TRUE +// 恒为 TRUE,仅添加无关凭证键也会误清受管键。两类行在 api_key/身份不变、凭证仅 +// 无关变化时都必须保留 OpenCode 受管键。 +func TestUpdateCredentialsOpenCodeGoUnrelatedCredentialChangeKeepsManagedState(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + now := time.Now().UTC() + + create := func(name, platform string, credentials map[string]any) *service.Account { + return mustCreateAccount(t, tx.Client(), &service.Account{ + Name: name, Platform: platform, Type: service.AccountTypeAPIKey, + Credentials: credentials, + Extra: map[string]any{ + service.OpenCodeGoUsageAutoRefreshExtraKey: true, + service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{ + "status": service.OpenCodeGoUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour), + }, + }, + }) + } + anthropicMount := create("opencode-mount-unrelated", service.PlatformAnthropic, map[string]any{ + "api_key": "mount-key", "base_url": "https://opencode.ai/zen/go", + }) + platformGo := create("opencode-platform-unrelated", service.PlatformOpenCodeGo, map[string]any{ + "api_key": "platform-key", "account_mode": service.AccountModeGo, + }) + + // 挂载行:api_key 与官方基址都未变,仅新增无关凭证键。 + require.NoError(t, repo.UpdateCredentials(ctx, anthropicMount.ID, map[string]any{ + "api_key": "mount-key", "base_url": "https://opencode.ai/zen/go", "remark": "unrelated", + })) + // opencode_go 行:api_key 未变、模式仍为 Go,仅新增无关凭证键。 + require.NoError(t, repo.UpdateCredentials(ctx, platformGo.ID, map[string]any{ + "api_key": "platform-key", "account_mode": service.AccountModeGo, "remark": "unrelated", + })) + + for _, id := range []int64{anthropicMount.ID, platformGo.ID} { + loaded, err := repo.GetByID(ctx, id) + require.NoError(t, err) + require.Equal(t, true, loaded.Extra[service.OpenCodeGoUsageAutoRefreshExtraKey]) + snapshot, ok := loaded.Extra[service.OpenCodeGoUsageSnapshotExtraKey].(map[string]any) + require.True(t, ok) + require.Equal(t, service.OpenCodeGoUsageStatusOK, snapshot["status"]) + } +} + +// F7:生产故障路径——前端脱敏普通编辑(incoming Extra 不含 OpenCode 受管键)经真实 +// repo.Update 走 lockAndMergeAccountProbeExtra 的 SELECT/Scan/UPDATE 后,custom 生效 +// 且两个受管键从锁定 DB 行回填;incoming Extra 伪造受管键时以锁定 DB 值为准。 +// 旧代码(无 opencode 三列与回填分支)下:脱敏编辑会直接抹掉 DB 里的开关与快照, +// 伪造编辑会把伪造值原样写入,两条断言都会失败。 +func TestUpdateOpenCodeGoManagedStateSurvivesDesensitizedEdit(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + now := time.Now().UTC() + + proxy := mustCreateProxy(t, tx.Client(), &service.Proxy{ + Name: "opencode-edit-proxy", Protocol: "http", Host: "127.0.0.1", Port: 3128, + Username: "user", Password: "pass", Status: service.StatusActive, + }) + account := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "opencode-desensitized-edit", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "edit-key", "base_url": "https://opencode.ai/zen/go/v1"}, + ProxyID: &proxy.ID, + Extra: map[string]any{ + service.OpenCodeGoUsageAutoRefreshExtraKey: true, + service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{ + "status": service.OpenCodeGoUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour), + }, + "custom": "original", + }, + }) + + // 前端脱敏普通编辑:incoming Extra 不含两个受管键,只改 custom;base_url 用 + // :443 + 尾斜杠变体证明 normalized 身份判定(同 ID/platform/type/api_key/proxy)。 + edit, err := repo.GetByID(ctx, account.ID) + require.NoError(t, err) + edit.Credentials = map[string]any{"api_key": "edit-key", "base_url": "https://opencode.ai:443/zen/go/v1/"} + edit.Extra = map[string]any{"custom": "edited"} + require.NoError(t, repo.Update(ctx, edit)) + + loaded, err := repo.GetByID(ctx, account.ID) + require.NoError(t, err) + require.Equal(t, "edited", loaded.Extra["custom"]) + require.Equal(t, true, loaded.Extra[service.OpenCodeGoUsageAutoRefreshExtraKey], + "脱敏编辑不得抹掉 auto_refresh") + snapshot, ok := loaded.Extra[service.OpenCodeGoUsageSnapshotExtraKey].(map[string]any) + require.True(t, ok) + require.Equal(t, service.OpenCodeGoUsageStatusOK, snapshot["status"], "脱敏编辑不得抹掉快照") + + // incoming Extra 伪造受管键:锁定 DB 值必须胜出。 + forged, err := repo.GetByID(ctx, account.ID) + require.NoError(t, err) + forged.Extra = map[string]any{ + service.OpenCodeGoUsageAutoRefreshExtraKey: false, + service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "forged"}, + "custom": "edited-again", + } + require.NoError(t, repo.Update(ctx, forged)) + + loaded, err = repo.GetByID(ctx, account.ID) + require.NoError(t, err) + require.Equal(t, "edited-again", loaded.Extra["custom"]) + require.Equal(t, true, loaded.Extra[service.OpenCodeGoUsageAutoRefreshExtraKey], + "伪造 auto_refresh 必须以锁定 DB 值为准") + snapshot, ok = loaded.Extra[service.OpenCodeGoUsageSnapshotExtraKey].(map[string]any) + require.True(t, ok) + require.Equal(t, service.OpenCodeGoUsageStatusOK, snapshot["status"], "伪造快照必须以锁定 DB 值为准") +} diff --git a/backend/internal/repository/account_repo_opencode_go_usage_test.go b/backend/internal/repository/account_repo_opencode_go_usage_test.go new file mode 100644 index 0000000000..8a5add8ffd --- /dev/null +++ b/backend/internal/repository/account_repo_opencode_go_usage_test.go @@ -0,0 +1,526 @@ +package repository + +import ( + "context" + "encoding/json" + "regexp" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func openCodeGoUsageRepositoryAccount() *service.Account { + previousAttempt := time.Date(2026, time.August, 15, 8, 0, 0, 0, time.UTC) + return &service.Account{ + ID: 17, + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key", "base_url": "https://opencode.ai/zen/go/v1"}, + Extra: map[string]any{ + service.OpenCodeGoUsageAutoRefreshExtraKey: true, + service.OpenCodeGoUsageSnapshotExtraKey: &service.OpenCodeGoUsageSnapshot{ + Status: service.OpenCodeGoUsageStatusOK, + LastAttemptAt: previousAttempt, + NextRefreshAt: previousAttempt.Add(time.Hour), + }, + }, + } +} + +func TestUpdateOpenCodeGoUsageSnapshotWritesSnapshotOnly(t *testing.T) { + client, mock := newOllamaCloudUsageRepositoryTestClient(t) + account := openCodeGoUsageRepositoryAccount() + previousSnapshotJSON, err := json.Marshal(account.Extra[service.OpenCodeGoUsageSnapshotExtraKey]) + require.NoError(t, err) + + attemptedAt := time.Date(2026, time.August, 15, 9, 0, 0, 0, time.UTC) + snapshot := &service.OpenCodeGoUsageSnapshot{ + Status: service.OpenCodeGoUsageStatusOK, + LastAttemptAt: attemptedAt, + NextRefreshAt: attemptedAt.Add(time.Hour), + } + expectedPayload, err := json.Marshal(map[string]any{ + service.OpenCodeGoUsageSnapshotExtraKey: snapshot, + }) + require.NoError(t, err) + require.NotContains(t, string(expectedPayload), service.OpenCodeGoUsageAutoRefreshExtraKey) + + credentials, err := json.Marshal(normalizeJSONMap(account.Credentials)) + require.NoError(t, err) + mock.ExpectBegin() + mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")). + WithArgs("key", account.ID, account.Platform, account.Type, string(credentials), nil). + WillReturnRows(sqlmock.NewRows([]string{"id", "anchor_matches", "auto_refresh", "snapshot"}). + AddRow(account.ID, true, `true`, string(previousSnapshotJSON))) + mock.ExpectExec(`(?s)`+regexp.QuoteMeta("UPDATE accounts")). + WithArgs(string(expectedPayload), "key", sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + repo := newAccountRepositoryWithSQL(client, nil, nil) + + err = repo.UpdateOpenCodeGoUsageSnapshot(context.Background(), account, snapshot) + + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +// lockAndMergeAccountProbeExtra 的 SELECT 现在多出三列(opencode 组身份 / 开关 / 快照), +// 供通用 Update 路径做 OpenCode 受管键的原子回填。 +func openCodeGoMergeMockColumns() []string { + return []string{ + "identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", + "enabled", "rate_sync_enabled", "snapshot", + "ollama_session", "ollama_auto", "ollama_snapshot", + "opencode_group_unchanged", "opencode_auto", "opencode_snapshot", + } +} + +func openCodeGoSnapshotJSON() string { + previousAttempt := time.Date(2026, time.August, 15, 8, 0, 0, 0, time.UTC) + raw, err := json.Marshal(&service.OpenCodeGoUsageSnapshot{ + Status: service.OpenCodeGoUsageStatusOK, + LastAttemptAt: previousAttempt, + NextRefreshAt: previousAttempt.Add(time.Hour), + }) + if err != nil { + panic(err) + } + return string(raw) +} + +func TestLockAndMergeAccountProbeExtraPreservesOpenCodeGoManagedState(t *testing.T) { + tests := []struct { + name string + account *service.Account + groupUnchanged bool + proxyUnchanged bool + databaseAuto string + databaseSnapshot string + inputExtra map[string]any + wantAuto any // nil 表示键不存在 + wantSnapshot any // nil 表示键不存在 + wantSnapshotCheck bool + }{ + { + name: "identity unchanged restores both managed keys over forged input", + account: openCodeGoUsageRepositoryAccount(), + groupUnchanged: true, + proxyUnchanged: true, + databaseAuto: "true", + databaseSnapshot: openCodeGoSnapshotJSON(), + inputExtra: map[string]any{ + service.OpenCodeGoUsageAutoRefreshExtraKey: false, + service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "forged"}, + }, + wantAuto: true, + wantSnapshotCheck: true, + }, + { + name: "identity unchanged keeps disabled switch from database", + account: openCodeGoUsageRepositoryAccount(), + groupUnchanged: true, + proxyUnchanged: true, + databaseAuto: "false", + databaseSnapshot: openCodeGoSnapshotJSON(), + inputExtra: map[string]any{ + service.OpenCodeGoUsageAutoRefreshExtraKey: true, + }, + wantAuto: false, + wantSnapshotCheck: true, + }, + { + name: "api key changed clears both managed keys", + account: openCodeGoUsageRepositoryAccount(), + groupUnchanged: false, + proxyUnchanged: true, + databaseAuto: "true", + databaseSnapshot: openCodeGoSnapshotJSON(), + inputExtra: map[string]any{ + service.OpenCodeGoUsageAutoRefreshExtraKey: true, + service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "stale"}, + }, + }, + { + name: "base url no longer eligible clears both managed keys", + account: openCodeGoUsageRepositoryAccount(), + groupUnchanged: false, + proxyUnchanged: true, + databaseAuto: "true", + databaseSnapshot: openCodeGoSnapshotJSON(), + inputExtra: map[string]any{"custom": "value"}, + }, + { + name: "proxy changed invalidates snapshot but keeps auto-refresh switch", + account: openCodeGoUsageRepositoryAccount(), + groupUnchanged: true, + proxyUnchanged: false, + databaseAuto: "true", + databaseSnapshot: openCodeGoSnapshotJSON(), + inputExtra: map[string]any{"custom": "value"}, + wantAuto: true, + }, + { + name: "no longer eligible account never inherits managed keys", + account: func() *service.Account { + account := openCodeGoUsageRepositoryAccount() + account.Type = service.AccountTypeOAuth + return account + }(), + groupUnchanged: false, + proxyUnchanged: true, + databaseAuto: "true", + databaseSnapshot: openCodeGoSnapshotJSON(), + inputExtra: map[string]any{ + service.OpenCodeGoUsageAutoRefreshExtraKey: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, mock := newOllamaCloudUsageRepositoryTestClient(t) + credentials, err := json.Marshal(normalizeJSONMap(tt.account.Credentials)) + require.NoError(t, err) + mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")). + WithArgs(tt.account.ID, tt.account.Platform, tt.account.Type, string(credentials), nil). + WillReturnRows(sqlmock.NewRows(openCodeGoMergeMockColumns()). + AddRow(false, false, tt.proxyUnchanged, nil, nil, nil, nil, nil, nil, tt.groupUnchanged, tt.databaseAuto, tt.databaseSnapshot)) + + got, err := lockAndMergeAccountProbeExtra(context.Background(), client, tt.account, nil, nil) + require.NoError(t, err) + if tt.wantAuto == nil { + require.NotContains(t, got, service.OpenCodeGoUsageAutoRefreshExtraKey) + } else { + require.Equal(t, tt.wantAuto, got[service.OpenCodeGoUsageAutoRefreshExtraKey]) + } + if tt.wantSnapshotCheck { + snapshot, ok := got[service.OpenCodeGoUsageSnapshotExtraKey].(map[string]any) + require.True(t, ok) + require.Equal(t, service.OpenCodeGoUsageStatusOK, snapshot["status"]) + } else if tt.wantSnapshot == nil { + require.NotContains(t, got, service.OpenCodeGoUsageSnapshotExtraKey) + } else { + require.Equal(t, tt.wantSnapshot, got[service.OpenCodeGoUsageSnapshotExtraKey]) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestUpdateCredentialsOpenCodeGoIdentityChangeClearsManagedExtra(t *testing.T) { + client, mock := newOllamaCloudUsageRepositoryTestClient(t) + mock.ExpectBegin() + // opencode 清理分支必须文本上先于 ollama 分支出现,否则 opencode 行的 + // api_key/base_url 变化会被先求值的 Ollama 分支遮蔽。 + mock.ExpectExec(`(?s)UPDATE accounts.*- 'opencode_go_usage_auto_refresh'.*- 'opencode_go_usage_snapshot'.*- 'ollama_cloud_usage_session'`). + WithArgs(`{"api_key":"new-key","base_url":"https://opencode.ai/zen/go/v1"}`, int64(17)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")). + WithArgs(service.SchedulerOutboxEventAccountChanged, int64(17), nil, nil, sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + repo := newAccountRepositoryWithSQL(client, nil, nil) + + err := repo.UpdateCredentials(context.Background(), 17, map[string]any{ + "api_key": "new-key", "base_url": "https://opencode.ai/zen/go/v1", + }) + + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +// opencode → ollama 的 base_url 跨域变化同样必须清除 opencode 受管键(不再 eligible)。 +func TestUpdateCredentialsOpenCodeGoToOllamaCrossOverClearsManagedExtra(t *testing.T) { + client, mock := newOllamaCloudUsageRepositoryTestClient(t) + mock.ExpectBegin() + mock.ExpectExec(`(?s)UPDATE accounts.*opencode_go_usage_auto_refresh.*opencode_go_usage_snapshot`). + WithArgs(`{"api_key":"same-key","base_url":"https://ollama.com/v1"}`, int64(17)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")). + WithArgs(service.SchedulerOutboxEventAccountChanged, int64(17), nil, nil, sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + repo := newAccountRepositoryWithSQL(client, nil, nil) + + err := repo.UpdateCredentials(context.Background(), 17, map[string]any{ + "api_key": "same-key", "base_url": "https://ollama.com/v1", + }) + + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +// OpenCode 清理分支必须带顶层 credentials DISTINCT 守卫:凭证未变化时不能误清受管键。 +func TestUpdateCredentialsOpenCodeGoCleanupRequiresChangedCredentials(t *testing.T) { + client, mock := newOllamaCloudUsageRepositoryTestClient(t) + mock.ExpectBegin() + mock.ExpectExec(`(?s)UPDATE accounts.*CASE.*AND credentials IS DISTINCT FROM \$1::jsonb`). + WithArgs(`{"api_key":"same-key","base_url":"https://relay.example.com/v1"}`, int64(17)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")). + WithArgs(service.SchedulerOutboxEventAccountChanged, int64(17), nil, nil, sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + repo := newAccountRepositoryWithSQL(client, nil, nil) + + err := repo.UpdateCredentials(context.Background(), 17, map[string]any{ + "api_key": "same-key", "base_url": "https://relay.example.com/v1", + }) + + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestBulkUpdateOpenCodeGoIdentityCleanupIsValueConditional(t *testing.T) { + exec := &recordingSQLExecutor{result: rowsAffectedResult(1)} + repo := newAccountRepositoryWithSQL(nil, exec, nil) + + _, err := repo.BulkUpdate(context.Background(), []int64{17}, service.AccountBulkUpdate{ + Credentials: map[string]any{"api_key": "new-key"}, + }) + + require.NoError(t, err) + require.NotEmpty(t, exec.execQueries) + query := normalizeSQLWhitespace(exec.execQueries[0]) + // OpenCode 分支必须出现在 Ollama 分支之前,且 eligible 判定按新谓词同时覆盖 + // opencode_go 平台(Go 订阅)与挂载白名单平台 + opencode 基址。 + require.Contains(t, query, "platform = 'opencode_go'") + require.Contains(t, query, "platform IN ("+opencodeGoUsageMountPlatformsSQL+")") + require.Contains(t, query, "- 'opencode_go_usage_auto_refresh' - 'opencode_go_usage_snapshot'") + opencodeBranch := strings.Index(query, "opencode_go_usage_auto_refresh") + ollamaBranch := strings.Index(query, "ollama_cloud_usage_auto_refresh") + require.NotEqual(t, -1, opencodeBranch) + require.NotEqual(t, -1, ollamaBranch) + require.Less(t, opencodeBranch, ollamaBranch) +} + +// F1 回归:OpenCode eligible 判定必须包含旧行 opencode base URL,否则 OpenAI+Ollama +// 行在代理变化时会先命中 OpenCode 分支而遮蔽 Ollama 快照清理。这里断言 OpenCode 分支 +// 的 WHEN 携带 opencode 正则(与 Ollama 正则互斥),真实行为由 integration 测试覆盖。 +func TestBulkUpdateOpenCodeGoEligiblePredicateIncludesBaseURL(t *testing.T) { + exec := &recordingSQLExecutor{result: rowsAffectedResult(1)} + repo := newAccountRepositoryWithSQL(nil, exec, nil) + + proxyID := int64(9) + _, err := repo.BulkUpdate(context.Background(), []int64{17}, service.AccountBulkUpdate{ + ProxyID: &proxyID, + }) + + require.NoError(t, err) + require.NotEmpty(t, exec.execQueries) + query := normalizeSQLWhitespace(exec.execQueries[0]) + // 第一个 WHEN 分支是 OpenCode 快照失效分支(代理变化),其 eligible 判定必须 + // 包含 opencode base URL 正则,使 OpenAI+Ollama 行无法命中该分支。 + caseStart := strings.Index(query, "CASE") + firstThen := strings.Index(query, "THEN") + require.NotEqual(t, -1, caseStart) + require.NotEqual(t, -1, firstThen) + require.Less(t, caseStart, firstThen) + firstWhen := query[caseStart:firstThen] + // 第一个 WHEN 分支是 OpenCode 快照失效分支(代理变化),其 eligible 判定必须 + // 覆盖 opencode_go 平台与挂载白名单的 opencode 基址,使 Ollama 行无法命中。 + require.Contains(t, firstWhen, "platform = 'opencode_go'") + require.Contains(t, firstWhen, "[oO][pP][eE][nN][cC][oO][dD][eE]") + require.Contains(t, firstWhen, "credentials ->> 'base_url'") + require.NotContains(t, firstWhen, "[oO][lL][lL][aA][mM][aA]") +} + +// F3 延伸:BulkUpdate 的 opencode base_url 变化子句同样必须 NULL-safe——新 base_url +// 缺失/为 null 时 regex(NULL) 为 NULL,NOT NULL 仍为 NULL 会令 WHEN 不命中而残留 +// OpenCode 状态;IS NOT TRUE 把 NULL 视为不匹配。 +func TestBulkUpdateOpenCodeGoBaseURLClauseIsNullSafe(t *testing.T) { + exec := &recordingSQLExecutor{result: rowsAffectedResult(1)} + repo := newAccountRepositoryWithSQL(nil, exec, nil) + + _, err := repo.BulkUpdate(context.Background(), []int64{17}, service.AccountBulkUpdate{ + Credentials: map[string]any{"base_url": nil}, + }) + + require.NoError(t, err) + require.NotEmpty(t, exec.execQueries) + query := normalizeSQLWhitespace(exec.execQueries[0]) + require.Contains(t, query, "IS NOT TRUE") + require.NotContains(t, query, "AND NOT btrim(credentials ->> 'base_url')") +} + +// F6 回归:SQL 正则与 service.isOpenCodeGoBaseURL 对齐,接受显式默认端口 :443 与 +// 两个官方基址变体(CC/Responses 的 /zen/go/v1、Anthropic 的 /zen/go)。该正则 +// 同时用于 eligible 判定与身份清理,Go 侧接受而 SQL 侧拒绝会导致漏清/漏组。 +func TestOpenCodeGoBaseURLRegexSQLAcceptsDefaultPort443(t *testing.T) { + re := regexp.MustCompile(opencodeGoBaseURLRegexSQL) + for _, url := range []string{ + "https://opencode.ai/zen/go/v1", + "https://opencode.ai/zen/go/v1/", + "https://opencode.ai:443/zen/go/v1", + "https://opencode.ai:443/zen/go/v1/", + "https://opencode.ai/zen/go", + "https://opencode.ai/zen/go/", + "https://opencode.ai:443/zen/go", + "HTTPS://OPENCODE.AI:443/ZEN/GO/V1", + } { + require.True(t, re.MatchString(url), "SQL regex must accept %s", url) + } + for _, url := range []string{ + "https://opencode.ai:444/zen/go/v1", + "https://opencode.ai/v1", + "https://ollama.com/zen/go/v1", + "https://opencode.ai/zen/go/v1?x=1", + // zen 基址必须拒绝:按量付费无订阅配额窗口 + "https://opencode.ai/zen", + "https://opencode.ai/zen/", + "https://opencode.ai/zen/v1", + "https://opencode.ai/zen/v1/", + // 非官方 scheme / 子域 / 路径前缀拼接 + "http://opencode.ai/zen/go/v1", + "https://www.opencode.ai/zen/go/v1", + "https://opencode.ai/zen/gov1", + } { + require.False(t, re.MatchString(url), "SQL regex must reject %s", url) + } +} + +// SQL 挂载平台白名单常量与 service 判定必须互为镜像:对每个挂载候选平台,常量里 +// 的成员关系都要与 IsOpenCodeGoUsageAccount(apikey + 官方 OpenCode Go 基址)一致, +// 防止两侧平台列表各自漂移。opencode_go 走平台 + Go 订阅分支而非挂载白名单: +// 不得出现在白名单常量里,且其资格由 account_mode 决定,在此一并钉死。 +func TestOpenCodeGoUsagePlatformWhitelistMatchesServicePredicate(t *testing.T) { + matches := regexp.MustCompile(`'([^']+)'`).FindAllStringSubmatch(opencodeGoUsageMountPlatformsSQL, -1) + sqlPlatforms := make(map[string]struct{}, len(matches)) + for _, match := range matches { + sqlPlatforms[match[1]] = struct{}{} + } + require.Len(t, sqlPlatforms, 6) + for _, platform := range []string{ + service.PlatformOpenAI, service.PlatformAnthropic, + service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, + service.PlatformGemini, service.PlatformGrok, service.PlatformAntigravity, + service.PlatformComposite, "kiro", + } { + account := openCodeGoUsageRepositoryAccount() + account.Platform = platform + _, inSQL := sqlPlatforms[platform] + require.Equal(t, inSQL, service.IsOpenCodeGoUsageAccount(account), platform) + } + // opencode_go 不在挂载白名单里,资格来自平台 + Go 订阅分支。 + _, opencodeInSQL := sqlPlatforms[service.PlatformOpenCodeGo] + require.False(t, opencodeInSQL) + goAccount := openCodeGoUsageRepositoryAccount() + goAccount.Platform = service.PlatformOpenCodeGo + require.True(t, service.IsOpenCodeGoUsageAccount(goAccount)) + zenAccount := openCodeGoUsageRepositoryAccount() + zenAccount.Platform = service.PlatformOpenCodeGo + zenAccount.Credentials["account_mode"] = service.AccountModeZen + require.False(t, service.IsOpenCodeGoUsageAccount(zenAccount)) +} + +func TestBulkUpdateOpenCodeGoProxyChangeClearsSnapshotOnly(t *testing.T) { + exec := &recordingSQLExecutor{result: rowsAffectedResult(1)} + repo := newAccountRepositoryWithSQL(nil, exec, nil) + + proxyID := int64(9) + _, err := repo.BulkUpdate(context.Background(), []int64{17}, service.AccountBulkUpdate{ + ProxyID: &proxyID, + }) + + require.NoError(t, err) + require.NotEmpty(t, exec.execQueries) + query := normalizeSQLWhitespace(exec.execQueries[0]) + require.Contains(t, query, "- 'opencode_go_usage_snapshot'") + require.NotContains(t, query, "- 'opencode_go_usage_auto_refresh'") +} + +func TestInvalidateProxyProbeSnapshotsClearsOpenCodeGoSnapshot(t *testing.T) { + client, mock := newOllamaCloudUsageRepositoryTestClient(t) + mock.ExpectQuery(`(?s)UPDATE accounts.*opencode_go_usage_snapshot.*RETURNING id`). + WithArgs(int64(9)). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(17))) + + ids, err := invalidateProxyProbeSnapshots(context.Background(), client, 9) + + require.NoError(t, err) + require.Equal(t, []int64{17}, ids) + require.NoError(t, mock.ExpectationsWereMet()) +} + +// UpdateCredentials 的 CASE 求值顺序是正确性依赖而非防御:Ollama 分支的 WHEN 是 +// 宽守卫(NOT(ollamaMatch(old) AND ollamaMatch(new)),旧行不匹配 ollama.com 基址 +// 时恒真,且不含 opencode.ai 正则),而两侧平台白名单完全相同,因此挂载行 +// (白名单平台 + 官方 OpenCode Go 基址)会同时满足两分支的 WHEN。若把 Ollama +// 分支前移,挂载行的 api_key 变化会先命中 Ollama 分支,OpenCode THEN 才清除的 +// opencode_go_usage_snapshot / opencode_go_usage_auto_refresh 残留,陈旧快照跟着 +// 新 api_key 走,跨 key 组污染。本测试用正则钉死 OpenCode 分支的 WHEN 标记 +// (platform = 'opencode_go',只出现在 OpenCode 分支)文本上先于 Ollama 分支的 +// WHEN 标记(ollama.com 基址正则片段)。 +func TestUpdateCredentialsOpenCodeBranchPrecedesOllamaBranch(t *testing.T) { + client, mock := newOllamaCloudUsageRepositoryTestClient(t) + mock.ExpectBegin() + mock.ExpectExec(`(?s)UPDATE accounts.*platform = 'opencode_go'.*\[oO\]\[lL\]\[lL\]\[aA\]\[mM\]\[aA\]`). + WithArgs(`{"api_key":"new-key","base_url":"https://opencode.ai/zen/go/v1"}`, int64(17)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")). + WithArgs(service.SchedulerOutboxEventAccountChanged, int64(17), nil, nil, sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + repo := newAccountRepositoryWithSQL(client, nil, nil) + + err := repo.UpdateCredentials(context.Background(), 17, map[string]any{ + "api_key": "new-key", "base_url": "https://opencode.ai/zen/go/v1", + }) + + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +// SQL 侧「默认 Go」语义的常量级护栏:opencode_go 平台行 account_mode 缺失/为 +// JSON null 时,COALESCE 兜底必须落 true(视为 Go 订阅,与 GetOpenCodeAccountMode +// 的默认兼容逻辑一致)。若误改成 false,存量 opencode_go 账号在 SQL 侧集体失去 +// 资格(组查询漏行、身份清理漏清、RunDue 自动刷新停摆),而 Go 侧仍判合格, +// 两侧不一致;唯一的行为级覆盖在需要 Docker 的 integration 测试,无 Docker 的 +// unit 通道此前完全拦不住,这里以文本形态钉死该 COALESCE 表达式。 +func TestOpenCodeGoUsageEligibleSQLDefaultsMissingAccountModeToGo(t *testing.T) { + require.Contains(t, opencodeGoUsageEligibleSQL, + "COALESCE(btrim(credentials ->> 'account_mode') <> 'zen', true)") +} + +// Go 侧「默认 Go」契约的表驱动钉死:account_mode 只有 trim 后恰好等于 "zen" 才是 +// Zen,其余取值一律判 Go。SQL 侧(opencodeGoUsageEligibleSQL)以 +// COALESCE(btrim(...) <> 'zen', true) 声明同一语义:btrim 只去空格,与本表全部 +// 取值在 Go 侧 strings.TrimSpace 下的结论一致(空白字符集差异见常量注释)。 +func TestOpenCodeAccountModeOnlyTrimmedZenIsZen(t *testing.T) { + tests := []struct { + name string + accountMode func(map[string]any) + wantMode string + wantGoPlan bool + }{ + {name: "键缺失", accountMode: func(map[string]any) {}, wantMode: service.AccountModeGo, wantGoPlan: true}, + {name: "JSON null", accountMode: func(c map[string]any) { c["account_mode"] = nil }, wantMode: service.AccountModeGo, wantGoPlan: true}, + {name: "空串", accountMode: func(c map[string]any) { c["account_mode"] = "" }, wantMode: service.AccountModeGo, wantGoPlan: true}, + {name: "go", accountMode: func(c map[string]any) { c["account_mode"] = service.AccountModeGo }, wantMode: service.AccountModeGo, wantGoPlan: true}, + {name: "zen", accountMode: func(c map[string]any) { c["account_mode"] = service.AccountModeZen }, wantMode: service.AccountModeZen, wantGoPlan: false}, + {name: "前后空格的 zen", accountMode: func(c map[string]any) { c["account_mode"] = " zen " }, wantMode: service.AccountModeZen, wantGoPlan: false}, + {name: "大小写不同的 ZEN", accountMode: func(c map[string]any) { c["account_mode"] = "ZEN" }, wantMode: service.AccountModeGo, wantGoPlan: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + account := &service.Account{ + ID: 17, + Platform: service.PlatformOpenCodeGo, + Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key"}, + } + tt.accountMode(account.Credentials) + require.Equal(t, tt.wantMode, account.GetOpenCodeAccountMode()) + require.Equal(t, tt.wantGoPlan, account.IsOpenCodeGoPlan()) + require.Equal(t, !tt.wantGoPlan, account.IsOpenCodeZen()) + // opencode_go 平台行的用量资格即 Go 订阅判定,与 SQL 侧 + // opencodeGoUsageEligibleSQL 的「默认 Go、仅 trim 后等于 zen 排除」 + // 互为镜像。 + require.Equal(t, tt.wantGoPlan, service.IsOpenCodeGoUsageAccount(account)) + }) + } +} diff --git a/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go b/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go index 46d78ac5b7..d3778ac00f 100644 --- a/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go +++ b/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go @@ -81,8 +81,8 @@ func TestLockAndMergeAccountProbeExtraUsesCurrentDatabaseSnapshot(t *testing.T) mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")). WithArgs(int64(27), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil). - WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}). - AddRow(tt.identityUnchanged, false, true, tt.databaseEnabled, nil, tt.databaseSnapshot, nil, nil, nil)) + WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}). + AddRow(tt.identityUnchanged, false, true, tt.databaseEnabled, nil, tt.databaseSnapshot, nil, nil, nil, false, nil, nil)) account := &service.Account{ ID: 27, @@ -172,8 +172,8 @@ func TestLockAndMergeAccountProbeExtraNeverInfersProbeFromRateSync(t *testing.T) mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")). WithArgs(int64(31), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil). - WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}). - AddRow(true, false, true, tt.databaseEnabled, tt.databaseRateSync, nil, nil, nil, nil)) + WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}). + AddRow(true, false, true, tt.databaseEnabled, tt.databaseRateSync, nil, nil, nil, nil, false, nil, nil)) account := &service.Account{ ID: 31, @@ -211,8 +211,8 @@ func TestLockAndMergeAccountProbeExtraProtectsOllamaManagedFields(t *testing.T) mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")). WithArgs(int64(29), service.PlatformAnthropic, service.AccountTypeAPIKey, `{"api_key":"key","base_url":"https://ollama.com"}`, nil). - WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}). - AddRow(identityUnchanged, identityUnchanged, true, nil, nil, nil, []byte(`"local-ciphertext"`), []byte(`true`), []byte(`{"status":"ok"}`))) + WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}). + AddRow(identityUnchanged, identityUnchanged, true, nil, nil, nil, []byte(`"local-ciphertext"`), []byte(`true`), []byte(`{"status":"ok"}`), false, nil, nil)) account := &service.Account{ ID: 29, Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, @@ -371,8 +371,8 @@ func TestUpdateWithAccountBillingSettingsRollsBackWhenOutboxFails(t *testing.T) mock.ExpectBegin() mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")). WithArgs(int64(27), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil). - WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}). - AddRow(true, false, true, []byte(`true`), []byte(`true`), []byte(`{"status":"ok"}`), nil, nil, nil)) + WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}). + AddRow(true, false, true, []byte(`true`), []byte(`true`), []byte(`{"status":"ok"}`), nil, nil, nil, false, nil, nil)) mock.ExpectExec(`(?s)UPDATE .*accounts.*SET.*WHERE .*id.*`). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectQuery(`(?s)SELECT .* FROM "accounts" WHERE "id" = \$1`). diff --git a/backend/internal/repository/affiliate_repo.go b/backend/internal/repository/affiliate_repo.go index 61da539e25..4e2eaaf3d4 100644 --- a/backend/internal/repository/affiliate_repo.go +++ b/backend/internal/repository/affiliate_repo.go @@ -341,6 +341,171 @@ VALUES ($1, 'transfer', $2, NULL, $3, $4, $5, $6, NOW(), NOW())`, return transferred, newBalance, nil } +// WithdrawQuota 登记一笔线下提现。operationID 标识一次登记:同一事务内先以 +// operation_id 唯一约束写入占位流水,同标识的流水已存在时不重复扣减,用户与金额 +// 一致则返回该流水记录的结果(Replayed=true),不一致返回 +// ErrIdempotencyKeyConflict。占位成功后先解冻已到期的冻结额度,再以 +// aff_quota >= amount 为条件原子扣减并补写额度快照。可提取额度不足或用户没有 +// 返利档案时返回 ErrAffiliateQuotaInsufficient,占位随事务回滚,不占用该标识。 +func (r *affiliateRepository) WithdrawQuota(ctx context.Context, userID int64, amount float64, operationID string) (*service.AffiliateWithdrawResult, error) { + if userID <= 0 { + return nil, service.ErrUserNotFound + } + if amount <= 0 { + return nil, service.ErrAffiliateWithdrawAmountInvalid + } + if operationID == "" { + return nil, service.ErrIdempotencyKeyRequired + } + + var result *service.AffiliateWithdrawResult + err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error { + ledgerID, claimed, err := claimAffiliateWithdrawLedger(txCtx, txClient, userID, amount, operationID) + if err != nil { + return err + } + if !claimed { + result, err = findAffiliateWithdrawByOperation(txCtx, txClient, userID, amount, operationID) + return err + } + + if _, err := thawFrozenQuotaTx(txCtx, txClient, userID); err != nil { + return fmt.Errorf("thaw before withdraw: %w", err) + } + + res, err := txClient.ExecContext(txCtx, ` +UPDATE user_affiliates +SET aff_quota = aff_quota - $1, + updated_at = NOW() +WHERE user_id = $2 + AND aff_quota >= $1`, amount, userID) + if err != nil { + return fmt.Errorf("deduct affiliate quota: %w", err) + } + affected, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("deduct affiliate quota: %w", err) + } + if affected == 0 { + return service.ErrAffiliateQuotaInsufficient + } + + snapshot, err := queryAffiliateTransferSnapshot(txCtx, txClient, userID) + if err != nil { + return err + } + + if _, err := txClient.ExecContext(txCtx, ` +UPDATE user_affiliate_ledger +SET balance_after = $1, + aff_quota_after = $2, + aff_frozen_quota_after = $3, + aff_history_quota_after = $4, + updated_at = NOW() +WHERE id = $5`, + snapshot.BalanceAfter, + snapshot.AvailableQuotaAfter, + snapshot.FrozenQuotaAfter, + snapshot.HistoryQuotaAfter, + ledgerID, + ); err != nil { + return fmt.Errorf("record affiliate withdraw snapshot: %w", err) + } + + result = &service.AffiliateWithdrawResult{ + LedgerID: ledgerID, + UserID: userID, + Amount: amount, + AvailableQuotaAfter: snapshot.AvailableQuotaAfter, + FrozenQuotaAfter: snapshot.FrozenQuotaAfter, + HistoryQuotaAfter: snapshot.HistoryQuotaAfter, + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +// claimAffiliateWithdrawLedger 为有返利档案的用户写入一条带 operation_id 的 +// withdraw 占位流水。同标识的流水已存在或用户没有返利档案时 claimed 为 false。 +// 同标识的并发事务在唯一索引上等待先写入者结束:先写入者提交后返回 claimed=false, +// 回滚后本事务继续写入。 +func claimAffiliateWithdrawLedger(ctx context.Context, client affiliateQueryExecer, userID int64, amount float64, operationID string) (int64, bool, error) { + rows, err := client.QueryContext(ctx, ` +INSERT INTO user_affiliate_ledger (user_id, action, amount, operation_id, created_at, updated_at) +SELECT ua.user_id, 'withdraw', $2, $3, NOW(), NOW() +FROM user_affiliates ua +WHERE ua.user_id = $1 +ON CONFLICT (operation_id) WHERE operation_id IS NOT NULL DO NOTHING +RETURNING id`, userID, amount, operationID) + if err != nil { + return 0, false, fmt.Errorf("claim affiliate withdraw ledger: %w", err) + } + defer func() { _ = rows.Close() }() + + if !rows.Next() { + if err := rows.Err(); err != nil { + return 0, false, fmt.Errorf("claim affiliate withdraw ledger: %w", err) + } + return 0, false, nil + } + var ledgerID int64 + if err := rows.Scan(&ledgerID); err != nil { + return 0, false, err + } + return ledgerID, true, rows.Close() +} + +// findAffiliateWithdrawByOperation 读取 operation_id 已对应的线下提现流水, +// 用户与金额一致时返回该流水记录的登记结果(Replayed=true),不一致返回 +// ErrIdempotencyKeyConflict;没有对应流水说明用户没有返利档案,返回 +// ErrAffiliateQuotaInsufficient。 +func findAffiliateWithdrawByOperation(ctx context.Context, client affiliateQueryExecer, userID int64, amount float64, operationID string) (*service.AffiliateWithdrawResult, error) { + rows, err := client.QueryContext(ctx, ` +SELECT id, + user_id = $2 AND action = 'withdraw' AND amount = CAST($3 AS DECIMAL(20,8)), + user_id, + amount::double precision, + COALESCE(aff_quota_after, 0)::double precision, + COALESCE(aff_frozen_quota_after, 0)::double precision, + COALESCE(aff_history_quota_after, 0)::double precision +FROM user_affiliate_ledger +WHERE operation_id = $1`, operationID, userID, amount) + if err != nil { + return nil, fmt.Errorf("query affiliate withdraw by operation: %w", err) + } + defer func() { _ = rows.Close() }() + + if !rows.Next() { + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("query affiliate withdraw by operation: %w", err) + } + return nil, service.ErrAffiliateQuotaInsufficient + } + var sameRequest bool + result := &service.AffiliateWithdrawResult{Replayed: true} + if err := rows.Scan( + &result.LedgerID, + &sameRequest, + &result.UserID, + &result.Amount, + &result.AvailableQuotaAfter, + &result.FrozenQuotaAfter, + &result.HistoryQuotaAfter, + ); err != nil { + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + if !sameRequest { + return nil, service.ErrIdempotencyKeyConflict + } + return result, nil +} + func (r *affiliateRepository) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]service.AffiliateInvitee, error) { if limit <= 0 { limit = 100 @@ -466,13 +631,14 @@ func (r *affiliateRepository) ListAffiliateRebateRecords(ctx context.Context, fi "inviter.email", "inviter.username", "invitee.email", "invitee.username", "po.id::text", "po.out_trade_no", "po.payment_type", "po.status", }) + // 返利记录列出全部 accrue 流水:非订单来源的流水没有订单,被邀请人账号 + // 删除后 source_user_id 为 NULL,这两类行都保留。 baseJoin := ` FROM user_affiliate_ledger ual -JOIN payment_orders po ON po.id = ual.source_order_id -JOIN users invitee ON invitee.id = ual.source_user_id JOIN users inviter ON inviter.id = ual.user_id -WHERE ual.action = 'accrue' - AND ual.source_order_id IS NOT NULL` +LEFT JOIN users invitee ON invitee.id = ual.source_user_id +LEFT JOIN payment_orders po ON po.id = ual.source_order_id +WHERE ual.action = 'accrue'` if where != "" { where = strings.Replace(where, "WHERE ", " AND ", 1) } @@ -496,7 +662,7 @@ WHERE ual.action = 'accrue' args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize) rows, err := client.QueryContext(ctx, ` SELECT po.id, - po.out_trade_no, + COALESCE(po.out_trade_no, ''), ual.user_id, COALESCE(inviter.email, ''), COALESCE(inviter.username, ''), @@ -506,8 +672,8 @@ SELECT po.id, po.amount::double precision, po.pay_amount::double precision, ual.amount::double precision, - po.payment_type, - po.status, + COALESCE(po.payment_type, ''), + COALESCE(po.status, ''), ual.created_at `+baseJoin+where+` `+orderBy+` @@ -520,17 +686,21 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) items := make([]service.AffiliateRebateRecord, 0) for rows.Next() { var item service.AffiliateRebateRecord + var orderID sql.NullInt64 + var inviteeID sql.NullInt64 + var orderAmount sql.NullFloat64 + var payAmount sql.NullFloat64 if err := rows.Scan( - &item.OrderID, + &orderID, &item.OutTradeNo, &item.InviterID, &item.InviterEmail, &item.InviterUsername, - &item.InviteeID, + &inviteeID, &item.InviteeEmail, &item.InviteeUsername, - &item.OrderAmount, - &item.PayAmount, + &orderAmount, + &payAmount, &item.RebateAmount, &item.PaymentType, &item.OrderStatus, @@ -538,6 +708,10 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) ); err != nil { return nil, 0, err } + item.OrderID = nullableInt64Ptr(orderID) + item.InviteeID = nullableInt64Ptr(inviteeID) + item.OrderAmount = nullableFloat64Ptr(orderAmount) + item.PayAmount = nullableFloat64Ptr(payAmount) items = append(items, item) } if err := rows.Err(); err != nil { @@ -554,7 +728,7 @@ func (r *affiliateRepository) ListAffiliateTransferRecords(ctx context.Context, baseJoin := ` FROM user_affiliate_ledger ual JOIN users u ON u.id = ual.user_id -WHERE ual.action = 'transfer'` +WHERE ual.action IN ('transfer', 'withdraw')` if where != "" { where = strings.Replace(where, "WHERE ", " AND ", 1) } @@ -566,6 +740,7 @@ WHERE ual.action = 'transfer'` orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{ "user": "u.email", + "action": "ual.action", "amount": "ual.amount", "balance_after": "ual.balance_after", "available_quota_after": "ual.aff_quota_after", @@ -576,6 +751,7 @@ WHERE ual.action = 'transfer'` args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize) rows, err := client.QueryContext(ctx, ` SELECT ual.id, + ual.action, ual.user_id, COALESCE(u.email, ''), COALESCE(u.username, ''), @@ -602,6 +778,7 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...) var historyQuotaAfter sql.NullFloat64 if err := rows.Scan( &item.LedgerID, + &item.Action, &item.UserID, &item.UserEmail, &item.Username, @@ -958,6 +1135,13 @@ func nullableFloat64Ptr(v sql.NullFloat64) *float64 { return &v.Float64 } +func nullableInt64Ptr(v sql.NullInt64) *int64 { + if !v.Valid { + return nil + } + return &v.Int64 +} + func generateAffiliateCode() (string, error) { buf := make([]byte, affiliateCodeLength) if _, err := rand.Read(buf); err != nil { diff --git a/backend/internal/repository/affiliate_repo_integration_test.go b/backend/internal/repository/affiliate_repo_integration_test.go index b01ed528a6..c68482dd75 100644 --- a/backend/internal/repository/affiliate_repo_integration_test.go +++ b/backend/internal/repository/affiliate_repo_integration_test.go @@ -4,7 +4,10 @@ package repository import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" + "sync" "testing" "time" @@ -417,3 +420,383 @@ func TestAffiliateRepository_ListUsersWithCustomSettings(t *testing.T) { require.GreaterOrEqual(t, total, int64(2), "total must include at least our 2 custom rows") } + +func mustCreateAffiliateWithdrawUser(t *testing.T, client *dbent.Client, label string) *service.User { + t.Helper() + return mustCreateUser(t, client, &service.User{ + Email: fmt.Sprintf("affiliate-withdraw-%s-%d@example.com", label, time.Now().UnixNano()), + PasswordHash: "hash", + Role: service.RoleUser, + Status: service.StatusActive, + Balance: 5.5, + Concurrency: 5, + }) +} + +func mustSeedAffiliateQuota(t *testing.T, ctx context.Context, client *dbent.Client, userID int64, quota, frozen, history float64) { + t.Helper() + affCode := fmt.Sprintf("AFW%d-%06d", userID, time.Now().UnixNano()%1_000_000) + _, err := client.ExecContext(ctx, ` +INSERT INTO user_affiliates (user_id, aff_code, aff_quota, aff_frozen_quota, aff_history_quota, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, NOW(), NOW())`, userID, affCode, quota, frozen, history) + require.NoError(t, err) +} + +// mustCreateCommittedAffiliateWithdrawUser 在测试事务之外建用户与返利档案, +// 测试结束时删除(级联返利档案与流水)。WithdrawQuota 在这些用例里自行开启 +// 并提交事务,与生产调用一致。 +func mustCreateCommittedAffiliateWithdrawUser(t *testing.T, label string, quota, frozen, history float64) *service.User { + t.Helper() + client := testEntClient(t) + u := mustCreateAffiliateWithdrawUser(t, client, label) + t.Cleanup(func() { + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM users WHERE id = $1", u.ID) + }) + mustSeedAffiliateQuota(t, context.Background(), client, u.ID, quota, frozen, history) + return u +} + +func newAffiliateWithdrawOperationID(label string) string { + sum := sha256.Sum256([]byte(fmt.Sprintf("%s-%d", label, time.Now().UnixNano()))) + return hex.EncodeToString(sum[:]) +} + +// TestAffiliateRepository_WithdrawQuota_DeductsAndRecordsLedger 覆盖线下提现主路径: +// 只扣可提取额度,余额与累计返利不变,流水 action=withdraw 带额度快照,并出现在提取记录中。 +func TestAffiliateRepository_WithdrawQuota_DeductsAndRecordsLedger(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + txCtx := dbent.NewTxContext(ctx, tx) + client := tx.Client() + repo := NewAffiliateRepository(client, integrationDB) + + u := mustCreateAffiliateWithdrawUser(t, client, "user") + mustSeedAffiliateQuota(t, txCtx, client, u.ID, 20, 0, 30) + operationID := newAffiliateWithdrawOperationID("deduct") + + result, err := repo.WithdrawQuota(txCtx, u.ID, 12.5, operationID) + require.NoError(t, err) + require.Positive(t, result.LedgerID) + require.False(t, result.Replayed) + require.InDelta(t, 12.5, result.Amount, 1e-9) + require.InDelta(t, 7.5, result.AvailableQuotaAfter, 1e-9) + require.InDelta(t, 0.0, result.FrozenQuotaAfter, 1e-9) + require.InDelta(t, 30.0, result.HistoryQuotaAfter, 1e-9) + + require.InDelta(t, 7.5, querySingleFloat(t, txCtx, client, + "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.InDelta(t, 30.0, querySingleFloat(t, txCtx, client, + "SELECT aff_history_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.InDelta(t, 5.5, querySingleFloat(t, txCtx, client, + "SELECT balance::double precision FROM users WHERE id = $1", u.ID), 1e-9) + + records, total, err := repo.ListAffiliateTransferRecords(txCtx, service.AffiliateRecordFilter{ + Search: u.Email, + Page: 1, + PageSize: 20, + SortDesc: true, + }) + require.NoError(t, err) + require.EqualValues(t, 1, total) + require.Len(t, records, 1) + record := records[0] + require.Equal(t, result.LedgerID, record.LedgerID) + require.Equal(t, "withdraw", record.Action) + require.Equal(t, u.ID, record.UserID) + require.InDelta(t, 12.5, record.Amount, 1e-9) + require.True(t, record.SnapshotAvailable) + require.InDelta(t, 5.5, *record.BalanceAfter, 1e-9) + require.InDelta(t, 7.5, *record.AvailableQuotaAfter, 1e-9) + require.InDelta(t, 30.0, *record.HistoryQuotaAfter, 1e-9) + + rows, err := client.QueryContext(txCtx, "SELECT operation_id FROM user_affiliate_ledger WHERE id = $1", result.LedgerID) + require.NoError(t, err) + defer func() { _ = rows.Close() }() + require.True(t, rows.Next()) + var storedOperationID string + require.NoError(t, rows.Scan(&storedOperationID)) + require.Equal(t, operationID, storedOperationID) +} + +// TestAffiliateRepository_WithdrawQuota_ThawsMaturedQuotaFirst 验证已过冻结期 +// 但尚未解冻的额度在扣减前解冻并可被提现。 +func TestAffiliateRepository_WithdrawQuota_ThawsMaturedQuotaFirst(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + txCtx := dbent.NewTxContext(ctx, tx) + client := tx.Client() + repo := NewAffiliateRepository(client, integrationDB) + + u := mustCreateAffiliateWithdrawUser(t, client, "thaw") + mustSeedAffiliateQuota(t, txCtx, client, u.ID, 0, 10, 10) + _, err := client.ExecContext(txCtx, ` +INSERT INTO user_affiliate_ledger (user_id, action, amount, frozen_until, created_at, updated_at) +VALUES ($1, 'accrue', 10, NOW() - INTERVAL '1 hour', NOW(), NOW())`, u.ID) + require.NoError(t, err) + + result, err := repo.WithdrawQuota(txCtx, u.ID, 10, newAffiliateWithdrawOperationID("thaw")) + require.NoError(t, err) + require.InDelta(t, 0.0, result.AvailableQuotaAfter, 1e-9) + require.InDelta(t, 0.0, result.FrozenQuotaAfter, 1e-9) + require.Equal(t, 1, querySingleInt(t, txCtx, client, + "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID)) +} + +// TestAffiliateRepository_WithdrawQuota_InsufficientLeavesQuotaUntouched 验证未到期的 +// 冻结额度不可提现:额度不足时不扣减、不写流水,占位随事务回滚、不占用该 +// operation_id,同一标识改成可提取范围内的金额仍可登记;没有返利档案的用户 +// 同样按额度不足处理。 +func TestAffiliateRepository_WithdrawQuota_InsufficientLeavesQuotaUntouched(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + repo := NewAffiliateRepository(client, integrationDB) + + u := mustCreateCommittedAffiliateWithdrawUser(t, "insufficient", 5, 10, 15) + _, err := integrationDB.ExecContext(ctx, ` +INSERT INTO user_affiliate_ledger (user_id, action, amount, frozen_until, created_at, updated_at) +VALUES ($1, 'accrue', 10, NOW() + INTERVAL '1 hour', NOW(), NOW())`, u.ID) + require.NoError(t, err) + operationID := newAffiliateWithdrawOperationID("insufficient") + + _, err = repo.WithdrawQuota(ctx, u.ID, 6, operationID) + require.ErrorIs(t, err, service.ErrAffiliateQuotaInsufficient) + + require.InDelta(t, 5.0, querySingleFloat(t, ctx, client, + "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.InDelta(t, 10.0, querySingleFloat(t, ctx, client, + "SELECT aff_frozen_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.Equal(t, 0, querySingleInt(t, ctx, client, + "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID)) + + result, err := repo.WithdrawQuota(ctx, u.ID, 5, operationID) + require.NoError(t, err) + require.False(t, result.Replayed) + require.InDelta(t, 0.0, result.AvailableQuotaAfter, 1e-9) + + noProfile := mustCreateAffiliateWithdrawUser(t, client, "no-profile") + t.Cleanup(func() { + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM users WHERE id = $1", noProfile.ID) + }) + _, err = repo.WithdrawQuota(ctx, noProfile.ID, 1, newAffiliateWithdrawOperationID("no-profile")) + require.ErrorIs(t, err, service.ErrAffiliateQuotaInsufficient) + require.Equal(t, 0, querySingleInt(t, ctx, client, + "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1", noProfile.ID)) +} + +// TestAffiliateRepository_WithdrawQuota_RetryAfterCommitReplaysFirstResult 复现 +// 「提交成功但响应丢失后重试」:首次登记已在独立事务中提交,以同一 operation_id +// 重试不重复扣减、不重复写流水,返回首次登记的流水与额度快照。两笔合法的等额打款 +// 使用不同的 operation_id,各扣一次。 +func TestAffiliateRepository_WithdrawQuota_RetryAfterCommitReplaysFirstResult(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + repo := NewAffiliateRepository(client, integrationDB) + + u := mustCreateCommittedAffiliateWithdrawUser(t, "retry", 100, 0, 100) + operationID := newAffiliateWithdrawOperationID("retry") + + first, err := repo.WithdrawQuota(ctx, u.ID, 10, operationID) + require.NoError(t, err) + require.False(t, first.Replayed) + + retry, err := repo.WithdrawQuota(ctx, u.ID, 10, operationID) + require.NoError(t, err) + require.True(t, retry.Replayed) + require.Equal(t, first.LedgerID, retry.LedgerID) + require.Equal(t, u.ID, retry.UserID) + require.InDelta(t, 10.0, retry.Amount, 1e-9) + require.InDelta(t, 90.0, retry.AvailableQuotaAfter, 1e-9) + require.InDelta(t, first.FrozenQuotaAfter, retry.FrozenQuotaAfter, 1e-9) + require.InDelta(t, 100.0, retry.HistoryQuotaAfter, 1e-9) + + require.InDelta(t, 90.0, querySingleFloat(t, ctx, client, + "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.Equal(t, 1, querySingleInt(t, ctx, client, + "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID)) + + second, err := repo.WithdrawQuota(ctx, u.ID, 10, newAffiliateWithdrawOperationID("retry-second")) + require.NoError(t, err) + require.False(t, second.Replayed) + require.NotEqual(t, first.LedgerID, second.LedgerID) + require.InDelta(t, 80.0, querySingleFloat(t, ctx, client, + "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.Equal(t, 2, querySingleInt(t, ctx, client, + "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID)) +} + +// TestAffiliateRepository_WithdrawQuota_SameOperationWaitsForInFlightRegistration +// 固定「并发同键」的交错:首个登记已占位并扣减但未提交时,同一 operation_id 的 +// 请求在唯一索引上等待。首个登记提交后,等待的请求不重复扣减,返回同一条流水; +// 首个登记回滚后,等待的请求自行完成登记。两种结局都只扣一次。 +func TestAffiliateRepository_WithdrawQuota_SameOperationWaitsForInFlightRegistration(t *testing.T) { + for _, commitFirst := range []bool{true, false} { + name := "first rolls back" + if commitFirst { + name = "first commits" + } + t.Run(name, func(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + repo := NewAffiliateRepository(client, integrationDB) + + u := mustCreateCommittedAffiliateWithdrawUser(t, "inflight", 100, 0, 100) + operationID := newAffiliateWithdrawOperationID("inflight") + + tx, err := client.Tx(ctx) + require.NoError(t, err) + t.Cleanup(func() { _ = tx.Rollback() }) + first, err := repo.WithdrawQuota(dbent.NewTxContext(ctx, tx), u.ID, 10, operationID) + require.NoError(t, err) + + type outcome struct { + result *service.AffiliateWithdrawResult + err error + } + waiting := make(chan outcome, 1) + go func() { + result, err := repo.WithdrawQuota(ctx, u.ID, 10, operationID) + waiting <- outcome{result: result, err: err} + }() + + select { + case got := <-waiting: + t.Fatalf("same operation must wait for the in-flight registration, got result=%+v err=%v", got.result, got.err) + case <-time.After(300 * time.Millisecond): + } + + if commitFirst { + require.NoError(t, tx.Commit()) + } else { + require.NoError(t, tx.Rollback()) + } + + var got outcome + select { + case got = <-waiting: + case <-time.After(10 * time.Second): + t.Fatal("waiting registration did not finish after the in-flight one ended") + } + require.NoError(t, got.err) + if commitFirst { + require.True(t, got.result.Replayed) + require.Equal(t, first.LedgerID, got.result.LedgerID) + } else { + require.False(t, got.result.Replayed) + require.NotEqual(t, first.LedgerID, got.result.LedgerID) + } + require.InDelta(t, 90.0, got.result.AvailableQuotaAfter, 1e-9) + + require.InDelta(t, 90.0, querySingleFloat(t, ctx, client, + "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.Equal(t, 1, querySingleInt(t, ctx, client, + "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID)) + }) + } +} + +// TestAffiliateRepository_WithdrawQuota_ConcurrentSameOperationDeductsOnce 让多个请求 +// 同时以同一 operation_id 登记:只有一个执行扣减,其余返回同一条流水。 +func TestAffiliateRepository_WithdrawQuota_ConcurrentSameOperationDeductsOnce(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + repo := NewAffiliateRepository(client, integrationDB) + + u := mustCreateCommittedAffiliateWithdrawUser(t, "concurrent", 100, 0, 100) + operationID := newAffiliateWithdrawOperationID("concurrent") + + const workers = 8 + results := make([]*service.AffiliateWithdrawResult, workers) + errs := make([]error, workers) + start := make(chan struct{}) + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + results[i], errs[i] = repo.WithdrawQuota(ctx, u.ID, 10, operationID) + }(i) + } + close(start) + wg.Wait() + + executed := 0 + for i := 0; i < workers; i++ { + require.NoError(t, errs[i]) + require.Equal(t, results[0].LedgerID, results[i].LedgerID) + require.InDelta(t, 90.0, results[i].AvailableQuotaAfter, 1e-9) + if !results[i].Replayed { + executed++ + } + } + require.Equal(t, 1, executed) + require.InDelta(t, 90.0, querySingleFloat(t, ctx, client, + "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.Equal(t, 1, querySingleInt(t, ctx, client, + "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'withdraw'", u.ID)) +} + +// TestAffiliateRepository_WithdrawQuota_RejectsOperationReuseWithDifferentRequest 验证 +// 同一 operation_id 换了金额或用户时以 ErrIdempotencyKeyConflict 拒绝, +// 不扣减、不写流水。 +func TestAffiliateRepository_WithdrawQuota_RejectsOperationReuseWithDifferentRequest(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + repo := NewAffiliateRepository(client, integrationDB) + + u := mustCreateCommittedAffiliateWithdrawUser(t, "conflict", 100, 0, 100) + other := mustCreateCommittedAffiliateWithdrawUser(t, "conflict-other", 100, 0, 100) + operationID := newAffiliateWithdrawOperationID("conflict") + + _, err := repo.WithdrawQuota(ctx, u.ID, 10, operationID) + require.NoError(t, err) + + _, err = repo.WithdrawQuota(ctx, u.ID, 11, operationID) + require.ErrorIs(t, err, service.ErrIdempotencyKeyConflict) + _, err = repo.WithdrawQuota(ctx, other.ID, 10, operationID) + require.ErrorIs(t, err, service.ErrIdempotencyKeyConflict) + + require.InDelta(t, 90.0, querySingleFloat(t, ctx, client, + "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", u.ID), 1e-9) + require.InDelta(t, 100.0, querySingleFloat(t, ctx, client, + "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1", other.ID), 1e-9) + require.Equal(t, 1, querySingleInt(t, ctx, client, + "SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id IN ($1, $2) AND action = 'withdraw'", u.ID, other.ID)) +} + +// TestAffiliateRepository_ListAffiliateRebateRecords_IncludesNonOrderAccruals 验证兑换码 +// 等非订单来源的返利出现在返利记录中,订单字段为空。 +func TestAffiliateRepository_ListAffiliateRebateRecords_IncludesNonOrderAccruals(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + txCtx := dbent.NewTxContext(ctx, tx) + client := tx.Client() + repo := NewAffiliateRepository(client, integrationDB) + + inviter := mustCreateAffiliateWithdrawUser(t, client, "inviter") + invitee := mustCreateAffiliateWithdrawUser(t, client, "invitee") + mustSeedAffiliateQuota(t, txCtx, client, inviter.ID, 0, 0, 0) + + applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 2, 0, nil) + require.NoError(t, err) + require.True(t, applied) + + records, total, err := repo.ListAffiliateRebateRecords(txCtx, service.AffiliateRecordFilter{ + Search: invitee.Email, + Page: 1, + PageSize: 20, + SortDesc: true, + }) + require.NoError(t, err) + require.EqualValues(t, 1, total) + require.Len(t, records, 1) + record := records[0] + require.Nil(t, record.OrderID) + require.Nil(t, record.OrderAmount) + require.Nil(t, record.PayAmount) + require.Empty(t, record.OutTradeNo) + require.Equal(t, inviter.ID, record.InviterID) + require.NotNil(t, record.InviteeID) + require.Equal(t, invitee.ID, *record.InviteeID) + require.InDelta(t, 2.0, record.RebateAmount, 1e-9) +} diff --git a/backend/internal/repository/affiliate_repo_test.go b/backend/internal/repository/affiliate_repo_test.go index ccb7bb3daf..a9a2016678 100644 --- a/backend/internal/repository/affiliate_repo_test.go +++ b/backend/internal/repository/affiliate_repo_test.go @@ -20,9 +20,56 @@ func TestAffiliateRecordQueriesUseLedgerAuditFields(t *testing.T) { require.NoError(t, err) content := string(source) - require.Contains(t, content, "JOIN payment_orders po ON po.id = ual.source_order_id") + require.Contains(t, content, "LEFT JOIN payment_orders po ON po.id = ual.source_order_id") require.Contains(t, content, "ual.amount::double precision") require.Contains(t, content, "ual.balance_after::double precision") require.NotContains(t, content, "parseAffiliateRebateAmount") require.NotContains(t, content, `"current_balance": "u.balance"`) } + +// TestAffiliateRebateRecordsQueryKeepsNonOrderAccruals 锁定返利记录列出全部 +// accrue 流水:订单与被邀请人均为 LEFT JOIN,且不按 source_order_id 过滤, +// 兑换码、管理员充值来源的返利才会出现在明细里。 +func TestAffiliateRebateRecordsQueryKeepsNonOrderAccruals(t *testing.T) { + source, err := os.ReadFile("affiliate_repo.go") + require.NoError(t, err) + content := string(source) + + require.Contains(t, content, "LEFT JOIN payment_orders po ON po.id = ual.source_order_id") + require.Contains(t, content, "LEFT JOIN users invitee ON invitee.id = ual.source_user_id") + require.NotContains(t, content, "\nJOIN payment_orders po ON po.id = ual.source_order_id") + require.NotContains(t, content, "\nJOIN users invitee ON invitee.id = ual.source_user_id") + require.NotContains(t, content, "AND ual.source_order_id IS NOT NULL") +} + +// TestAffiliateTransferRecordsQueryIncludesOfflineWithdrawals 锁定提取记录同时 +// 列出转入余额与线下提现两类额度流出。 +func TestAffiliateTransferRecordsQueryIncludesOfflineWithdrawals(t *testing.T) { + source, err := os.ReadFile("affiliate_repo.go") + require.NoError(t, err) + content := string(source) + + require.Contains(t, content, "WHERE ual.action IN ('transfer', 'withdraw')") +} + +// TestAffiliateWithdrawClaimsOperationBeforeDeducting 锁定线下提现的幂等形态: +// 同一事务内先按 operation_id 唯一约束写入占位流水,冲突时不扣减, +// 且唯一约束由迁移建立。 +func TestAffiliateWithdrawClaimsOperationBeforeDeducting(t *testing.T) { + source, err := os.ReadFile("affiliate_repo.go") + require.NoError(t, err) + content := string(source) + + require.Contains(t, content, "ON CONFLICT (operation_id) WHERE operation_id IS NOT NULL DO NOTHING") + withdraw := content[strings.Index(content, "func (r *affiliateRepository) WithdrawQuota("):] + withdraw = withdraw[:strings.Index(withdraw, "\n}\n")] + claimAt := strings.Index(withdraw, "claimAffiliateWithdrawLedger(") + deductAt := strings.Index(withdraw, "SET aff_quota = aff_quota - $1") + require.Positive(t, claimAt) + require.Greater(t, deductAt, claimAt, "operation claim must precede the quota deduction") + + migration, err := os.ReadFile("../../migrations/240_affiliate_ledger_operation_id.sql") + require.NoError(t, err) + require.Contains(t, string(migration), "CREATE UNIQUE INDEX IF NOT EXISTS idx_user_affiliate_ledger_operation_id") + require.Contains(t, string(migration), "WHERE operation_id IS NOT NULL") +} diff --git a/backend/internal/repository/proxy_repo.go b/backend/internal/repository/proxy_repo.go index 020acfd673..0af72159c9 100644 --- a/backend/internal/repository/proxy_repo.go +++ b/backend/internal/repository/proxy_repo.go @@ -226,7 +226,8 @@ func invalidateProxyProbeSnapshots(ctx context.Context, exec sqlExecutor, proxyI UPDATE accounts SET extra = COALESCE(extra, '{}'::jsonb) - 'upstream_billing_probe' - - 'ollama_cloud_usage_snapshot', + - 'ollama_cloud_usage_snapshot' + - 'opencode_go_usage_snapshot', updated_at = NOW() WHERE proxy_id = $1 AND type = 'apikey' @@ -236,6 +237,10 @@ func invalidateProxyProbeSnapshots(ctx context.Context, exec sqlExecutor, proxyI OR (platform IN (`+ollamaCloudUsagePlatformsSQL+`) AND extra ? 'ollama_cloud_usage_snapshot' AND extra -> 'ollama_cloud_usage_snapshot' <> 'null'::jsonb) + -- OpenCode 快照可能挂在 opencode_go 平台账号或挂载白名单平台账号上。 + OR ((platform = 'opencode_go' OR platform IN (`+opencodeGoUsageMountPlatformsSQL+`)) + AND extra ? 'opencode_go_usage_snapshot' + AND extra -> 'opencode_go_usage_snapshot' <> 'null'::jsonb) ) AND deleted_at IS NULL RETURNING id diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 986a691a5a..219c83b149 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -953,6 +953,9 @@ func TestAPIContracts(t *testing.T) { "openai_codex_client_version": "", "openai_codex_client_version_synced": "", "openai_codex_version_auto_sync_enabled": true, + "claude_code_client_version": "", + "claude_code_client_version_synced": "", + "claude_code_version_auto_sync_enabled": true, "openai_fast_policy_settings": { "rules": [] }, @@ -1270,6 +1273,9 @@ func TestAPIContracts(t *testing.T) { "openai_codex_client_version": "", "openai_codex_client_version_synced": "", "openai_codex_version_auto_sync_enabled": true, + "claude_code_client_version": "", + "claude_code_client_version_synced": "", + "claude_code_version_auto_sync_enabled": true, "openai_fast_policy_settings": { "rules": [] }, diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index ababc06d50..d0737f0397 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -364,6 +364,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.POST("/upstream-billing-probe/batch", h.Admin.Account.ProbeUpstreamBillingBatch) accounts.GET("/ollama-cloud-usage/settings", h.Admin.Account.GetOllamaCloudUsageSettings) accounts.PUT("/ollama-cloud-usage/settings", h.Admin.Account.UpdateOllamaCloudUsageSettings) + accounts.GET("/opencode-go-usage/settings", h.Admin.Account.GetOpenCodeGoUsageSettings) + accounts.PUT("/opencode-go-usage/settings", h.Admin.Account.UpdateOpenCodeGoUsageSettings) accounts.GET("/:id", h.Admin.Account.GetByID) accounts.POST("", h.Admin.Account.Create) accounts.POST("/:id/duplicate", h.Admin.Account.Duplicate) @@ -381,6 +383,9 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.DELETE("/:id/ollama-cloud-usage/session", h.Admin.Account.DeleteOllamaCloudUsageSession) accounts.PUT("/:id/ollama-cloud-usage/auto-refresh", h.Admin.Account.SetOllamaCloudUsageAutoRefresh) accounts.POST("/:id/ollama-cloud-usage/refresh", h.Admin.Account.RefreshOllamaCloudUsage) + accounts.GET("/:id/opencode-go-usage", h.Admin.Account.GetOpenCodeGoUsage) + accounts.PUT("/:id/opencode-go-usage/auto-refresh", h.Admin.Account.SetOpenCodeGoUsageAutoRefresh) + accounts.POST("/:id/opencode-go-usage/refresh", h.Admin.Account.RefreshOpenCodeGoUsage) accounts.DELETE("/:id", h.Admin.Account.Delete) accounts.POST("/:id/test", h.Admin.Account.Test) accounts.POST("/:id/recover-state", h.Admin.Account.RecoverState) @@ -823,6 +828,7 @@ func registerAffiliateRoutes(admin *gin.RouterGroup, h *handler.Handlers) { users.GET("/lookup", h.Admin.Affiliate.LookupUsers) users.POST("/batch-rate", h.Admin.Affiliate.BatchSetRate) users.GET("/:user_id/overview", h.Admin.Affiliate.GetUserOverview) + users.POST("/:user_id/withdraw", h.Admin.Affiliate.WithdrawQuota) users.PUT("/:user_id", h.Admin.Affiliate.UpdateUserSettings) users.DELETE("/:user_id", h.Admin.Affiliate.ClearUserSettings) } diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index f090742f79..7d16eeaaec 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -299,7 +299,7 @@ func (s *AccountTestService) validateUpstreamBaseURL(raw string) (string, error) } // generateSessionString generates a Claude Code style session string. -// The output format is determined by the UA version in claude.DefaultHeaders, +// The output format is determined by the UA version in claude.DefaultHeaders(), // ensuring consistency between the user_id format and the UA sent to upstream. func generateSessionString() (string, error) { b := make([]byte, 32) @@ -308,7 +308,7 @@ func generateSessionString() (string, error) { } hex64 := hex.EncodeToString(b) sessionUUID := uuid.New().String() - uaVersion := ExtractCLIVersion(claude.DefaultHeaders["User-Agent"]) + uaVersion := ExtractCLIVersion(claude.DefaultHeaders()["User-Agent"]) return FormatMetadataUserID(hex64, "", sessionUUID, uaVersion), nil } @@ -562,7 +562,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account req.Header.Set("anthropic-version", "2023-06-01") // Apply Claude Code client headers - for key, value := range claude.DefaultHeaders { + for key, value := range claude.DefaultHeaders() { req.Header.Set(key, value) } diff --git a/backend/internal/service/account_test_service_cn_adaptive.go b/backend/internal/service/account_test_service_cn_adaptive.go index 4fe14c380b..f56560d53d 100644 --- a/backend/internal/service/account_test_service_cn_adaptive.go +++ b/backend/internal/service/account_test_service_cn_adaptive.go @@ -77,7 +77,7 @@ func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Co req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "text/event-stream") req.Header.Set("anthropic-version", "2023-06-01") - for key, value := range claude.DefaultHeaders { + for key, value := range claude.DefaultHeaders() { req.Header.Set(key, value) } req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader) @@ -263,7 +263,7 @@ func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, a req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "text/event-stream") req.Header.Set("anthropic-version", "2023-06-01") - for key, value := range claude.DefaultHeaders { + for key, value := range claude.DefaultHeaders() { req.Header.Set(key, value) } // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer,其余保持 diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index 5d778b75ce..0cf93355fe 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -418,6 +418,8 @@ func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]an delete(accountExtra, OllamaCloudUsageSessionExtraKey) delete(accountExtra, OllamaCloudUsageAutoRefreshExtraKey) delete(accountExtra, OllamaCloudUsageSnapshotExtraKey) + delete(accountExtra, OpenCodeGoUsageAutoRefreshExtraKey) + delete(accountExtra, OpenCodeGoUsageSnapshotExtraKey) accountExtra = prepareCodexFingerprintExtraForCreate(input.Platform, input.Type, accountExtra) account := &Account{ Name: input.Name, @@ -598,6 +600,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U } previousProbeIdentity := upstreamBillingProbeIdentity(account) previousOllamaUsageIdentity := ollamaCloudUsageIdentity(account) + previousOpenCodeUsageIdentity := openCodeGoUsageIdentity(account) // 安全/身份不变量(影子账号):通用更新路径被 edit/re-auth/refresh/batch 共用, // 必须在此守住,否则仅在创建时的保证可被这些路径绕过。 if account.IsCredentialShadow() { @@ -674,6 +677,8 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U delete(normalizedExtra, OllamaCloudUsageSessionExtraKey) delete(normalizedExtra, OllamaCloudUsageAutoRefreshExtraKey) delete(normalizedExtra, OllamaCloudUsageSnapshotExtraKey) + delete(normalizedExtra, OpenCodeGoUsageAutoRefreshExtraKey) + delete(normalizedExtra, OpenCodeGoUsageSnapshotExtraKey) // 保留配额用量和专用服务受管字段,防止普通账号编辑意外覆盖。 for _, key := range []string{ "quota_used", @@ -689,6 +694,8 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U OllamaCloudUsageAutoRefreshExtraKey, OllamaCloudUsageSnapshotExtraKey, OpenAIAutoResetCreditStateExtraKey, + OpenCodeGoUsageAutoRefreshExtraKey, + OpenCodeGoUsageSnapshotExtraKey, } { if v, ok := account.Extra[key]; ok { normalizedExtra[key] = v @@ -775,6 +782,17 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U delete(account.Extra, OllamaCloudUsageSnapshotExtraKey) } } + // OpenCode Go 受管键:身份改变或不再 eligible 时随本次写入清除,防止跨组污染。 + // (代理变化只失效快照而保留开关,由 repository 合并层在锁定的 DB 行上裁决。) + if account.Extra != nil { + if !IsOpenCodeGoUsageAccount(account) { + delete(account.Extra, OpenCodeGoUsageAutoRefreshExtraKey) + delete(account.Extra, OpenCodeGoUsageSnapshotExtraKey) + } else if !reflect.DeepEqual(previousOpenCodeUsageIdentity, openCodeGoUsageIdentity(account)) { + delete(account.Extra, OpenCodeGoUsageAutoRefreshExtraKey) + delete(account.Extra, OpenCodeGoUsageSnapshotExtraKey) + } + } // 只在指针非 nil 时更新 Concurrency(支持设置为 0) if input.Concurrency != nil { account.Concurrency = normalizeAccountConcurrency(account.Platform, account.Type, *input.Concurrency) @@ -910,6 +928,8 @@ func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, upd delete(updates, OllamaCloudUsageSessionExtraKey) delete(updates, OllamaCloudUsageAutoRefreshExtraKey) delete(updates, OllamaCloudUsageSnapshotExtraKey) + delete(updates, OpenCodeGoUsageAutoRefreshExtraKey) + delete(updates, OpenCodeGoUsageSnapshotExtraKey) if _, exists := updates[openAILongContextBillingEnabledKey]; exists { account, err := s.accountRepo.GetByID(ctx, id) if err != nil { @@ -937,6 +957,8 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp delete(input.Extra, OllamaCloudUsageSessionExtraKey) delete(input.Extra, OllamaCloudUsageAutoRefreshExtraKey) delete(input.Extra, OllamaCloudUsageSnapshotExtraKey) + delete(input.Extra, OpenCodeGoUsageAutoRefreshExtraKey) + delete(input.Extra, OpenCodeGoUsageSnapshotExtraKey) if len(input.AccountIDs) == 0 && input.Filters != nil { accountIDs, err := s.resolveBulkUpdateTargetIDs(ctx, input.Filters) diff --git a/backend/internal/service/admin_account_upstream_billing_probe_test.go b/backend/internal/service/admin_account_upstream_billing_probe_test.go index f76dde0b2e..42988062c3 100644 --- a/backend/internal/service/admin_account_upstream_billing_probe_test.go +++ b/backend/internal/service/admin_account_upstream_billing_probe_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/stretchr/testify/require" @@ -749,3 +750,166 @@ func TestBulkUpdateAccountsKeepsProbeSnapshotForUnrelatedCredentials(t *testing. require.Len(t, repo.bulkUpdates, 1) require.NotContains(t, repo.bulkUpdates[0].Extra, UpstreamBillingProbeExtraKey) } + +func openCodeGoAdminUsageAccount(id int64, apiKey string) *Account { + previousAttempt := time.Date(2026, time.August, 15, 8, 0, 0, 0, time.UTC) + return &Account{ + ID: id, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Credentials: map[string]any{ + "api_key": apiKey, + "base_url": "https://opencode.ai/zen/go/v1", + }, + Extra: map[string]any{ + OpenCodeGoUsageAutoRefreshExtraKey: true, + OpenCodeGoUsageSnapshotExtraKey: &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, + LastAttemptAt: previousAttempt, + NextRefreshAt: previousAttempt.Add(time.Hour), + }, + }, + } +} + +// 普通账号编辑(脱敏 extra 不带受管键)不得丢 OpenCode Go 受管状态。 +func TestUpdateAccountPreservesOpenCodeGoManagedStateForUnrelatedEdit(t *testing.T) { + accountID := int64(210) + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{ + accountID: openCodeGoAdminUsageAccount(accountID, "sk-1"), + }} + + svc := &adminServiceImpl{accountRepo: repo} + updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + Extra: map[string]any{"custom": "value"}, + }) + + require.NoError(t, err) + require.Equal(t, true, updated.Extra[OpenCodeGoUsageAutoRefreshExtraKey]) + snapshot, ok := updated.Extra[OpenCodeGoUsageSnapshotExtraKey].(*OpenCodeGoUsageSnapshot) + require.True(t, ok) + require.Equal(t, OpenCodeGoUsageStatusOK, snapshot.Status) + require.Equal(t, "value", updated.Extra["custom"]) +} + +// 客户端在 extra 里伪造受管键必须被丢弃,以数据库中的受管状态为准。 +func TestUpdateAccountRejectsInjectedOpenCodeGoManagedKeys(t *testing.T) { + accountID := int64(211) + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{ + accountID: openCodeGoAdminUsageAccount(accountID, "sk-1"), + }} + + svc := &adminServiceImpl{accountRepo: repo} + updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + Extra: map[string]any{ + OpenCodeGoUsageAutoRefreshExtraKey: false, + OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "forged"}, + "custom": "value", + }, + }) + + require.NoError(t, err) + require.Equal(t, true, updated.Extra[OpenCodeGoUsageAutoRefreshExtraKey]) + snapshot, ok := updated.Extra[OpenCodeGoUsageSnapshotExtraKey].(*OpenCodeGoUsageSnapshot) + require.True(t, ok) + require.Equal(t, OpenCodeGoUsageStatusOK, snapshot.Status) + require.Equal(t, "value", updated.Extra["custom"]) +} + +// OpenCode 身份改变(api_key / base_url / type)必须清除旧受管状态,防止跨组污染。 +func TestUpdateAccountClearsOpenCodeGoManagedStateWhenIdentityChanges(t *testing.T) { + tests := []struct { + name string + input *UpdateAccountInput + }{ + {name: "api key", input: &UpdateAccountInput{Credentials: map[string]any{"api_key": "sk-new"}}}, + {name: "base url", input: &UpdateAccountInput{Credentials: map[string]any{"base_url": "https://api.openai.com/v1"}}}, + {name: "type", input: &UpdateAccountInput{Type: AccountTypeOAuth}}, + } + + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + accountID := int64(220 + i) + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{ + accountID: openCodeGoAdminUsageAccount(accountID, "sk-1"), + }} + + updated, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, tt.input) + + require.NoError(t, err) + require.NotContains(t, updated.Extra, OpenCodeGoUsageAutoRefreshExtraKey) + require.NotContains(t, updated.Extra, OpenCodeGoUsageSnapshotExtraKey) + }) + } +} + +// UpdateAccountExtra 是 key 级合并入口,伪造的受管键必须被剥离。 +func TestUpdateAccountExtraDropsOpenCodeGoManagedKeys(t *testing.T) { + accountID := int64(230) + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{ + accountID: openCodeGoAdminUsageAccount(accountID, "sk-1"), + }} + + svc := &adminServiceImpl{accountRepo: repo} + err := svc.UpdateAccountExtra(context.Background(), accountID, map[string]any{ + OpenCodeGoUsageAutoRefreshExtraKey: false, + OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "forged"}, + "custom": "value", + }) + + require.NoError(t, err) + require.Len(t, repo.updates[accountID], 1) + persisted := repo.updates[accountID][0] + require.NotContains(t, persisted, OpenCodeGoUsageAutoRefreshExtraKey) + require.NotContains(t, persisted, OpenCodeGoUsageSnapshotExtraKey) + require.Equal(t, "value", persisted["custom"]) +} + +// 批量更新同样不得接受伪造的受管键。 +func TestBulkUpdateAccountsDropsOpenCodeGoManagedKeys(t *testing.T) { + accountID := int64(240) + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{ + accountID: openCodeGoAdminUsageAccount(accountID, "sk-1"), + }} + + svc := &adminServiceImpl{accountRepo: repo} + _, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{ + AccountIDs: []int64{accountID}, + Extra: map[string]any{ + OpenCodeGoUsageAutoRefreshExtraKey: false, + OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "forged"}, + "custom": "value", + }, + }) + + require.NoError(t, err) + require.Len(t, repo.bulkUpdates, 1) + require.NotContains(t, repo.bulkUpdates[0].Extra, OpenCodeGoUsageAutoRefreshExtraKey) + require.NotContains(t, repo.bulkUpdates[0].Extra, OpenCodeGoUsageSnapshotExtraKey) + require.Equal(t, "value", repo.bulkUpdates[0].Extra["custom"]) +} + +// 创建入口不接受伪造的受管键。 +func TestCreateAccountDropsOpenCodeGoManagedKeys(t *testing.T) { + repo := &upstreamBillingProbeAccountRepo{} + svc := &adminServiceImpl{accountRepo: repo} + + created, err := svc.CreateAccount(context.Background(), &CreateAccountInput{ + Name: "opencode", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-1", "base_url": "https://opencode.ai/zen/go/v1"}, + SkipDefaultGroupBind: true, + Extra: map[string]any{ + OpenCodeGoUsageAutoRefreshExtraKey: true, + OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "forged"}, + "custom": "value", + }, + }) + + require.NoError(t, err) + require.NotContains(t, created.Extra, OpenCodeGoUsageAutoRefreshExtraKey) + require.NotContains(t, created.Extra, OpenCodeGoUsageSnapshotExtraKey) + require.Equal(t, "value", created.Extra["custom"]) +} diff --git a/backend/internal/service/affiliate_service.go b/backend/internal/service/affiliate_service.go index 91cca5e216..f48156e995 100644 --- a/backend/internal/service/affiliate_service.go +++ b/backend/internal/service/affiliate_service.go @@ -2,6 +2,8 @@ package service import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "math" "strings" @@ -17,6 +19,9 @@ var ( ErrAffiliateCodeTaken = infraerrors.Conflict("AFFILIATE_CODE_TAKEN", "affiliate code already in use") ErrAffiliateAlreadyBound = infraerrors.Conflict("AFFILIATE_ALREADY_BOUND", "affiliate inviter already bound") ErrAffiliateQuotaEmpty = infraerrors.BadRequest("AFFILIATE_QUOTA_EMPTY", "no affiliate quota available to transfer") + + ErrAffiliateQuotaInsufficient = infraerrors.BadRequest("AFFILIATE_QUOTA_INSUFFICIENT", "insufficient available affiliate quota") + ErrAffiliateWithdrawAmountInvalid = infraerrors.BadRequest("AFFILIATE_WITHDRAW_AMOUNT_INVALID", "invalid offline withdrawal amount") ) const ( @@ -102,6 +107,7 @@ type AffiliateRepository interface { GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error) ThawFrozenQuota(ctx context.Context, userID int64) (float64, error) TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error) + WithdrawQuota(ctx context.Context, userID int64, amount float64, operationID string) (*AffiliateWithdrawResult, error) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error) // 管理端:用户级专属配置 @@ -156,25 +162,31 @@ type AffiliateInviteRecord struct { CreatedAt time.Time `json:"created_at"` } +// AffiliateRebateRecord 是一笔返利入账流水。非订单来源(兑换码、管理员充值) +// 的流水没有订单信息,OrderID/OrderAmount/PayAmount 为 nil;被邀请人账号被删除时 +// InviteeID 为 nil。 type AffiliateRebateRecord struct { - OrderID int64 `json:"order_id"` + OrderID *int64 `json:"order_id"` OutTradeNo string `json:"out_trade_no"` InviterID int64 `json:"inviter_id"` InviterEmail string `json:"inviter_email"` InviterUsername string `json:"inviter_username"` - InviteeID int64 `json:"invitee_id"` + InviteeID *int64 `json:"invitee_id"` InviteeEmail string `json:"invitee_email"` InviteeUsername string `json:"invitee_username"` - OrderAmount float64 `json:"order_amount"` - PayAmount float64 `json:"pay_amount"` + OrderAmount *float64 `json:"order_amount"` + PayAmount *float64 `json:"pay_amount"` RebateAmount float64 `json:"rebate_amount"` PaymentType string `json:"payment_type"` OrderStatus string `json:"order_status"` CreatedAt time.Time `json:"created_at"` } +// AffiliateTransferRecord 是一笔返利额度流出:Action 为 transfer(用户转入余额) +// 或 withdraw(管理员登记的线下提现)。 type AffiliateTransferRecord struct { LedgerID int64 `json:"ledger_id"` + Action string `json:"action"` UserID int64 `json:"user_id"` UserEmail string `json:"user_email"` Username string `json:"username"` @@ -191,6 +203,18 @@ type AffiliateTransferRecord struct { CreatedAt time.Time `json:"created_at"` } +// AffiliateWithdrawResult 是线下提现登记完成后的流水 ID 与额度快照。 +// Replayed 表示本次请求命中了同一幂等标识的已有登记,返回的是首次登记的结果。 +type AffiliateWithdrawResult struct { + LedgerID int64 `json:"ledger_id"` + UserID int64 `json:"user_id"` + Amount float64 `json:"amount"` + AvailableQuotaAfter float64 `json:"available_quota_after"` + FrozenQuotaAfter float64 `json:"frozen_quota_after"` + HistoryQuotaAfter float64 `json:"history_quota_after"` + Replayed bool `json:"-"` +} + type AffiliateUserOverview struct { UserID int64 `json:"user_id"` Email string `json:"email"` @@ -559,6 +583,43 @@ func (s *AffiliateService) AdminBatchSetUserRebateRate(ctx context.Context, user return s.repo.BatchSetUserRebateRate(ctx, cleaned, ratePercent) } +// AdminWithdrawQuota 登记一笔已在站外打款的线下提现:从用户可提取返利额度 +// 中扣除 amount,并写入 action=withdraw 的流水。冻结期内的额度不可扣除。 +// 金额按返利流水精度保留 8 位小数,舍入后必须为正。 +// +// idempotencyKey 标识一次登记且必填:同一个键只扣减一次,重复请求在用户与金额 +// 一致时返回首次登记的结果,不一致时返回 ErrIdempotencyKeyConflict。 +func (s *AffiliateService) AdminWithdrawQuota(ctx context.Context, userID int64, amount float64, idempotencyKey string) (*AffiliateWithdrawResult, error) { + if userID <= 0 { + return nil, infraerrors.BadRequest("INVALID_USER", "invalid user") + } + if s == nil || s.repo == nil { + return nil, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable") + } + key, err := NormalizeIdempotencyKey(idempotencyKey) + if err != nil { + return nil, err + } + if key == "" { + return nil, ErrIdempotencyKeyRequired + } + if math.IsNaN(amount) || math.IsInf(amount, 0) { + return nil, ErrAffiliateWithdrawAmountInvalid + } + amount = roundTo(amount, 8) + if amount <= 0 || math.IsInf(amount, 0) { + return nil, ErrAffiliateWithdrawAmountInvalid + } + return s.repo.WithdrawQuota(ctx, userID, amount, affiliateWithdrawOperationID(key)) +} + +// affiliateWithdrawOperationID 把线下提现登记的幂等键映射为流水的 operation_id, +// 同一个键始终得到同一个值。 +func affiliateWithdrawOperationID(idempotencyKey string) string { + sum := sha256.Sum256([]byte("admin.affiliates.withdraw\x00" + idempotencyKey)) + return hex.EncodeToString(sum[:]) +} + // AdminListCustomUsers 列出有专属配置的用户。 func (s *AffiliateService) AdminListCustomUsers(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error) { if s == nil || s.repo == nil { diff --git a/backend/internal/service/affiliate_service_test.go b/backend/internal/service/affiliate_service_test.go index c02a4dd711..b3d92039a5 100644 --- a/backend/internal/service/affiliate_service_test.go +++ b/backend/internal/service/affiliate_service_test.go @@ -5,6 +5,7 @@ package service import ( "context" "math" + "strings" "testing" "github.com/stretchr/testify/require" @@ -129,3 +130,118 @@ func TestIsValidAffiliateCodeFormat(t *testing.T) { }) } } + +type affiliateWithdrawCall struct { + userID int64 + amount float64 + operationID string +} + +type affiliateWithdrawRepoStub struct { + AffiliateRepository + calls []affiliateWithdrawCall + result *AffiliateWithdrawResult + err error +} + +func (s *affiliateWithdrawRepoStub) WithdrawQuota(_ context.Context, userID int64, amount float64, operationID string) (*AffiliateWithdrawResult, error) { + s.calls = append(s.calls, affiliateWithdrawCall{userID: userID, amount: amount, operationID: operationID}) + return s.result, s.err +} + +const testAffiliateWithdrawKey = "affiliate-withdraw-7-3f2b8c1e" + +// TestAdminWithdrawQuota_RejectsInvalidAmount 覆盖线下提现金额校验:非正数、 +// NaN/Inf、舍入到 8 位小数后为 0 或溢出的金额都在服务层拒绝,不进入仓储。 +func TestAdminWithdrawQuota_RejectsInvalidAmount(t *testing.T) { + t.Parallel() + for _, amount := range []float64{0, -1, math.NaN(), math.Inf(1), math.Inf(-1), 1e-9, math.MaxFloat64} { + repo := &affiliateWithdrawRepoStub{} + svc := &AffiliateService{repo: repo} + _, err := svc.AdminWithdrawQuota(context.Background(), 1, amount, testAffiliateWithdrawKey) + require.ErrorIs(t, err, ErrAffiliateWithdrawAmountInvalid, "amount %v", amount) + require.Empty(t, repo.calls, "amount %v must not reach repository", amount) + } +} + +// TestAdminWithdrawQuota_RejectsInvalidUser 验证缺少目标用户时直接拒绝。 +func TestAdminWithdrawQuota_RejectsInvalidUser(t *testing.T) { + t.Parallel() + repo := &affiliateWithdrawRepoStub{} + svc := &AffiliateService{repo: repo} + _, err := svc.AdminWithdrawQuota(context.Background(), 0, 1, testAffiliateWithdrawKey) + require.Error(t, err) + require.Empty(t, repo.calls) +} + +// TestAdminWithdrawQuota_RequiresValidIdempotencyKey 验证幂等键必填且须为 +// 可见 ASCII、不超过 128 字符;不合格的键不进入仓储。 +func TestAdminWithdrawQuota_RequiresValidIdempotencyKey(t *testing.T) { + t.Parallel() + cases := []struct { + key string + want error + }{ + {key: "", want: ErrIdempotencyKeyRequired}, + {key: " ", want: ErrIdempotencyKeyRequired}, + {key: "has space", want: ErrIdempotencyKeyInvalid}, + {key: "登记", want: ErrIdempotencyKeyInvalid}, + {key: strings.Repeat("k", 129), want: ErrIdempotencyKeyInvalid}, + } + for _, tc := range cases { + repo := &affiliateWithdrawRepoStub{} + svc := &AffiliateService{repo: repo} + _, err := svc.AdminWithdrawQuota(context.Background(), 1, 1, tc.key) + require.ErrorIs(t, err, tc.want, "key %q", tc.key) + require.Empty(t, repo.calls, "key %q must not reach repository", tc.key) + } +} + +// TestAdminWithdrawQuota_DerivesStableOperationID 验证仓储收到的 operation_id +// 由幂等键确定性派生:同一个键(含首尾空白)得到同一个值,不同的键得到不同的值, +// 且不是原始键本身。 +func TestAdminWithdrawQuota_DerivesStableOperationID(t *testing.T) { + t.Parallel() + repo := &affiliateWithdrawRepoStub{result: &AffiliateWithdrawResult{LedgerID: 1}} + svc := &AffiliateService{repo: repo} + + for _, key := range []string{testAffiliateWithdrawKey, " " + testAffiliateWithdrawKey + " ", testAffiliateWithdrawKey + "-2"} { + _, err := svc.AdminWithdrawQuota(context.Background(), 42, 10, key) + require.NoError(t, err) + } + + require.Len(t, repo.calls, 3) + first := repo.calls[0].operationID + require.Regexp(t, `^[0-9a-f]{64}$`, first) + require.NotEqual(t, testAffiliateWithdrawKey, first) + require.Equal(t, first, repo.calls[1].operationID) + require.NotEqual(t, first, repo.calls[2].operationID) +} + +// TestAdminWithdrawQuota_RoundsAmountToLedgerPrecision 验证金额按 8 位小数舍入后 +// 交给仓储,仓储结果原样返回。 +func TestAdminWithdrawQuota_RoundsAmountToLedgerPrecision(t *testing.T) { + t.Parallel() + want := &AffiliateWithdrawResult{LedgerID: 9, UserID: 42, Amount: 12.34567891} + repo := &affiliateWithdrawRepoStub{result: want} + svc := &AffiliateService{repo: repo} + + got, err := svc.AdminWithdrawQuota(context.Background(), 42, 12.3456789149, testAffiliateWithdrawKey) + require.NoError(t, err) + require.Same(t, want, got) + require.Len(t, repo.calls, 1) + require.Equal(t, int64(42), repo.calls[0].userID) + require.Equal(t, 12.34567891, repo.calls[0].amount) +} + +// TestAdminWithdrawQuota_PropagatesRepositoryErrors 验证额度不足与同键不同参数 +// 由仓储判定,服务层原样返回,前端据错误码提示。 +func TestAdminWithdrawQuota_PropagatesRepositoryErrors(t *testing.T) { + t.Parallel() + for _, want := range []error{ErrAffiliateQuotaInsufficient, ErrIdempotencyKeyConflict} { + repo := &affiliateWithdrawRepoStub{err: want} + svc := &AffiliateService{repo: repo} + _, err := svc.AdminWithdrawQuota(context.Background(), 1, 1, testAffiliateWithdrawKey) + require.ErrorIs(t, err, want) + } +} diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 1be36ba528..9011f1ce4c 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -12,6 +12,8 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) @@ -419,6 +421,16 @@ func (s *BillingService) initFallbackPricing() { s.fallbackPrices["claude-opus-4.8"] = pricingWithPriorityMultiplier(s.fallbackPrices["claude-opus-4.7"], 2) s.fallbackPrices["claude-opus-5"] = pricingWithPriorityMultiplier(s.fallbackPrices["claude-opus-4.8"], 2) + s.fallbackPrices["claude-opus-5-5"] = &ModelPricing{ + InputPricePerToken: 4e-6, + OutputPricePerToken: 20e-6, + CacheCreationPricePerToken: 5e-6, + CacheReadPricePerToken: 0.2e-6, + CacheCreation5mPrice: 5e-6, + CacheCreation1hPrice: 8e-6, + SupportsCacheBreakdown: true, + } + // Claude Fable 5.x uses the same input/output and cache-write prices, while // Fable 5.1 reduces cache reads from $1 to $0.25 per MTok. s.fallbackPrices["claude-fable-5"] = &ModelPricing{ @@ -529,6 +541,35 @@ func (s *BillingService) initFallbackPricing() { LongContextOutputMultiplier: 1.5, } + // GPT-6 Sol/Luna official rates, 2026-09-22. + s.fallbackPrices["gpt-6-sol"] = &ModelPricing{ + InputPricePerToken: 2e-6, + InputPricePerTokenPriority: 4e-6, + OutputPricePerToken: 10e-6, + OutputPricePerTokenPriority: 20e-6, + CacheCreationPricePerToken: 2.5e-6, + CacheCreationPricePerTokenPriority: 5e-6, + CacheReadPricePerToken: 0.2e-6, + CacheReadPricePerTokenPriority: 0.4e-6, + CacheCreationPriceExplicit: true, + LongContextInputThreshold: 272_000, + LongContextInputMultiplier: 2, + LongContextOutputMultiplier: 1.5, + } + s.fallbackPrices["gpt-6-luna"] = &ModelPricing{ + InputPricePerToken: 0.1e-6, + InputPricePerTokenPriority: 0.2e-6, + OutputPricePerToken: 0.5e-6, + OutputPricePerTokenPriority: 1e-6, + CacheCreationPricePerToken: 0.125e-6, + CacheCreationPricePerTokenPriority: 0.25e-6, + CacheReadPricePerToken: 0.01e-6, + CacheReadPricePerTokenPriority: 0.02e-6, + CacheCreationPriceExplicit: true, + LongContextInputThreshold: 272_000, + LongContextInputMultiplier: 2, + LongContextOutputMultiplier: 1.5, + } // OpenAI GPT-5.6 官方价格(USD/token)。缓存写入为输入价的 1.25 倍。 s.fallbackPrices["gpt-5.6-sol"] = &ModelPricing{ InputPricePerToken: 5e-6, @@ -934,6 +975,9 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { if strings.Contains(modelLower, "fable-5") || strings.Contains(modelLower, "fable5") { return s.fallbackPrices["claude-fable-5"] } + if claude.IsOpus55(modelLower) { + return s.fallbackPrices["claude-opus-5-5"] + } if strings.Contains(modelLower, "opus") { // "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。 if strings.Contains(modelLower, "opus-5") || strings.Contains(modelLower, "opus5") { @@ -1113,6 +1157,8 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { // OpenAI(GPT-5 / Codex 族):仅匹配已知型号,避免未知 OpenAI 型号误计价。 if normalized := normalizeKnownOpenAICodexModel(modelLower); normalized != "" { switch normalized { + case "gpt-6-sol", "gpt-6-luna": + return s.fallbackPrices[normalized] case "gpt-6-astra": return s.fallbackPrices["gpt-6-astra"] case "gpt-5.6-sol": @@ -1270,6 +1316,7 @@ func (s *BillingService) getModelPricingAt(model string, pricingAt time.Time) (* InputPricePerTokenPriority: litellmPricing.InputCostPerTokenPriority, OutputPricePerToken: litellmPricing.OutputCostPerToken, OutputPricePerTokenPriority: litellmPricing.OutputCostPerTokenPriority, + CacheCreationPriceExplicit: openai.IsGPT6SolOrLunaModelSpelling(model) && litellmPricing.CacheCreationInputTokenCostExplicit, CacheCreationPricePerToken: litellmPricing.CacheCreationInputTokenCost, CacheCreationPricePerTokenPriority: litellmPricing.CacheCreationInputTokenCostPriority, CacheReadPricePerToken: litellmPricing.CacheReadInputTokenCost, @@ -1793,15 +1840,20 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing return &cloned } normalized := normalizeKnownOpenAICodexModel(model) - isGPT56 := isOpenAIGPT56Model(normalized) - needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 || + usesCacheWritePremium := isOpenAIGPT56Model(normalized) || openai.IsGPT6SolOrLunaModelSpelling(normalized) + needsCacheCreationPolicy := usesCacheWritePremium && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 || (pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0)) fastRatio := openAIModelFastPricingRatio(normalized) - if !needsCacheCreationPolicy && fastRatio <= 0 { + needsOpus55FastMultiplier := claude.IsOpus55(model) && pricing.FastMultiplier == nil + if !needsCacheCreationPolicy && fastRatio <= 0 && !needsOpus55FastMultiplier { return pricing } cloned := *pricing - if isGPT56 && !cloned.CacheCreationPriceExplicit { + if needsOpus55FastMultiplier { + multiplier := 2.0 + cloned.FastMultiplier = &multiplier + } + if usesCacheWritePremium && !cloned.CacheCreationPriceExplicit { if cloned.CacheCreationPricePerToken <= 0 { cloned.CacheCreationPricePerToken = cloned.InputPricePerToken * 1.25 } @@ -1811,6 +1863,9 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing } if fastRatio > 0 { enforceOpenAIFastPricingRatio(&cloned, fastRatio) + if openai.IsGPT6SolOrLunaModelSpelling(normalized) && cloned.CacheCreationPriceExplicit { + cloned.CacheCreationPricePerTokenPriority = cloned.CacheCreationPricePerToken * fastRatio + } } return &cloned } @@ -1820,7 +1875,7 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing // 档的模型(如 gpt-5.5-pro、gpt-5.4-mini/nano)返回 0。 func openAIModelFastPricingRatio(normalized string) float64 { switch normalized { - case "gpt-5.4", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra": + case "gpt-5.4", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra", "gpt-6-sol", "gpt-6-luna": return 2.0 case "gpt-5.5": return 2.5 diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index ed83a97308..12e133df79 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -6,6 +6,7 @@ import ( "bytes" "log" "math" + "os" "strings" "testing" @@ -1931,3 +1932,108 @@ func TestComputeTokenBreakdown_NonExplicitZeroImagePrice_FallsBackToOutput(t *te // textOutputTokens = 200 - 50 = 150 require.InDelta(t, 150*15e-6, bd.OutputCost, 1e-12) } + +func TestNewModelPricingCatalogFallbackAndContext(t *testing.T) { + data, err := os.ReadFile("../../resources/model-pricing/model_prices_and_context_window.json") + require.NoError(t, err) + catalog := &PricingService{} + catalog.pricingData, err = catalog.parsePricingData(data) + require.NoError(t, err) + sources := map[string]*BillingService{ + "billing fallback": newTestBillingService(), + "pricing fallback": NewBillingService(&config.Config{}, &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + "gpt-6": {InputCostPerToken: 10e-6, OutputCostPerToken: 50e-6}, + "claude-opus-5": {InputCostPerToken: 5e-6, OutputCostPerToken: 25e-6}, + }}), + "catalog": NewBillingService(&config.Config{}, catalog), + } + for source, svc := range sources { + for _, tc := range []struct { + model string + input, output, write, read float64 + }{ + {"gpt-6-sol", 2e-6, 10e-6, 2.5e-6, 0.2e-6}, + {"gpt-6-luna", 0.1e-6, 0.5e-6, 0.125e-6, 0.01e-6}, + } { + t.Run(source+"/"+tc.model, func(t *testing.T) { + for _, n := range []int{271999, 272000, 272001} { + for tier, mult := range map[string]float64{"": 1, "priority": 2, "fast": 2, "flex": 0.5} { + tokens := UsageTokens{InputTokens: n - 3000, CacheReadTokens: 2000, CacheCreationTokens: 1000, OutputTokens: 500} + cost, err := svc.CalculateCostWithServiceTier(tc.model, tokens, 1, tier) + require.NoError(t, err) + im, om := 1.0, 1.0 + if n > 272000 { + im = 2 + om = 1.5 + } + require.InDelta(t, float64(tokens.InputTokens)*tc.input*im*mult, cost.InputCost, 1e-10) + require.InDelta(t, 1000*tc.write*im*mult, cost.CacheCreationCost, 1e-10) + require.InDelta(t, 2000*tc.read*im*mult, cost.CacheReadCost, 1e-10) + require.InDelta(t, 500*tc.output*om*mult, cost.OutputCost, 1e-10) + require.InDelta(t, cost.InputCost+cost.OutputCost+cost.CacheCreationCost+cost.CacheReadCost, cost.TotalCost, 1e-10) + require.Equal(t, n > 272000, cost.LongContextBillingApplied) + } + } + }) + } + t.Run(source+"/opus", func(t *testing.T) { + tokens := UsageTokens{InputTokens: 300000, OutputTokens: 500, CacheReadTokens: 1000, CacheCreationTokens: 1000, CacheCreation5mTokens: 400, CacheCreation1hTokens: 600} + for tier, mult := range map[string]float64{"": 1, "fast": 2} { + cost, err := svc.CalculateCostWithServiceTier("claude-opus-5-5", tokens, 1, tier) + require.NoError(t, err) + require.InDelta(t, 1.2*mult, cost.InputCost, 1e-10) + require.InDelta(t, (400*5e-6+600*8e-6)*mult, cost.CacheCreationCost, 1e-10) + require.InDelta(t, 1000*0.2e-6*mult, cost.CacheReadCost, 1e-10) + require.InDelta(t, 500*20e-6*mult, cost.OutputCost, 1e-10) + require.False(t, cost.LongContextBillingApplied) + } + }) + } +} + +func TestNewModelPricingChannelOverridesAndFamilyIsolation(t *testing.T) { + svc := newTestBillingService() + for _, model := range []string{"gpt-6-sol", "gpt-6-luna", "claude-opus-5-5"} { + t.Run(model, func(t *testing.T) { + zero := 0.0 + prices, err := svc.GetModelPricingWithChannel(model, &ChannelModelPricing{InputPrice: &zero, OutputPrice: &zero, CacheWritePrice: &zero, CacheReadPrice: &zero}) + require.NoError(t, err) + cost := svc.computeTokenBreakdown(prices, UsageTokens{InputTokens: 300000, CacheReadTokens: 1000, CacheCreationTokens: 1000, OutputTokens: 1000}, 1, "priority", true) + require.Zero(t, cost.TotalCost) + fresh, err := svc.GetModelPricing(model) + require.NoError(t, err) + require.Positive(t, fresh.InputPricePerToken) + }) + } + prices, err := svc.GetModelPricing("claude-opus-5") + require.NoError(t, err) + require.Equal(t, 5e-6, prices.InputPricePerToken) + prices, err = svc.GetModelPricing("gpt-6") + require.NoError(t, err) + require.Equal(t, 10e-6, prices.InputPricePerToken) + require.Equal(t, "gpt-6-sol", normalizeKnownOpenAICodexModel("openai/gpt-6-sol-max")) + require.Equal(t, "gpt-6-luna", normalizeKnownOpenAICodexModel("gpt-6-luna-openai-compact")) +} + +func TestNewModelPricingExplicitZeroCacheWrite(t *testing.T) { + svc := &PricingService{} + var err error + svc.pricingData, err = svc.parsePricingData([]byte(`{"gpt-6-sol":{"litellm_provider":"openai","input_cost_per_token":0.000002,"output_cost_per_token":0.00001,"input_cost_per_token_flex":0.000001,"cache_creation_input_token_cost":0}}`)) + require.NoError(t, err) + billing := NewBillingService(&config.Config{}, svc) + for _, tier := range []string{"", "priority", "flex"} { + cost, err := billing.CalculateCostWithServiceTier("gpt-6-sol", UsageTokens{CacheCreationTokens: 1000}, 1, tier) + require.NoError(t, err) + require.Zero(t, cost.CacheCreationCost) + } +} + +func TestNewModelPricingAliasesRetainExplicitOverrides(t *testing.T) { + zero := &LiteLLMModelPricing{} + svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + "gpt-6-sol": zero, "gpt-6-luna": zero, "claude-opus-5-5": zero, + }} + for _, model := range []string{"gpt-6-sol-max", "openai/gpt-6-luna-openai-compact", "claude-opus-5-5-thinking"} { + require.Same(t, zero, svc.GetModelPricing(model)) + } +} diff --git a/backend/internal/service/claude_code_version_sync_service.go b/backend/internal/service/claude_code_version_sync_service.go new file mode 100644 index 0000000000..ed5bd2bbce --- /dev/null +++ b/backend/internal/service/claude_code_version_sync_service.go @@ -0,0 +1,218 @@ +package service + +import ( + "context" + "errors" + "log/slog" + "strings" + "sync" + "time" +) + +const ( + // claudeCodeVersionSyncInterval 自动同步间隔。Claude Code CLI 发版频率是小时级, + // 1 小时足够跟上官方节奏,同时把对 GitHub API 的调用压到每天 24 次。 + claudeCodeVersionSyncInterval = time.Hour + // claudeCodeVersionSyncTimeout 单次同步的整体超时。 + claudeCodeVersionSyncTimeout = 30 * time.Second + // claudeCodeVersionSyncRepo 官方 Claude Code CLI 仓库。 + claudeCodeVersionSyncRepo = "anthropics/claude-code" + // claudeCodeVersionSyncPerPage 回退路径单次拉取的 release 数量(主路径见 + // fetchLatestStableVersion)。 + claudeCodeVersionSyncPerPage = 30 + // claudeCodeVersionTagPrefix 客户端 release 的 tag 前缀(如 v2.1.280)。 + // 同仓库的 tag 家族单一,但显式按前缀过滤仍能挡住上游改版或混入的其他 tag。 + claudeCodeVersionTagPrefix = "v" +) + +// ClaudeCodeVersionSyncService 周期性把官方 Claude Code CLI 的最新稳定版版本号同步到设置, +// 供出站规范身份使用,避免为了跟上游版本而发新版本。 +// +// 同步值写入 SettingKeyClaudeCodeClientVersionSynced(本服务独占写入);管理员在面板填写的 +// SettingKeyClaudeCodeClientVersion 优先级更高,因此手工固定版本不会被同步覆盖。 +type ClaudeCodeVersionSyncService struct { + settingRepo SettingRepository + settingService *SettingService + githubClient GitHubReleaseClient + interval time.Duration + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup +} + +func NewClaudeCodeVersionSyncService( + settingRepo SettingRepository, + settingService *SettingService, + githubClient GitHubReleaseClient, + interval time.Duration, +) *ClaudeCodeVersionSyncService { + return &ClaudeCodeVersionSyncService{ + settingRepo: settingRepo, + settingService: settingService, + githubClient: githubClient, + interval: interval, + stopCh: make(chan struct{}), + } +} + +func (s *ClaudeCodeVersionSyncService) Start() { + if s == nil || s.settingRepo == nil || s.githubClient == nil || s.interval <= 0 { + return + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + + s.runInitial() + for { + select { + case <-ticker.C: + s.runOnce() + case <-s.stopCh: + return + } + } + }() +} + +func (s *ClaudeCodeVersionSyncService) Stop() { + if s == nil { + return + } + s.stopOnce.Do(func() { + close(s.stopCh) + }) + s.wg.Wait() +} + +// runInitial 执行启动时的首次同步。若同步值在一个同步周期内已被刷新过则跳过: +// 频繁重启、滚动发布或崩溃重启会让「启动即同步」放大成对 GitHub 的连续请求, +// 而同步间隔只有 1 小时,重启后没有立刻重新拉取的必要。 +func (s *ClaudeCodeVersionSyncService) runInitial() { + if s.syncedWithinInterval() { + return + } + s.runOnce() +} + +// syncedWithinInterval 判断已同步值是否仍在一个同步周期内。 +// 借设置行自身的 UpdatedAt 判断,无需额外记录时间戳的设置项。 +// 读取失败或尚无有效同步值时返回 false,让启动同步照常执行。 +func (s *ClaudeCodeVersionSyncService) syncedWithinInterval() bool { + if s.interval <= 0 { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), claudeCodeVersionSyncTimeout) + defer cancel() + + setting, err := s.settingRepo.Get(ctx, SettingKeyClaudeCodeClientVersionSynced) + if err != nil || setting == nil || setting.UpdatedAt.IsZero() { + return false + } + if NormalizeClaudeCodeClientVersion(setting.Value) == "" { + return false + } + return time.Since(setting.UpdatedAt) < s.interval +} + +func (s *ClaudeCodeVersionSyncService) runOnce() { + ctx, cancel := context.WithTimeout(context.Background(), claudeCodeVersionSyncTimeout) + defer cancel() + + if !s.autoSyncEnabled(ctx) { + return + } + + latest := s.fetchLatestStableVersion(ctx) + if latest == "" { + return + } + + current, err := s.settingRepo.GetValue(ctx, SettingKeyClaudeCodeClientVersionSynced) + if err != nil && !errors.Is(err, ErrSettingNotFound) { + // 无法确认已有版本时跳过写入,避免数据库短暂故障导致版本降级。 + slog.Warn("claude_code_version_sync_current_read_failed", "error", err) + return + } + current = NormalizeClaudeCodeClientVersion(current) + // 只向前推进:上游偶发返回旧数据或重新发布历史 tag 时不把已同步的版本号降级。 + if current != "" && CompareVersions(latest, current) <= 0 { + return + } + if err := s.settingRepo.Set(ctx, SettingKeyClaudeCodeClientVersionSynced, latest); err != nil { + slog.Warn("claude_code_version_sync_persist_failed", "version", latest, "error", err) + return + } + s.settingService.InvalidateClaudeCodeClientVersionCache() + slog.Info("claude_code_version_synced", "previous", current, "version", latest) +} + +// fetchLatestStableVersion 取官方最新稳定版 CLI 版本号;取不到时返回空串, +// 由调用方保持既有值(不清空、不降级),各失败分支自行落日志。 +// +// 主路径 /releases/latest:该端点本身就排除 draft 与 prerelease,直接给出最新正式发布。 +// +// 回退列表扫描:latest 抓取失败或返回值未通过过滤时,扫一页 release 继续跟随官方版本, +// 否则版本号会静默停更。两条路径共用同一套过滤(前缀 / draft / prerelease / 版本号形态), +// 语义不会分叉。 +func (s *ClaudeCodeVersionSyncService) fetchLatestStableVersion(ctx context.Context) string { + release, err := s.githubClient.FetchLatestRelease(ctx, claudeCodeVersionSyncRepo) + if err != nil { + slog.Warn("claude_code_version_sync_latest_fetch_failed", "error", err) + } else if version := latestClaudeCodeStableReleaseVersion([]*GitHubRelease{release}); version != "" { + return version + } + + // 主路径没拿到可用版本(抓取失败,或 latest 未通过过滤)。 + releases, err := s.githubClient.FetchRecentReleases(ctx, claudeCodeVersionSyncRepo, claudeCodeVersionSyncPerPage) + if err != nil { + slog.Warn("claude_code_version_sync_fetch_failed", "error", err) + return "" + } + version := latestClaudeCodeStableReleaseVersion(releases) + if version == "" { + slog.Warn("claude_code_version_sync_no_stable_release", "repo", claudeCodeVersionSyncRepo) + } + return version +} + +// autoSyncEnabled 读取面板开关。缺失或空值视为开启,与设置默认值一致; +// 读取失败时保持开启,避免一次数据库抖动就静默停掉版本跟随。 +func (s *ClaudeCodeVersionSyncService) autoSyncEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyClaudeCodeVersionAutoSyncEnabled) + if err != nil { + return true + } + if strings.TrimSpace(value) == "" { + return true + } + return strings.TrimSpace(value) == "true" +} + +// latestClaudeCodeStableReleaseVersion 从 release 列表里挑出最大的稳定版 CLI 版本号。 +// 过滤条件:tag 前缀为 v、非草稿、非预发布、版本号须通过 NormalizeClaudeCodeClientVersion +// (严格三段纯数字 semver,且不低于内置基线)。取最大值而非最新发布, +// 避免重新发布历史 tag 造成回退。 +// 主路径的单条 /releases/latest 结果也走本函数(单元素切片),保证两条取数路径的过滤语义一致。 +func latestClaudeCodeStableReleaseVersion(releases []*GitHubRelease) string { + best := "" + for _, release := range releases { + if release == nil || release.Draft || release.Prerelease { + continue + } + tag := strings.TrimSpace(release.TagName) + if !strings.HasPrefix(tag, claudeCodeVersionTagPrefix) { + continue + } + version := NormalizeClaudeCodeClientVersion(strings.TrimPrefix(tag, claudeCodeVersionTagPrefix)) + if version == "" || strings.Contains(version, "-") { + continue + } + if best == "" || CompareVersions(version, best) > 0 { + best = version + } + } + return best +} diff --git a/backend/internal/service/claude_code_version_sync_service_test.go b/backend/internal/service/claude_code_version_sync_service_test.go new file mode 100644 index 0000000000..b4c4bb3314 --- /dev/null +++ b/backend/internal/service/claude_code_version_sync_service_test.go @@ -0,0 +1,373 @@ +package service + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// --- mock: 只实现同步任务用到的读写,其余方法不应被调用 --- + +type claudeCodeVersionSyncSettingRepoStub struct { + SettingRepository // 嵌入接口,未实现的方法会 panic(不应被调用) + + mu sync.Mutex + values map[string]string + getErr error + getErrKey string + setErr error + updatedAt time.Time + writes []string +} + +func newClaudeCodeVersionSyncSettingRepoStub(values map[string]string) *claudeCodeVersionSyncSettingRepoStub { + if values == nil { + values = map[string]string{} + } + return &claudeCodeVersionSyncSettingRepoStub{values: values} +} + +func (r *claudeCodeVersionSyncSettingRepoStub) GetValue(_ context.Context, key string) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.getErr != nil && (r.getErrKey == "" || r.getErrKey == key) { + return "", r.getErr + } + value, ok := r.values[key] + if !ok { + return "", ErrSettingNotFound + } + return value, nil +} + +func (r *claudeCodeVersionSyncSettingRepoStub) Set(_ context.Context, key, value string) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.setErr != nil { + return r.setErr + } + r.values[key] = value + r.writes = append(r.writes, value) + return nil +} + +func (r *claudeCodeVersionSyncSettingRepoStub) syncedWrites() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.writes...) +} + +func (r *claudeCodeVersionSyncSettingRepoStub) Get(_ context.Context, key string) (*Setting, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.getErr != nil { + return nil, r.getErr + } + value, ok := r.values[key] + if !ok { + return nil, ErrSettingNotFound + } + return &Setting{Key: key, Value: value, UpdatedAt: r.updatedAt}, nil +} + +type claudeCodeVersionSyncGitHubStub struct { + GitHubReleaseClient // 嵌入接口,未实现的方法会 panic(不应被调用) + + // 主路径 /releases/latest;latest 为 nil 且 latestErr 为 nil 时模拟「拿不到可用值」, + // 使调用落到回退的列表扫描上。 + latest *GitHubRelease + latestErr error + latestCalls int + + releases []*GitHubRelease + err error + calls int +} + +func (c *claudeCodeVersionSyncGitHubStub) FetchLatestRelease(_ context.Context, _ string) (*GitHubRelease, error) { + c.latestCalls++ + if c.latestErr != nil { + return nil, c.latestErr + } + return c.latest, nil +} + +func (c *claudeCodeVersionSyncGitHubStub) FetchRecentReleases(_ context.Context, _ string, _ int) ([]*GitHubRelease, error) { + c.calls++ + if c.err != nil { + return nil, c.err + } + return c.releases, nil +} + +func newClaudeCodeVersionSyncService( + repo SettingRepository, + github GitHubReleaseClient, +) *ClaudeCodeVersionSyncService { + return NewClaudeCodeVersionSyncService(repo, &SettingService{}, github, claudeCodeVersionSyncInterval) +} + +// 同仓库可能混入非 v 前缀 tag 与预发布 / 草稿,必须只认 v 前缀的稳定版, +// 否则会把无关 tag 的版本号当成客户端版本同步出去。 +func TestLatestClaudeCodeStableReleaseVersion(t *testing.T) { + releases := []*GitHubRelease{ + {TagName: "v2.1.281-beta.1", Prerelease: true}, + {TagName: "v2.1.280"}, + {TagName: "v2.1.279"}, + {TagName: "v2.999.0", Draft: true}, + {TagName: "not-a-tag"}, + nil, + } + + require.Equal(t, "2.1.280", latestClaudeCodeStableReleaseVersion(releases)) + require.Empty(t, latestClaudeCodeStableReleaseVersion(nil)) + require.Empty(t, latestClaudeCodeStableReleaseVersion([]*GitHubRelease{{TagName: "not-a-tag"}})) + // 预发布 tag 即使漏标 Prerelease 也要被版本号后缀挡住。 + require.Empty(t, latestClaudeCodeStableReleaseVersion([]*GitHubRelease{{TagName: "v2.1.281-beta.1"}})) + // 低于内置基线的版本号即使形态合法也不得采信。 + require.Empty(t, latestClaudeCodeStableReleaseVersion([]*GitHubRelease{{TagName: "v2.1.9"}})) +} + +func TestClaudeCodeVersionSyncWritesLatestStableVersion(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(nil) + github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{ + {TagName: "v2.1.279"}, + {TagName: "v2.1.280"}, + }} + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Equal(t, []string{"2.1.280"}, repo.syncedWrites()) +} + +// 只向前推进:上游偶发返回旧数据或重新发布历史 tag 时不把已同步版本降级。 +func TestClaudeCodeVersionSyncNeverMovesBackwards(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{ + SettingKeyClaudeCodeClientVersionSynced: "2.1.280", + }) + github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.279"}}} + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Empty(t, repo.syncedWrites()) +} + +func TestClaudeCodeVersionSyncSkippedWhenDisabled(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{ + SettingKeyClaudeCodeVersionAutoSyncEnabled: "false", + }) + github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.280"}}} + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Zero(t, github.latestCalls, "关闭自动同步后不应请求上游") + require.Zero(t, github.calls, "关闭自动同步后不应请求上游") + require.Empty(t, repo.syncedWrites()) +} + +// 面板开关缺失或为空一律视为开启,与设置默认值一致;读取失败同样按开启处理, +// 避免一次数据库抖动就静默停掉版本跟随。 +func TestClaudeCodeVersionSyncEnabledByDefaultOrOnError(t *testing.T) { + for _, tt := range []struct { + name string + getErr error + value string + }{ + {name: "缺失"}, + {name: "空值", value: ""}, + {name: "显式开启", value: "true"}, + {name: "读取失败", getErr: errors.New("db down")}, + } { + t.Run(tt.name, func(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{ + SettingKeyClaudeCodeVersionAutoSyncEnabled: tt.value, + }) + repo.getErr = tt.getErr + repo.getErrKey = SettingKeyClaudeCodeVersionAutoSyncEnabled + github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.280"}}} + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Equal(t, []string{"2.1.280"}, repo.syncedWrites()) + }) + } +} + +func TestClaudeCodeVersionSyncKeepsValueOnCurrentVersionReadError(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{ + SettingKeyClaudeCodeClientVersionSynced: "2.1.281", + }) + repo.getErr = errors.New("读取已有版本失败") + repo.getErrKey = SettingKeyClaudeCodeClientVersionSynced + github := &claudeCodeVersionSyncGitHubStub{latest: &GitHubRelease{TagName: "v2.1.280"}} + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Empty(t, repo.syncedWrites(), "无法确认已有版本时不得覆盖同步值") + require.Equal(t, "2.1.281", repo.values[SettingKeyClaudeCodeClientVersionSynced]) +} + +// 抓取失败保持既有值,不清空、不降级。两条取数路径都失败才算真正拿不到。 +func TestClaudeCodeVersionSyncKeepsValueOnFetchError(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{ + SettingKeyClaudeCodeClientVersionSynced: "2.1.280", + }) + github := &claudeCodeVersionSyncGitHubStub{ + latestErr: errors.New("network down"), + err: errors.New("network down"), + } + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Equal(t, 1, github.latestCalls) + require.Equal(t, 1, github.calls) + require.Empty(t, repo.syncedWrites()) + value, err := repo.GetValue(context.Background(), SettingKeyClaudeCodeClientVersionSynced) + require.NoError(t, err) + require.Equal(t, "2.1.280", value) +} + +// 主路径 /releases/latest:该端点已排除 draft / prerelease,直接给出最新正式发布。 +func TestClaudeCodeVersionSyncUsesLatestReleaseEndpoint(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(nil) + github := &claudeCodeVersionSyncGitHubStub{ + latest: &GitHubRelease{TagName: "v2.1.280"}, + // 列表若被调用会给出不同答案,用于证明取值确实来自主路径。 + releases: []*GitHubRelease{{TagName: "v2.1.279"}}, + } + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Equal(t, 1, github.latestCalls) + require.Zero(t, github.calls, "主路径可用时不应再拉列表页") + require.Equal(t, []string{"2.1.280"}, repo.syncedWrites()) +} + +// 回退列表扫描:主路径拿不到可用稳定版时必须继续扫一页 release, +// 否则版本号会静默停更。各种拿不到的形态都要回退。 +func TestClaudeCodeVersionSyncFallsBackToReleaseList(t *testing.T) { + tests := []struct { + name string + latest *GitHubRelease + latestErr error + }{ + {name: "latest 前缀不符", latest: &GitHubRelease{TagName: "cli-2.1.280"}}, + {name: "latest 是预发布", latest: &GitHubRelease{TagName: "v2.1.281-beta.1", Prerelease: true}}, + {name: "latest 是草稿", latest: &GitHubRelease{TagName: "v2.1.281", Draft: true}}, + {name: "latest 抓取失败", latestErr: errors.New("network down")}, + // 上游返回空对象:不得因此 panic,按拿不到处理。 + {name: "latest 为空", latest: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(nil) + github := &claudeCodeVersionSyncGitHubStub{ + latest: tt.latest, + latestErr: tt.latestErr, + releases: []*GitHubRelease{ + {TagName: "v2.1.281-beta.1", Prerelease: true}, + {TagName: "v2.1.280"}, + {TagName: "v2.1.279"}, + }, + } + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Equal(t, 1, github.latestCalls) + require.Equal(t, 1, github.calls) + require.Equal(t, []string{"2.1.280"}, repo.syncedWrites()) + }) + } +} + +// 两条路径共用同一套过滤:主路径的单条 latest 同样要过前缀 / 版本号形态校验, +// 不能因为「端点保证是正式发布」就直接采信 tag。 +func TestClaudeCodeVersionSyncLatestSharesFiltering(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(nil) + github := &claudeCodeVersionSyncGitHubStub{ + // 剥掉 v 前缀后不满足基线校验(2.1.9 低于内置基线 2.1.258)的 latest + // 必须被拒绝而不是直接采信。 + latest: &GitHubRelease{TagName: "v2.1.9"}, + releases: []*GitHubRelease{{TagName: "v2.1.280"}}, + } + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Equal(t, []string{"2.1.280"}, repo.syncedWrites()) +} + +// 依赖缺失时 Start 必须直接返回,不能起一个空转的 goroutine。 +func TestClaudeCodeVersionSyncStartRequiresDependencies(t *testing.T) { + require.NotPanics(t, func() { + svc := NewClaudeCodeVersionSyncService(nil, nil, nil, claudeCodeVersionSyncInterval) + svc.Start() + svc.Stop() + }) +} + +// 启动同步防抖:同步值仍在一个周期内时跳过,避免频繁重启/滚动发布把启动同步 +// 放大成对 GitHub 的连续请求。 +func TestClaudeCodeVersionSyncInitialSkipsWhenRecentlySynced(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{ + SettingKeyClaudeCodeClientVersionSynced: "2.1.280", + }) + repo.updatedAt = time.Now().Add(-time.Minute) + github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.281"}}} + + newClaudeCodeVersionSyncService(repo, github).runInitial() + + require.Zero(t, github.calls, "同步值仍在周期内时不应请求上游") + require.Empty(t, repo.syncedWrites()) +} + +func TestClaudeCodeVersionSyncInitialRunsWhenStaleOrMissing(t *testing.T) { + t.Run("同步值已过期", func(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{ + SettingKeyClaudeCodeClientVersionSynced: "2.1.280", + }) + repo.updatedAt = time.Now().Add(-2 * time.Hour) + github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.281"}}} + + newClaudeCodeVersionSyncService(repo, github).runInitial() + + require.Equal(t, 1, github.calls) + require.Equal(t, []string{"2.1.281"}, repo.syncedWrites()) + }) + + // 首次部署尚无同步值:必须立刻同步,不能被防抖挡住。 + t.Run("尚无同步值", func(t *testing.T) { + repo := newClaudeCodeVersionSyncSettingRepoStub(nil) + repo.updatedAt = time.Now() + github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.280"}}} + + newClaudeCodeVersionSyncService(repo, github).runInitial() + + require.Equal(t, 1, github.calls) + require.Equal(t, []string{"2.1.280"}, repo.syncedWrites()) + }) +} + +// 版本比较必须按段取数字:字典序会把 2.1.9 判为大于 2.1.280, +// 从而让「取最大值」和「只向前推进」两处逻辑同时判错。 +func TestClaudeCodeVersionComparisonIsNumericNotLexical(t *testing.T) { + require.Greater(t, CompareVersions("2.1.280", "2.1.9"), 0) + + require.Equal(t, "2.1.280", latestClaudeCodeStableReleaseVersion([]*GitHubRelease{ + {TagName: "v2.1.9"}, + {TagName: "v2.1.280"}, + })) + + repo := newClaudeCodeVersionSyncSettingRepoStub(map[string]string{ + SettingKeyClaudeCodeClientVersionSynced: "2.1.280", + }) + github := &claudeCodeVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "v2.1.258"}}} + + newClaudeCodeVersionSyncService(repo, github).runOnce() + + require.Empty(t, repo.syncedWrites(), "更低的版本号不得写入") +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 883ac9f0d5..9118d28316 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -570,6 +570,9 @@ const ( // SettingKeyOllamaCloudUsageSettings stores the opt-in global runner switch and interval. SettingKeyOllamaCloudUsageSettings = "ollama_cloud_usage_settings" + // SettingKeyOpenCodeGoUsageSettings stores the opt-in global runner switch and interval. + SettingKeyOpenCodeGoUsageSettings = "opencode_go_usage_settings" + // ========================= // Overload Cooldown (529) // ========================= @@ -707,6 +710,15 @@ const ( SettingKeyOpenAICodexClientVersionSynced = "openai_codex_client_version_synced" // SettingKeyOpenAICodexVersionAutoSyncEnabled 是否启用 Codex 客户端版本号自动同步(默认 true)。 SettingKeyOpenAICodexVersionAutoSyncEnabled = "openai_codex_version_auto_sync_enabled" + // SettingKeyClaudeCodeClientVersion 网关对 Anthropic 上游声明的 Claude Code CLI 客户端版本号(管理员覆写)。 + // 空值表示跟随自动同步值;自动同步也没有结果时回退到 claude.CLIVersion()(环境变量覆盖 + 内置基线)。 + // 版本太旧会被 Anthropic 拒绝(claude_code_version_too_old),故该值需保持跟随官方发布。 + SettingKeyClaudeCodeClientVersion = "claude_code_client_version" + // SettingKeyClaudeCodeClientVersionSynced 自动同步任务写入的官方 Claude Code CLI 最新版本号。 + // 由同步任务独占写入,面板只读展示;管理员覆写请用 SettingKeyClaudeCodeClientVersion。 + SettingKeyClaudeCodeClientVersionSynced = "claude_code_client_version_synced" + // SettingKeyClaudeCodeVersionAutoSyncEnabled 是否启用 Claude Code 客户端版本号自动同步(默认 true)。 + SettingKeyClaudeCodeVersionAutoSyncEnabled = "claude_code_version_auto_sync_enabled" // SettingKeyOpenAIAllowClaudeCodeCodexPlugin 已废弃:历史全局开关只作为升级迁移输入读取。 // 迁移后等价规则写入 SettingKeyCodexCLIOnlyWhitelist,不再参与运行时判定。 SettingKeyOpenAIAllowClaudeCodeCodexPlugin = "openai_allow_claude_code_codex_plugin" diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 34eb98d0b7..8a4159c113 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -913,7 +913,7 @@ func TestGatewayService_AnthropicOAuthMimic_RewritesSystemWithBillingBlock(t *te require.Truef(t, anthropicBetaTokensContains(finalBeta, beta), "missing mimic beta %s", beta) } require.False(t, anthropicBetaTokensContains(finalBeta, "client-only-beta")) - for key, value := range claude.DefaultHeaders { + for key, value := range claude.DefaultHeaders() { require.Equal(t, value, getHeaderRaw(upstream.lastReq.Header, key), "mimic fingerprint header %s", key) } require.NotEmpty(t, getHeaderRaw(upstream.lastReq.Header, "x-client-request-id")) @@ -1769,3 +1769,45 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_Non2xxRecordsOllamaActivity(t _, ok := deferred.lastUsedUpdates.Load(int64(604)) require.True(t, ok, "Anthropic passthrough non-2xx on Ollama account must record activity via handleErrorResponse") } + +func TestOpus55RejectsUnsupportedParametersBeforeMimicry(t *testing.T) { + for _, typ := range []string{AccountTypeOAuth, AccountTypeAPIKey} { + for _, field := range []string{`"thinking":{"type":"disabled"}`, `"thinking":{"type":"enabled","budget_tokens":1024}`, `"tool_choice":{"type":"any"}`, `"tool_choice":{"type":"tool","name":"lookup"}`} { + for _, count := range []bool{false, true} { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + model := "claude-opus-5-5" + account := &Account{ID: 1, Platform: PlatformAnthropic, Type: typ} + if typ == AccountTypeAPIKey { + model = "public-opus" + account.Credentials = map[string]any{"model_mapping": map[string]any{model: "claude-opus-5-5"}} + } + body := []byte(`{"model":"` + model + `","messages":[{"role":"user","content":"hello"}],` + field + `}`) + parsed := &ParsedRequest{Model: model, Body: NewRequestBodyRef(body)} + svc := &GatewayService{} + var err error + if count { + err = svc.ForwardCountTokens(context.Background(), c, account, parsed) + } else { + _, err = svc.Forward(context.Background(), c, account, parsed) + } + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "invalid_request_error") + } + } + } +} + +func TestOpus55ThinkingDefaultPreservesSignedHistory(t *testing.T) { + body := []byte(`{"model":"claude-opus-5-5","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"signed"},{"type":"redacted_thinking","data":"encrypted"},{"type":"tool_use","id":"toolu_1","name":"lookup","input":{}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}]}],"tool_choice":{"type":"none"},"thinking":{"type":"adaptive","display":"omitted"}}`) + require.Equal(t, string(body), string(FilterThinkingBlocks(body, "claude-opus-5-5"))) + withoutThinking, _ := deleteJSONPathBytes(body, "thinking") + require.Equal(t, string(withoutThinking), string(FilterThinkingBlocks(withoutThinking, "claude-opus-5-5"))) + out, _ := normalizeClaudeOAuthRequestBody(body, "claude-opus-5-5", claudeOAuthNormalizeOptions{}) + require.Equal(t, "none", gjson.GetBytes(out, "tool_choice.type").String()) + require.False(t, gjson.GetBytes(out, "output_config.effort").Exists(), "omission uses the official medium default") + require.Equal(t, "omitted", gjson.GetBytes(out, "thinking.display").String()) + require.JSONEq(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(out, "messages").Raw) +} diff --git a/backend/internal/service/gateway_billing_header.go b/backend/internal/service/gateway_billing_header.go index dd1f16b2c7..a794af0d20 100644 --- a/backend/internal/service/gateway_billing_header.go +++ b/backend/internal/service/gateway_billing_header.go @@ -5,7 +5,6 @@ import ( "regexp" "strings" - "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -15,10 +14,13 @@ var ccVersionInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+`) var ccVersionWithFingerprintInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+\.[0-9a-fA-F]{3}\b`) -// OAuth mimicry forces the built-in User-Agent after applying account fingerprints. -func effectiveBillingUserAgent(tokenType string, mimicClaudeCode bool, fingerprint *Fingerprint) string { +// effectiveBillingUserAgent 选择写进 x-anthropic-billing-header 的 User-Agent。 +// OAuth mimicry 强制使用调用方传入的 mimicUserAgent(与出站 User-Agent 头同源、 +// 同一次请求内取一次复用,保证 cc_version 与出站头版本严格一致), +// 其余情况使用账号指纹 UA。 +func effectiveBillingUserAgent(mimicUserAgent, tokenType string, mimicClaudeCode bool, fingerprint *Fingerprint) string { if tokenType == "oauth" && mimicClaudeCode { - return claude.DefaultHeaders["User-Agent"] + return mimicUserAgent } if fingerprint == nil { return "" diff --git a/backend/internal/service/gateway_billing_header_test.go b/backend/internal/service/gateway_billing_header_test.go index 6de6b4bfec..5d85b29924 100644 --- a/backend/internal/service/gateway_billing_header_test.go +++ b/backend/internal/service/gateway_billing_header_test.go @@ -138,7 +138,7 @@ func TestBuildOAuthRequest_BillingMatchesWireUserAgent(t *testing.T) { defer func() { require.NoError(t, req.Body.Close()) }() wantUA := cachedUA if tc.mimic { - wantUA = claude.DefaultHeaders["User-Agent"] + wantUA = claude.DefaultHeaders()["User-Agent"] } require.Equal(t, wantUA, getHeaderRaw(req.Header, "User-Agent")) version := ExtractCLIVersion(wantUA) diff --git a/backend/internal/service/gateway_claude_oauth_body.go b/backend/internal/service/gateway_claude_oauth_body.go index b879611dee..fbdeb683ea 100644 --- a/backend/internal/service/gateway_claude_oauth_body.go +++ b/backend/internal/service/gateway_claude_oauth_body.go @@ -263,7 +263,7 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu // temperature:真实 Claude Code CLI 总是发送 temperature(默认 1,客户端可覆盖)。 // 之前的实现直接 delete 会导致 payload 缺字段,与真实 CLI 字节级不一致。 // 策略:客户端传了什么就透传;没传则补默认 1。 - if !gjson.GetBytes(out, "temperature").Exists() { + if !gjson.GetBytes(out, "temperature").Exists() && !claude.IsOpus55(modelID) { if next, ok := setJSONValueBytes(out, "temperature", 1); ok { out = next modified = true @@ -304,7 +304,7 @@ func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAu // - 其他形态(auto/any/none)原样透传 // 如果 body 里完全没有 tools(空数组),tool_choice 没意义时才删除 if !gjson.GetBytes(out, "tools").IsArray() || len(gjson.GetBytes(out, "tools").Array()) == 0 { - if gjson.GetBytes(out, "tool_choice").Exists() { + if !claude.IsOpus55(modelID) && gjson.GetBytes(out, "tool_choice").Exists() { if next, ok := deleteJSONPathBytes(out, "tool_choice"); ok { out = next modified = true @@ -767,14 +767,17 @@ func expandClaudeOAuthSystemPromptTextTemplate(body []byte, text string, expansi return "", nil } expansionPrompt = defaultClaudeOAuthExpansionPrompt(expansionPrompt) - billingText, err := buildBillingAttributionText(body, claude.CLIVersion()) + // 同一次展开内只取一次版本号,billing attribution / 指纹 / 占位符三处共用, + // 避免运行期版本翻转瞬间取到不同值。 + cliVersion := claude.EffectiveCLIVersion() + billingText, err := buildBillingAttributionText(body, cliVersion) if err != nil { return "", err } - fp := computeClaudeCodeFingerprint(body, claude.CLIVersion()) + fp := computeClaudeCodeFingerprint(body, cliVersion) replacer := strings.NewReplacer( "{billing_header}", billingText, - "{cc_version}", claude.CLIVersion(), + "{cc_version}", cliVersion, "{fp}", fp, "{claude_code_system_prompt}", claudeCodeSystemPrompt, "{claude_code_expansion_prompt}", expansionPrompt, diff --git a/backend/internal/service/gateway_cli_version_runtime_test.go b/backend/internal/service/gateway_cli_version_runtime_test.go new file mode 100644 index 0000000000..8175ec2981 --- /dev/null +++ b/backend/internal/service/gateway_cli_version_runtime_test.go @@ -0,0 +1,120 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// withCLIVersionResolverForTest 注入解析器并在用例结束时还原(置 nil),避免用例间污染。 +func withCLIVersionResolverForTest(t *testing.T, resolver func() string) { + t.Helper() + claude.SetCLIVersionResolver(resolver) + t.Cleanup(func() { claude.SetCLIVersionResolver(nil) }) +} + +// 一致性铁律(d):模拟一次 OAuth + mimic 的请求构建(messages 与 count_tokens 两条 +// 出站路径),断言出站 User-Agent 头里的版本号与请求体 x-anthropic-billing-header +// 中 cc_version 的版本号相同,且都等于运行期注入的版本号。头/体不一致会被 Anthropic +// 判为非正版客户端。 +func TestBuildOAuthMimicRequest_RuntimeVersionConsistentBetweenHeaderAndBilling(t *testing.T) { + gin.SetMode(gin.TestMode) + const upgraded = "9.9.9" + withCLIVersionResolverForTest(t, func() string { return upgraded }) + + for _, endpoint := range []string{"messages", "count_tokens"} { + t.Run(endpoint, func(t *testing.T) { + resetGatewayForwardingSettingsCacheForTest(t) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + body := []byte(`{"model":"claude-haiku-4-5","system":[{"type":"text","text":""}],"messages":[{"role":"user","content":"hello world"}]}`) + billing, err := buildBillingAttributionText(body, "2.1.81") + require.NoError(t, err) + body, err = sjson.SetBytes(body, "system.0.text", billing) + require.NoError(t, err) + + svc := &GatewayService{cfg: &config.Config{}} + account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + + var req *http.Request + var wireBody []byte + if endpoint == "messages" { + req, wireBody, err = svc.buildUpstreamRequest(context.Background(), c, account, + body, "test-token", "oauth", "claude-haiku-4-5", false, true) + } else { + req, wireBody, err = svc.buildCountTokensRequest(context.Background(), c, account, + body, "test-token", "oauth", "claude-haiku-4-5", true) + } + require.NoError(t, err) + + wantUA := "claude-cli/" + upgraded + " (external, cli)" + outboundUA := getHeaderRaw(req.Header, "User-Agent") + require.Equal(t, wantUA, outboundUA) + + billingText := gjson.GetBytes(wireBody, "system.0.text").String() + require.Contains(t, billingText, "x-anthropic-billing-header") + require.Contains(t, billingText, "cc_version="+upgraded+"."+computeClaudeCodeFingerprint(wireBody, upgraded)+";") + require.NotContains(t, billingText, "cc_version=2.1.81") + + // 头/体版本号必须来自同一字符串。 + headerVersion := ExtractCLIVersion(outboundUA) + require.Equal(t, upgraded, headerVersion) + require.Contains(t, billingText, "cc_version="+headerVersion+".") + }) + } +} + +// e. 注入更高版本后,floorClaudeCLIUserAgentVersion 把存量低版本指纹抬升到新版本; +// +// 等于或高于新版本的指纹保持不动(只升不降)。 +func TestFloorClaudeCLIUserAgentVersion_UsesRuntimeVersion(t *testing.T) { + const upgraded = "9.9.9" + withCLIVersionResolverForTest(t, func() string { return upgraded }) + + floored, changed := floorClaudeCLIUserAgentVersion("claude-cli/2.1.100 (external, cli)") + require.True(t, changed) + require.Equal(t, "claude-cli/"+upgraded+" (external, cli)", floored) + + // 等于运行期版本:不动。 + same, changed := floorClaudeCLIUserAgentVersion("claude-cli/" + upgraded + " (external, cli)") + require.False(t, changed) + require.Equal(t, "claude-cli/"+upgraded+" (external, cli)", same) + + // 高于运行期版本:不动。 + greater, changed := floorClaudeCLIUserAgentVersion("claude-cli/99.0.0 (external, cli)") + require.False(t, changed) + require.Equal(t, "claude-cli/99.0.0 (external, cli)", greater) + + // resolver 返回非法值时回退内置基线,floor 语义仍然成立。 + withCLIVersionResolverForTest(t, func() string { return "abc" }) + baselineFloored, changed := floorClaudeCLIUserAgentVersion("claude-cli/1.0.0 (external, cli)") + require.True(t, changed) + require.Equal(t, "claude-cli/"+claude.CLIVersion()+" (external, cli)", baselineFloored) +} + +// 注入更高版本后,isAcceptableFingerprintUserAgent 以运行期版本为基准,允许客户端 +// 上报的新版本。 +func TestIsAcceptableFingerprintUserAgent_UsesRuntimeVersion(t *testing.T) { + const upgraded = "9.9.9" + withCLIVersionResolverForTest(t, func() string { return upgraded }) + require.True(t, isAcceptableFingerprintUserAgent("claude-cli/"+upgraded+" (external, cli)")) + // 允许的最大主版本超前量是 +2(9.9.9 → 11.x)。 + require.True(t, isAcceptableFingerprintUserAgent("claude-cli/11.0.0 (external, cli)")) + // 超前 +3 会被拒(与静态基线下的既有语义一致,只是基准换成运行期版本)。 + require.False(t, isAcceptableFingerprintUserAgent("claude-cli/12.0.0 (external, cli)")) +} + +// defaultFingerprint 现取运行期版本,不再在 init 固化。 +func TestDefaultFingerprintUsesRuntimeVersion(t *testing.T) { + const upgraded = "9.9.9" + withCLIVersionResolverForTest(t, func() string { return upgraded }) + require.Equal(t, "claude-cli/"+upgraded+" (external, cli)", defaultFingerprint().UserAgent) +} diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index 6a17f50b17..7a0578f08c 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -23,6 +23,17 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, return fmt.Errorf("parse request: empty request") } + validationModel := parsed.Model + if account != nil && account.Type == AccountTypeAPIKey { + validationModel = account.GetMappedModel(validationModel) + } + if account != nil && account.Platform == PlatformAnthropic && !account.IsBedrock() && account.Type != AccountTypeServiceAccount { + if err := validateClaudeOpus55Request(parsed.Body.Bytes(), validationModel); err != nil { + s.countTokensError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return err + } + } + if account != nil && account.IsAnthropicAPIKeyPassthroughEnabled() { passthroughBody := parsed.Body.Bytes() if reqModel := parsed.Model; reqModel != "" { @@ -498,7 +509,10 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con if ctEnableFP { billingFingerprint = ctFingerprint } - if billingUA := effectiveBillingUserAgent(tokenType, mimicClaudeCode, billingFingerprint); billingUA != "" { + // 一致性铁律:同一次请求内只取一次 mimic UA,billing cc_version 与出站 + // User-Agent 头共用这一个字符串(同 buildUpstreamRequest)。 + ctMimicUserAgent := claude.DefaultUserAgent() + if billingUA := effectiveBillingUserAgent(ctMimicUserAgent, tokenType, mimicClaudeCode, billingFingerprint); billingUA != "" { body = syncBillingHeaderVersion(body, billingUA) } @@ -564,7 +578,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con // OAuth + mimic Claude Code:强制注入 CLI 指纹 header if tokenType == "oauth" && mimicClaudeCode { - applyClaudeCodeMimicHeaders(req, false) + applyClaudeCodeMimicHeaders(req, false, ctMimicUserAgent) } // 写入最终 anthropic-beta header(Del 一次避免白名单透传值残留) diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index 3839198484..f292773765 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -93,6 +93,17 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if parsed == nil { return nil, fmt.Errorf("parse request: empty request") } + // API-key mappings and OAuth native IDs are resolved before mimicry. + validationModel := parsed.Model + if account != nil && account.Type == AccountTypeAPIKey { + validationModel = account.GetMappedModel(validationModel) + } + if account != nil && account.Platform == PlatformAnthropic && !account.IsBedrock() && account.Type != AccountTypeServiceAccount { + if err := validateClaudeOpus55Request(parsed.Body.Bytes(), validationModel); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"type": "error", "error": gin.H{"type": "invalid_request_error", "message": err.Error()}}) + return nil, err + } + } // Anthropic Fast is requested with speed=fast rather than OpenAI's // service_tier. Attach it at this shared boundary so passthrough, OAuth and // partial-stream results all use the same billing and usage-log path. diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index f305146e58..ac1d393602 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -50,16 +50,7 @@ func (s *GatewayService) ForwardAsChatCompletions( return nil, fmt.Errorf("convert chat completions to responses: %w", err) } - anthropicReq, err := apicompat.ResponsesToAnthropicRequest(responsesReq) - if err != nil { - return nil, fmt.Errorf("convert responses to anthropic: %w", err) - } - - // 3. Force upstream streaming - anthropicReq.Stream = true - reqStream := true - - // 4. Model mapping + // Resolve the final upstream model before model-specific conversion. mappedModel := originalModel if account.Type == AccountTypeAPIKey || account.Type == AccountTypeServiceAccount { mappedModel = account.GetMappedModel(originalModel) @@ -75,7 +66,22 @@ func (s *GatewayService) ForwardAsChatCompletions( mappedModel = normalized } } - anthropicReq.Model = mappedModel + if err := validateClaudeOpus55Request(body, mappedModel); err != nil { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } + responsesReq.Model = mappedModel + anthropicReq, err := apicompat.ResponsesToAnthropicRequest(responsesReq) + if err != nil { + if claude.IsOpus55(mappedModel) { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + } + return nil, fmt.Errorf("convert responses to anthropic: %w", err) + } + + // 3. Force upstream streaming + anthropicReq.Stream = true + reqStream := true logger.L().Debug("gateway forward_as_chat_completions: model mapping applied", zap.Int64("account_id", account.ID), diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 999b544adc..44ed3329c4 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -60,16 +60,7 @@ func (s *GatewayService) ForwardAsResponses( clientStream := responsesReq.Stream // 3. Convert Responses → Anthropic - anthropicReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq) - if err != nil { - return nil, fmt.Errorf("convert responses to anthropic: %w", err) - } - - // 3. Force upstream streaming (Anthropic works best with streaming) - anthropicReq.Stream = true - reqStream := true - - // 4. Model mapping + // Resolve the final upstream model before model-specific conversion. mappedModel := originalModel if account.Type == AccountTypeAPIKey || account.Type == AccountTypeServiceAccount { mappedModel = account.GetMappedModel(originalModel) @@ -85,7 +76,22 @@ func (s *GatewayService) ForwardAsResponses( mappedModel = normalized } } - anthropicReq.Model = mappedModel + if err := validateClaudeOpus55Request(body, mappedModel); err != nil { + writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } + responsesReq.Model = mappedModel + anthropicReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq) + if err != nil { + if claude.IsOpus55(mappedModel) { + writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + } + return nil, fmt.Errorf("convert responses to anthropic: %w", err) + } + + // 3. Force upstream streaming (Anthropic works best with streaming) + anthropicReq.Stream = true + reqStream := true logger.L().Debug("gateway forward_as_responses: model mapping applied", zap.Int64("account_id", account.ID), @@ -412,6 +418,8 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse( finalResp.Content[idx].Text += event.Delta.Text case "thinking_delta": finalResp.Content[idx].Thinking += event.Delta.Thinking + case "signature_delta": + finalResp.Content[idx].Signature += event.Delta.Signature case "input_json_delta": finalResp.Content[idx].Input = appendRawJSON(finalResp.Content[idx].Input, event.Delta.PartialJSON) } @@ -444,6 +452,9 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse( } // Convert to Responses format + if claude.IsOpus55(mappedModel) { + finalResp.Model = mappedModel + } responsesResp := apicompat.AnthropicToResponsesResponse(finalResp) responsesResp.Model = originalModel // Use original model name @@ -503,6 +514,7 @@ func (s *GatewayService) handleResponsesStreamingResponse( state := apicompat.NewAnthropicEventToResponsesState() state.Model = originalModel + state.PreserveThinkingSignatures = claude.IsOpus55(mappedModel) clientToolRestorer := apicompat.NewResponsesClientToolStreamRestorer(clientToolMapping) var usage ClaudeUsage var firstTokenMs *int diff --git a/backend/internal/service/gateway_forward_as_responses_test.go b/backend/internal/service/gateway_forward_as_responses_test.go index 972c6bc1a6..5f74dfac59 100644 --- a/backend/internal/service/gateway_forward_as_responses_test.go +++ b/backend/internal/service/gateway_forward_as_responses_test.go @@ -3,7 +3,10 @@ package service import ( + "context" "encoding/json" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/tidwall/gjson" "io" "net/http" "net/http/httptest" @@ -449,3 +452,74 @@ func TestHandleResponsesStreamingResponse_CompactSSEFormat(t *testing.T) { require.Equal(t, 6, result.Usage.OutputTokens) require.Contains(t, rec.Body.String(), `response.completed`) } + +func TestOpus55ResponsesSignedThinkingBufferedAndStreamed(t *testing.T) { + payload := strings.Join([]string{ + "event: message_start\n" + `data: {"type":"message_start","message":{"id":"msg_1","model":"claude-opus-5-5","content":[],"usage":{"input_tokens":10}}}`, + "event: content_block_start\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`, + "event: content_block_delta\n" + `data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"signed"}}`, + "event: content_block_stop\n" + `data: {"type":"content_block_stop","index":0}`, + "event: content_block_start\n" + `data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}`, + "event: content_block_delta\n" + `data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"ok"}}`, + "event: content_block_stop\n" + `data: {"type":"content_block_stop","index":1}`, + "event: message_delta\n" + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`, + "event: message_stop\n" + `data: {"type":"message_stop"}`, + }, "\n\n") + "\n\n" + for _, stream := range []bool{false, true} { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + resp := &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(payload))} + svc := &GatewayService{} + var err error + if stream { + _, err = svc.handleResponsesStreamingResponse(resp, c, "public-opus", "claude-opus-5-5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + } else { + _, err = svc.handleResponsesBufferedStreamingResponse(resp, c, "public-opus", "claude-opus-5-5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + } + require.NoError(t, err) + require.Contains(t, rec.Body.String(), "anthropic-thinking-v1:") + require.Contains(t, rec.Body.String(), "public-opus") + require.Contains(t, rec.Body.String(), `"text":"ok"`) + } +} + +func TestOpus55BridgeUsesMappedModelBeforeThinkingConversion(t *testing.T) { + for _, chat := range []bool{false, true} { + for _, forced := range []bool{false, true} { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := `{"model":"public-opus","input":"hello","reasoning":{"effort":"xhigh"}}` + if chat { + body = `{"model":"public-opus","messages":[{"role":"user","content":"hello"}],"reasoning_effort":"xhigh"}` + } + if forced { + body = body[:len(body)-1] + `,"tool_choice":"required","tools":[{"type":"function","name":"lookup","function":{"name":"lookup"}}]}` + } + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) + upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(namespaceToolAnthropicStream()))}} + svc := &GatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "fixture-key", "model_mapping": map[string]any{"public-opus": "claude-opus-5-5"}}} + var err error + var result *ForwardResult + if chat { + result, err = svc.ForwardAsChatCompletions(context.Background(), c, account, []byte(body), nil) + } else { + result, err = svc.ForwardAsResponses(context.Background(), c, account, []byte(body), nil) + } + if forced { + require.Error(t, err) + require.Equal(t, 400, rec.Code) + require.Nil(t, upstream.lastReq) + } else { + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.Equal(t, "claude-opus-5-5", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "adaptive", gjson.GetBytes(upstream.lastBody, "thinking.type").String()) + require.Equal(t, "xhigh", gjson.GetBytes(upstream.lastBody, "output_config.effort").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "thinking.budget_tokens").Exists()) + } + } + } +} diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index 72f48ad40a..da4e5d6017 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -223,7 +223,7 @@ func parseGatewayRequestCurrentBody(parsed *ParsedRequest, protocol string) erro parsed.MetadataUserID = gjson.Get(jsonStr, "metadata.user_id").String() thinkingType := gjson.Get(jsonStr, "thinking.type").String() - parsed.ThinkingEnabled = thinkingType == "enabled" || thinkingType == "adaptive" + parsed.ThinkingEnabled = thinkingType == "enabled" || thinkingType == "adaptive" || (protocol == domain.PlatformAnthropic && claude.IsOpus55(parsed.Model)) parsed.OutputEffort = strings.TrimSpace(gjson.Get(jsonStr, "output_config.effort").String()) if protocol == domain.PlatformAnthropic { @@ -566,6 +566,26 @@ func StripEmptyTextBlocks(body []byte) []byte { return out } +// validateClaudeOpus55Request rejects settings that the upstream cannot honor. +// Call before OAuth mimicry can remove tool_choice or alter thinking defaults. +func validateClaudeOpus55Request(body []byte, model string) error { + if !claude.IsOpus55(model) { + return nil + } + switch gjson.GetBytes(body, "thinking.type").String() { + case "disabled", "enabled": + return fmt.Errorf("claude-opus-5-5 requires adaptive thinking; omit thinking or use thinking.type=adaptive and output_config.effort") + } + if gjson.GetBytes(body, "tool_choice").String() == "required" { + return fmt.Errorf("claude-opus-5-5 does not support forced tool_choice; use auto or none") + } + switch gjson.GetBytes(body, "tool_choice.type").String() { + case "any", "tool", "function", "custom", "namespace": + return fmt.Errorf("claude-opus-5-5 does not support forced tool_choice; use auto or none") + } + return nil +} + // FilterThinkingBlocks removes thinking blocks from request body // Returns filtered body or original body if filtering fails (fail-safe) // This prevents 400 errors from invalid thinking block signatures. @@ -584,7 +604,7 @@ func FilterThinkingBlocks(body []byte, mappedModel string) []byte { if !ShouldPreFilterThinkingBlocks(mappedModel) { return body } - return filterThinkingBlocksInternal(body, false) + return filterThinkingBlocksInternal(body, claude.IsOpus55(mappedModel)) } // FilterThinkingBlocksForRetry strips thinking-related constructs for retry scenarios. @@ -1321,7 +1341,7 @@ func FilterSignatureSensitiveBlocksForRetry(body []byte, mappedModel string) []b // 策略: // - 当 thinking.type 不是 "enabled"/"adaptive":移除所有 thinking 相关块 // - 当 thinking.type 是 "enabled"/"adaptive":仅移除缺失/无效 signature 的 thinking 块 -func filterThinkingBlocksInternal(body []byte, _ bool) []byte { +func filterThinkingBlocksInternal(body []byte, alwaysThinking bool) []byte { // Fast path: if body doesn't contain "thinking", skip parsing if !bytes.Contains(body, []byte(`"type":"thinking"`)) && !bytes.Contains(body, []byte(`"type": "thinking"`)) && @@ -1338,7 +1358,7 @@ func filterThinkingBlocksInternal(body []byte, _ bool) []byte { } // Check if thinking is enabled - thinkingEnabled := false + thinkingEnabled := alwaysThinking if thinking, ok := req["thinking"].(map[string]any); ok { if thinkType, ok := thinking["type"].(string); ok && (thinkType == "enabled" || thinkType == "adaptive") { thinkingEnabled = true @@ -1379,6 +1399,12 @@ func filterThinkingBlocksInternal(body []byte, _ bool) []byte { // When thinking is enabled and this is an assistant message, // only keep thinking blocks with valid signatures if thinkingEnabled && role == "assistant" { + if alwaysThinking && blockType == "redacted_thinking" { + if data, ok := blockMap["data"].(string); ok && data != "" { + newContent = append(newContent, block) + continue + } + } signature, _ := blockMap["signature"].(string) if signature != "" && signature != antigravity.DummyThoughtSignature { newContent = append(newContent, block) diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index 4d8e74c162..ff2438a615 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -84,8 +84,13 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } } + // 一致性铁律:同一次请求内只取一次 mimic UA,出站 User-Agent 头与 + // 请求体 x-anthropic-billing-header 的 cc_version 都源自这一个字符串, + // 避免运行期版本缓存翻转瞬间头/体版本自相矛盾(会被判非正版客户端)。 + mimicUserAgent := claude.DefaultUserAgent() + // Mimicry may override the cached User-Agent later, even without a fingerprint. - if billingUA := effectiveBillingUserAgent(tokenType, mimicClaudeCode, fingerprint); billingUA != "" { + if billingUA := effectiveBillingUserAgent(mimicUserAgent, tokenType, mimicClaudeCode, fingerprint); billingUA != "" { body = syncBillingHeaderVersion(body, billingUA) } @@ -172,7 +177,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // OAuth + mimic Claude Code:强制注入 CLI 指纹相关 header // (user-agent/x-stainless-*/x-app/Accept/x-stainless-helper-method/x-client-request-id) if tokenType == "oauth" && mimicClaudeCode { - applyClaudeCodeMimicHeaders(req, reqStream) + applyClaudeCodeMimicHeaders(req, reqStream, mimicUserAgent) } // 写入最终 anthropic-beta header @@ -417,7 +422,7 @@ func applyClaudeOAuthHeaderDefaults(req *http.Request) { if getHeaderRaw(req.Header, "Accept") == "" { setHeaderRaw(req.Header, "Accept", "application/json") } - for key, value := range claude.DefaultHeaders { + for key, value := range claude.DefaultHeaders() { if value == "" { continue } @@ -854,7 +859,9 @@ var defaultDroppedBetasSet = buildBetaTokenSet(claude.DroppedBetas) // applyClaudeCodeMimicHeaders forces "Claude Code-like" request headers. // This mirrors opencode-anthropic-auth behavior: do not trust downstream // headers when using Claude Code-scoped OAuth credentials. -func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) { +// mimicUserAgent 由调用方在同一请求内取一次传入,保证出站 User-Agent 头与 +// 请求体 billing attribution 的 cc_version 版本号严格一致。 +func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool, mimicUserAgent string) { if req == nil { return } @@ -862,10 +869,14 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) { applyClaudeOAuthHeaderDefaults(req) // Then force key headers to match Claude Code fingerprint regardless of what the client sent. // 使用 resolveWireCasing 确保 key 与真实 wire format 一致(如 "x-app" 而非 "X-App") - for key, value := range claude.DefaultHeaders { + for key, value := range claude.DefaultHeaders() { if value == "" { continue } + if key == "User-Agent" { + // 版本号与 billing 路径共用同一字符串(见 mimicUserAgent 注释)。 + value = mimicUserAgent + } setHeaderRaw(req.Header, resolveWireCasing(key), value) } // Real Claude CLI uses Accept: application/json (even for streaming). diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index 5b1307b40e..6aaaaf4b16 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -362,8 +362,9 @@ func (s *GatewayService) readUpstreamErrorBody(resp *http.Response) ([]byte, err } func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, requestedModel ...string) (*ForwardResult, error) { - // Upstream returned a non-success HTTP status; count Ollama Cloud activity. + // Upstream returned a non-success HTTP status; count Ollama Cloud / OpenCode Go activity. scheduleOllamaCloudUsageActivity(s.deferredService, account) + scheduleOpenCodeGoUsageActivity(s.deferredService, account) body, readErr := s.readUpstreamErrorBody(resp) if readErr != nil { // 读取失败时 body 可能被截断,错误分类会基于不完整数据;记录日志以便排查, diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index d117f720cc..d5b58d6f45 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -72,15 +72,17 @@ func isAcceptableFingerprintUserAgent(ua string) bool { if !ok { return false } - currentMajor, _, _, currentOK := parseUserAgentVersion(claudeCLIUserAgentProduct + "/" + claude.CLIVersion()) + // 超前检查以运行期生效版本为基准(EffectiveCLIVersion):同步跨大版本后, + // 若仍与静态基线比较会误拒客户端上报的新版本。 + currentMajor, _, _, currentOK := parseUserAgentVersion(claudeCLIUserAgentProduct + "/" + claude.EffectiveCLIVersion()) if !currentOK { return true } return major <= currentMajor+maxClaudeCLIMajorVersionSkew } -// floorClaudeCLIUserAgentVersion 把 claude-cli UA 中的版本号抬升到 claude.CLICurrentVersion -// 下限:低于下限时就地升到下限并返回 changed=true,否则原样返回。 +// floorClaudeCLIUserAgentVersion 把 claude-cli UA 中的版本号抬升到运行期生效版本 +// (claude.EffectiveCLIVersion())下限:低于下限时就地升到下限并返回 changed=true,否则原样返回。 // // 为什么需要这条:账号级指纹"只升不降"、活跃账号懒续期后近乎永不过期,且系统内没有重置 // 入口,defaultFingerprint 只在首次创建指纹时使用。存量账号缓存里的 claude-cli 版本停留在 @@ -98,28 +100,36 @@ func floorClaudeCLIUserAgentVersion(ua string) (string, bool) { if extractProduct(ua) != claudeCLIUserAgentProduct { return ua, false } - floorUA := claudeCLIUserAgentProduct + "/" + claude.CLICurrentVersion + // 用 EffectiveCLIVersion()(面板/同步的运行期值,经 IsSupportedCLIVersion + // 校验、恒 >= 内置基线)而非静态基线 CLICurrentVersion: + // 运行期同步到更高版本后,存量低版本指纹才能被抬到新版本;"只升不降"语义不变。 + floor := claude.EffectiveCLIVersion() + floorUA := claudeCLIUserAgentProduct + "/" + floor // isNewerVersion(floor, ua) 为 true 当且仅当下限版本严格高于 ua: // ua 等于或高于下限、产品名不一致、或版本无法解析时都不做改动。 if !isNewerVersion(floorUA, ua) { return ua, false } - floored := claudeCLIUAVersionPrefixRegex.ReplaceAllString(ua, "${1}/"+claude.CLICurrentVersion) + floored := claudeCLIUAVersionPrefixRegex.ReplaceAllString(ua, "${1}/"+floor) if floored == ua { return ua, false } return floored, true } -// 默认指纹值(当客户端未提供时使用) -var defaultFingerprint = Fingerprint{ - UserAgent: "claude-cli/" + claude.CLIVersion() + " (external, cli)", - StainlessLang: "js", - StainlessPackageVersion: "0.94.0", - StainlessOS: "Linux", - StainlessArch: "arm64", - StainlessRuntime: "node", - StainlessRuntimeVersion: "v24.3.0", +// defaultFingerprint 返回默认指纹值(当客户端未提供时使用)。 +// UserAgent 不能在包 init 时固化:必须在每次使用时经 EffectiveCLIVersion 现取, +// 否则运行期同步到新版本后,新建账号会写入过期版本作为持久身份。 +func defaultFingerprint() Fingerprint { + return Fingerprint{ + UserAgent: claude.DefaultUserAgent(), + StainlessLang: "js", + StainlessPackageVersion: "0.94.0", + StainlessOS: "Linux", + StainlessArch: "arm64", + StainlessRuntime: "node", + StainlessRuntimeVersion: "v24.3.0", + } } // Fingerprint represents account fingerprint data @@ -187,7 +197,7 @@ func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID if uaAcceptable { mergeHeadersIntoFingerprint(cached, headers) } else { - cached.UserAgent = defaultFingerprint.UserAgent + cached.UserAgent = defaultFingerprint().UserAgent } needWrite = true logger.LegacyPrintf("service.identity", @@ -262,19 +272,20 @@ func (s *IdentityService) createFingerprintFromHeaders(headers http.Header) *Fin // 首次创建同样是持久化写入,必须与升级路径共用同一套校验。 if ua := strings.TrimSpace(headers.Get("User-Agent")); isAcceptableFingerprintUserAgent(ua) { // 首次创建与缓存命中路径共用同一个版本下限:合法但过旧的 claude-cli UA - // 落库时同样不能低于 CLICurrentVersion,否则新账号一开始就带着过旧的持久身份。 + // 落库时同样不能低于运行期生效版本(EffectiveCLIVersion),否则新账号一开始就带着过旧的持久身份。 fp.UserAgent, _ = floorClaudeCLIUserAgentVersion(ua) } else { - fp.UserAgent = defaultFingerprint.UserAgent + fp.UserAgent = defaultFingerprint().UserAgent } // 获取x-stainless-*头,如果没有则使用默认值 - fp.StainlessLang = getHeaderOrDefault(headers, "X-Stainless-Lang", defaultFingerprint.StainlessLang) - fp.StainlessPackageVersion = getHeaderOrDefault(headers, "X-Stainless-Package-Version", defaultFingerprint.StainlessPackageVersion) - fp.StainlessOS = getHeaderOrDefault(headers, "X-Stainless-OS", defaultFingerprint.StainlessOS) - fp.StainlessArch = getHeaderOrDefault(headers, "X-Stainless-Arch", defaultFingerprint.StainlessArch) - fp.StainlessRuntime = getHeaderOrDefault(headers, "X-Stainless-Runtime", defaultFingerprint.StainlessRuntime) - fp.StainlessRuntimeVersion = getHeaderOrDefault(headers, "X-Stainless-Runtime-Version", defaultFingerprint.StainlessRuntimeVersion) + df := defaultFingerprint() + fp.StainlessLang = getHeaderOrDefault(headers, "X-Stainless-Lang", df.StainlessLang) + fp.StainlessPackageVersion = getHeaderOrDefault(headers, "X-Stainless-Package-Version", df.StainlessPackageVersion) + fp.StainlessOS = getHeaderOrDefault(headers, "X-Stainless-OS", df.StainlessOS) + fp.StainlessArch = getHeaderOrDefault(headers, "X-Stainless-Arch", df.StainlessArch) + fp.StainlessRuntime = getHeaderOrDefault(headers, "X-Stainless-Runtime", df.StainlessRuntime) + fp.StainlessRuntimeVersion = getHeaderOrDefault(headers, "X-Stainless-Runtime-Version", df.StainlessRuntimeVersion) return fp } @@ -324,7 +335,7 @@ func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) { setHeaderRaw(req.Header, "User-Agent", fp.UserAgent) } - // 设置x-stainless-*头(保持与 claude.DefaultHeaders 一致的大小写) + // 设置x-stainless-*头(保持与 claude.DefaultHeaders() 一致的大小写) if fp.StainlessLang != "" { setHeaderRaw(req.Header, "X-Stainless-Lang", fp.StainlessLang) } diff --git a/backend/internal/service/identity_service_user_agent_validation_test.go b/backend/internal/service/identity_service_user_agent_validation_test.go index 1a52d12554..51cf70fd85 100644 --- a/backend/internal/service/identity_service_user_agent_validation_test.go +++ b/backend/internal/service/identity_service_user_agent_validation_test.go @@ -97,7 +97,7 @@ func TestGetOrCreateFingerprintRejectsMalformedUserAgentOnCreate(t *testing.T) { ) require.NoError(t, err) - require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent, + require.Equal(t, defaultFingerprint().UserAgent, fp.UserAgent, "畸形 UA 必须回退默认指纹,而不是被写成账号级持久身份") require.NotContains(t, cache.lastSet.UserAgent, "999.0.0") } @@ -160,8 +160,8 @@ func TestGetOrCreateFingerprintAcceptsValidUserAgentOnCreate(t *testing.T) { // 不变式:默认指纹自身必须能通过校验,否则自愈路径会在每次读取时反复重写。 func TestDefaultFingerprintUserAgentIsAcceptable(t *testing.T) { - require.True(t, isAcceptableFingerprintUserAgent(defaultFingerprint.UserAgent), - "defaultFingerprint.UserAgent 必须自洽,否则自愈会陷入反复重写") + require.True(t, isAcceptableFingerprintUserAgent(defaultFingerprint().UserAgent), + "defaultFingerprint().UserAgent 必须自洽,否则自愈会陷入反复重写") } // 存量自愈:本次加固之前写入的畸形指纹在读取时被纠正。 @@ -203,7 +203,7 @@ func TestGetOrCreateFingerprintHealsPoisonedCacheWithoutValidClientUA(t *testing ) require.NoError(t, err) - require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent) + require.Equal(t, defaultFingerprint().UserAgent, fp.UserAgent) require.Equal(t, 1, cache.setCalls) } @@ -235,5 +235,5 @@ func TestGetOrCreateFingerprintMissingUserAgentKeepsDefault(t *testing.T) { fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, http.Header{}) require.NoError(t, err) - require.Equal(t, defaultFingerprint.UserAgent, fp.UserAgent) + require.Equal(t, defaultFingerprint().UserAgent, fp.UserAgent) } diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index 1e0303a7d1..cc8b51cfd4 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -99,6 +99,7 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont // Any non-2xx upstream HTTP response means the model request was actually sent. if s != nil { scheduleOllamaCloudUsageActivity(s.deferredService, account) + scheduleOpenCodeGoUsageActivity(s.deferredService, account) } // Capacity shedding describes this request, not account health. Keep the // account schedulable while the request-local retry budget handles recovery. diff --git a/backend/internal/service/openai_account_scheduler_canonical_quota_test.go b/backend/internal/service/openai_account_scheduler_canonical_quota_test.go index 4422e4023c..a7cc8116dd 100644 --- a/backend/internal/service/openai_account_scheduler_canonical_quota_test.go +++ b/backend/internal/service/openai_account_scheduler_canonical_quota_test.go @@ -97,8 +97,7 @@ func TestOpenAISchedulerCanonicalResetScoresMatchSnapshot(t *testing.T) { scores := openAIPlanScores(plan) require.InDelta(t, 1, scores[1]-scores[2], 1e-9) - multiplier := float64(defaultOpenAIOAuthSchedulingRateMultiplier) - snapshots := buildOpenAIAccountSchedulerScoreSnapshot(accounts, nil, scheduler.service.openAIWSSchedulerWeights(), false, &multiplier) + snapshots := buildOpenAIAccountSchedulerScoreSnapshot(accounts, nil, scheduler.service.openAIWSSchedulerWeights(), false, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) for _, account := range accounts { require.InDelta(t, scores[account.ID], snapshots[account.ID].BaseScore, 1e-9) } diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 4a4f02cfea..f4b77f061e 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -486,6 +486,10 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr } if isClaudeCodexModel(modelID) { + if claude.IsOpus55(modelID) { + descriptor.ContextWindow = 1_000_000 + descriptor.MaxContextWindow = 1_000_000 + } descriptor.DisplayName = claudeCodexDisplayName(modelID) descriptor.Description = "Claude coding and reasoning model routed through Sub2API." descriptor.SupportsParallelToolCalls = true @@ -510,7 +514,9 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr descriptor.SupportedReasoningLevels = configuredCodexGPTReasoningLevels(modelID) descriptor.DefaultReasoningSummary = "none" descriptor.TruncationPolicy = configuredCodexTruncationPolicy{Mode: "tokens", Limit: configuredCodexToolOutputMaxTokens} - if isOpenAIGPT56Model(modelID) { + // GPT-6 Sol/Luna retain the existing 5.6 Codex window as an offline + // compatibility template; live account metadata remains authoritative. + if isOpenAIGPT56Model(modelID) || openai.IsGPT6SolOrLunaModelSpelling(modelID) { descriptor.MaxContextWindow = configuredCodexGPT56MaxContext } if isOpenAIGPT6AstraModel(modelID) { @@ -559,8 +565,8 @@ func configuredCodexSupportsPriorityServiceTier(modelID string) bool { return true } } - // GPT-6 Astra advertises Fast via service_tier=priority in public model metadata. - return isOpenAIGPT6AstraModel(modelID) + // GPT-6 models advertise Fast via service_tier=priority in public model metadata. + return isOpenAIGPT6Model(modelID) } func configuredCodexSupportsUltrafastServiceTier(modelID string) bool { @@ -623,7 +629,7 @@ func configuredCodexGPTReasoningLevels(modelID string) []configuredCodexReasonin {Effort: "xhigh", Description: "Extra-high reasoning depth for difficult tasks"}, } normalized := getNormalizedCodexModel(modelID) - if isOpenAIGPT56Model(modelID) || isOpenAIGPT6AstraModel(modelID) { + if isOpenAIGPT56Model(modelID) || isOpenAIGPT6Model(modelID) { levels = append(levels, configuredCodexReasoningLevel{ Effort: "max", Description: "Maximum reasoning depth for complex tasks", @@ -689,12 +695,12 @@ func isOpenAICodexGPTModel(modelID string) bool { func isOpenAICodexReasoningGPTModel(modelID string) bool { normalized := canonicalizeOpenAIModelAliasSpelling(modelID) - return isOpenAIGPT6AstraModel(normalized) || strings.HasPrefix(normalized, "gpt-5") + return isOpenAIGPT6Model(normalized) || strings.HasPrefix(normalized, "gpt-5") } func isOpenAICodexImageInputModel(modelID string) bool { normalized := canonicalizeOpenAIModelAliasSpelling(modelID) - return isOpenAIGPT6AstraModel(normalized) || + return isOpenAIGPT6Model(normalized) || strings.HasPrefix(normalized, "gpt-5") || strings.HasPrefix(normalized, "gpt-4o") || strings.HasPrefix(normalized, "gpt-4.1") || diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 7149dbf74e..d2ce58a038 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -3760,3 +3760,23 @@ func TestCodexGPTIdentityPatternsCoverBundledPrompts(t *testing.T) { require.True(t, strings.HasPrefix(tmpl, "You are Codex"), "%s: %.60q", model, tmpl) } } + +func TestGPT6SolLunaCatalogKeepsAuthoritativeCapabilities(t *testing.T) { + for _, id := range []string{"gpt-6-sol", "gpt-6-luna"} { + svc := &OpenAIGatewayService{} + manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[{"slug":"` + id + `","supported_reasoning_levels":[{"effort":"ultra"}],"default_reasoning_level":"ultra","multi_agent_reasoning_effort":"xhigh","service_tiers":[{"id":"ultrafast"}],"context_window":300000,"max_context_window":900000,"supports_search_tool":false,"apply_patch_tool_type":null}]}`)} + account := newCodexModelsAPIKeyTestAccount("https://api.openai.com/v1") + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + require.Equal(t, []string{"ultra"}, effortsFromManifestModel(t, models[0])) + require.Equal(t, "ultra", models[0]["default_reasoning_level"]) + require.Equal(t, "xhigh", models[0]["multi_agent_reasoning_effort"]) + require.Equal(t, float64(300000), models[0]["context_window"]) + require.Equal(t, float64(900000), models[0]["max_context_window"]) + require.Equal(t, []any{map[string]any{"id": "ultrafast"}}, models[0]["service_tiers"]) + require.Equal(t, false, models[0]["supports_search_tool"]) + require.Contains(t, models[0], "apply_patch_tool_type") + require.Nil(t, models[0]["apply_patch_tool_type"]) + } +} diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 8feac0843f..bd49f8fcaa 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -11,6 +11,8 @@ import ( ) var codexModelMap = map[string]string{ + "gpt-6-sol": "gpt-6-sol", + "gpt-6-luna": "gpt-6-luna", "gpt-6-astra": "gpt-6-astra", "gpt-5.6-sol": "gpt-5.6-sol", "gpt-5.6-terra": "gpt-5.6-terra", @@ -60,6 +62,8 @@ var codexVersionModelPrefixes = []struct { prefix string target string }{ + {prefix: "gpt-6-sol", target: "gpt-6-sol"}, + {prefix: "gpt-6-luna", target: "gpt-6-luna"}, {prefix: "gpt-5.6-sol", target: "gpt-5.6-sol"}, {prefix: "gpt-5.6-terra", target: "gpt-5.6-terra"}, {prefix: "gpt-5.6-luna", target: "gpt-5.6-luna"}, diff --git a/backend/internal/service/openai_compat_prompt_cache_key.go b/backend/internal/service/openai_compat_prompt_cache_key.go index 3d3595d3c2..99ae309f9a 100644 --- a/backend/internal/service/openai_compat_prompt_cache_key.go +++ b/backend/internal/service/openai_compat_prompt_cache_key.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" ) const compatPromptCacheKeyPrefix = "compat_cc_" @@ -14,9 +15,8 @@ const compatPromptCacheKeyPrefix = "compat_cc_" func shouldAutoInjectPromptCacheKeyForCompat(model string) bool { trimmed := strings.TrimSpace(strings.ToLower(model)) canonical := canonicalizeOpenAIModelAliasSpelling(trimmed) - // GPT-6 is the public alias for Astra. Keep this deliberately scoped to - // Astra so other GPT-6 families do not inherit Messages compatibility state. - if canonical == "gpt-6" || canonical == "gpt-6-astra" { + // GPT-6 Sol/Luna share the supported Responses cache identity contract. + if canonical == "gpt-6" || canonical == "gpt-6-astra" || openai.IsGPT6SolOrLunaModelSpelling(canonical) { return true } // 仅对 Responses 兼容路径支持的 GPT-5 族开启自动注入,避免 normalizeCodexModel diff --git a/backend/internal/service/openai_compat_prompt_cache_key_test.go b/backend/internal/service/openai_compat_prompt_cache_key_test.go index d486857662..776c68f6ad 100644 --- a/backend/internal/service/openai_compat_prompt_cache_key_test.go +++ b/backend/internal/service/openai_compat_prompt_cache_key_test.go @@ -164,3 +164,12 @@ func TestDeriveAnthropicCompatPromptCacheKey_UsesCacheControlAnchors(t *testing. require.True(t, strings.HasPrefix(k1, "anthropic-cache-")) require.False(t, strings.HasPrefix(k1, compatPromptCacheKeyPrefix)) } + +func TestGPT6SolLunaCompatCacheIdentity(t *testing.T) { + for _, model := range []string{"gpt-6-sol", "openai/gpt-6-luna", "gpt-6-sol-max"} { + require.True(t, shouldAutoInjectPromptCacheKeyForCompat(model), model) + } + for _, model := range []string{"gpt-6-sol-preview", "gpt-6-solitude", "gpt-6-luna-preview"} { + require.False(t, shouldAutoInjectPromptCacheKeyForCompat(model), model) + } +} diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index cd043db85d..db249da487 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -266,6 +266,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( } else { // Normal path: convert Chat Completions → Responses. // ChatCompletionsToResponses always sets Stream=true (upstream always streams). + chatReq.Model = upstreamModel responsesReq, err = apicompat.ChatCompletionsToResponses(&chatReq) if err != nil { return nil, fmt.Errorf("convert chat completions to responses: %w", err) @@ -347,6 +348,10 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( } // 4b. Apply OpenAI fast policy (may filter service_tier or block the request). + responsesBody, _, err = normalizeGPT6ResponsesSampling(responsesBody, upstreamModel) + if err != nil { + return nil, err + } updatedBody, policyErr := s.applyOpenAIFastPolicyToBody(ctx, account, upstreamModel, responsesBody) if policyErr != nil { var blocked *OpenAIFastBlockedError diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go index bc029bf480..4019530426 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -63,16 +63,19 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "Failed to convert request") return nil, fmt.Errorf("convert chat completions to responses: %w", err) } - anthropicReq, err := apicompat.ResponsesToAnthropicRequest(responsesReq) - if err != nil { - writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "Failed to convert request") - return nil, fmt.Errorf("convert responses to anthropic: %w", err) - } - - // 3. Model mapping(OpenAI 网关统一入口的映射语义) + // Resolve the mapped model before choosing its thinking/tool protocol. billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) - anthropicReq.Model = upstreamModel + if err := validateClaudeOpus55Request(body, upstreamModel); err != nil { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } + responsesReq.Model = upstreamModel + anthropicReq, err := apicompat.ResponsesToAnthropicRequest(responsesReq) + if err != nil { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, fmt.Errorf("convert responses to anthropic: %w", err) + } // 4. Force upstream streaming(客户端原始终决定响应格式; // 上游恒为流式,非流式由缓冲路径组装)。 diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index c92528f2d7..ba5f7e7c7a 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -9,6 +9,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" @@ -80,6 +81,11 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( // anchored to the client's stable conversation prefix. grokCacheIdentity = resolveGrokCacheIdentity(c, body, "", upstreamModel) } + if openai.IsGPT6SolOrLunaModelSpelling(upstreamModel) && (len(gjson.GetBytes(body, "tools").Array()) > 0 || len(gjson.GetBytes(body, "functions").Array()) > 0) && gjson.GetBytes(body, "reasoning_effort").String() != "none" { + err := fmt.Errorf("%s requires Responses for tool calls with reasoning; this account only supports Chat Completions. Use reasoning_effort=none or a Responses-capable account", upstreamModel) + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } // 3. Rewrite model in body (no protocol conversion) upstreamBody := body if upstreamModel != originalModel { diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index 35f6de0ed1..e26306a900 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -1136,3 +1136,116 @@ func TestBuildChatStreamErrorSSE(t *testing.T) { require.Equal(t, "cyber_policy", gjson.Get(payload, "error.code").String()) require.Equal(t, "blocked by policy", gjson.Get(payload, "error.message").String()) } + +func TestGPT6RawChatRejectsReasoningToolCalls(t *testing.T) { + for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"model_mapping": map[string]any{"public": model}}} + svc := &OpenAIGatewayService{} + _, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, []byte(`{"model":"public","messages":[{"role":"user","content":"hello"}],"tools":[{"type":"function","function":{"name":"lookup"}}]}`), "") + require.ErrorContains(t, err, "requires Responses") + require.Equal(t, 400, rec.Code) + } +} + +func TestGPT6ReasoningModeAndSamplingCompatibility(t *testing.T) { + for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { + body := []byte(`{"model":"` + model + `","reasoning":{"mode":"pro","effort":"max"},"temperature":0.7,"top_p":0.9,"top_logprobs":2,"include":["reasoning.encrypted_content","message.output_text.logprobs"],"prompt_cache_options":{"ttl":"30m"}}`) + out, changed, err := normalizeOpenAIResponsesReasoningMode(body, "") + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "pro", gjson.GetBytes(out, "reasoning.mode").String()) + require.Equal(t, "max", gjson.GetBytes(out, "reasoning.effort").String()) + require.False(t, gjson.GetBytes(out, "temperature").Exists()) + require.False(t, gjson.GetBytes(out, "top_p").Exists()) + require.False(t, gjson.GetBytes(out, "top_logprobs").Exists()) + require.Equal(t, "reasoning.encrypted_content", gjson.GetBytes(out, "include.0").String()) + require.Equal(t, "30m", gjson.GetBytes(out, "prompt_cache_options.ttl").String()) + require.Equal(t, "max", normalizeOpenAIReasoningEffortForModel("max", model)) + d := newConfiguredCodexModelDescriptor(model) + require.NotNil(t, d.DefaultReasoningLevel) + require.Equal(t, "medium", *d.DefaultReasoningLevel) + require.EqualValues(t, 872000, d.MaxContextWindow) + require.Len(t, d.SupportedReasoningLevels, 5) + require.Len(t, d.ServiceTiers, 1) + } +} + +func TestGPT6RawChatNoneToolsAreForwarded(t *testing.T) { + for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"` + model + `","reasoning_effort":"none","messages":[{"role":"user","content":"hello"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}]}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"id":"chat_1","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1000,"completion_tokens":10,"prompt_tokens_details":{"cached_tokens":200,"cache_write_tokens":300}}}`))}} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1, Credentials: map[string]any{"api_key": "fixture-key", "base_url": "https://api.openai.com"}} + result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "none", gjson.GetBytes(upstream.lastBody, "reasoning_effort").String()) + require.Len(t, gjson.GetBytes(upstream.lastBody, "tools").Array(), 1) + require.Equal(t, 1000, result.Usage.InputTokens) + require.Equal(t, 300, result.Usage.CacheCreationInputTokens) + } +} + +func TestGPT6UsageHTTPAndWSHaveSameCacheBreakdown(t *testing.T) { + response := []byte(`{"model":"gpt-6-sol","usage":{"input_tokens":1000,"output_tokens":10,"input_tokens_details":{"cached_tokens":200,"cache_write_tokens":300}}}`) + httpUsage, ok := extractOpenAIUsageFromJSONBytes(response) + require.True(t, ok) + var wsUsage OpenAIUsage + parseOpenAIWSResponseUsageFromCompletedEvent(append(append([]byte(`{"type":"response.completed","response":`), response...), '}'), &wsUsage) + require.Equal(t, httpUsage, wsUsage) + require.Equal(t, 500, httpUsage.InputTokens-httpUsage.CacheReadInputTokens-httpUsage.CacheCreationInputTokens) +} + +func TestGPT6ReasoningModeUsesMappedUpstream(t *testing.T) { + body := []byte(`{"model":"public-model","reasoning":{"mode":"pro","effort":"max"},"temperature":0.5}`) + out, _, err := normalizeOpenAIResponsesReasoningMode(body, "gpt-6-sol") + require.NoError(t, err) + require.Equal(t, "public-model", gjson.GetBytes(out, "model").String()) + require.Equal(t, "pro", gjson.GetBytes(out, "reasoning.mode").String()) + require.Equal(t, "max", gjson.GetBytes(out, "reasoning.effort").String()) + require.False(t, gjson.GetBytes(out, "temperature").Exists()) +} + +func TestGPT6MappedCompatibilityBridgesKeepReasoningAndTools(t *testing.T) { + for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} { + for _, messages := range []bool{false, true} { + body := []byte(`{"model":"public","reasoning_effort":"max","temperature":0.7,"top_p":0.9,"prompt_cache_options":{"ttl":"30m"},"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}],"messages":[{"role":"user","content":"hello"}]}`) + if messages { + body = []byte(`{"model":"public","max_tokens":1000,"output_config":{"effort":"max"},"temperature":0.7,"top_p":0.9,"tools":[{"name":"lookup","input_schema":{"type":"object"}}],"messages":[{"role":"user","content":"hello"}]}`) + } + response := `data: {"type":"response.completed","response":{"id":"resp_1","model":"` + model + `","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":1000,"output_tokens":10,"input_tokens_details":{"cached_tokens":200,"cache_write_tokens":300}}}}` + "\n\n" + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(response))}} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1, Credentials: map[string]any{"api_key": "fixture-key", "base_url": "https://api.openai.com", "model_mapping": map[string]any{"public": model}}} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + var result *OpenAIForwardResult + var err error + if messages { + result, err = svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + } else { + result, err = svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + } + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.Equal(t, "/v1/responses", upstream.lastReq.URL.Path) + require.Equal(t, model, gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) + require.Len(t, gjson.GetBytes(upstream.lastBody, "tools").Array(), 1) + require.False(t, gjson.GetBytes(upstream.lastBody, "temperature").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "top_p").Exists()) + if !messages { + require.Equal(t, "30m", gjson.GetBytes(upstream.lastBody, "prompt_cache_options.ttl").String()) + } + require.Equal(t, 300, result.Usage.CacheCreationInputTokens) + } + } +} diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 7f4e354f22..51b7f223bd 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -81,7 +81,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco body = sanitizedToolBody } if account.IsOpenAIOAuthLike() { - reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(body) + reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(body, account.GetMappedModel(gjson.GetBytes(body, "model").String())) if reasoningErr != nil { return nil, fmt.Errorf("normalize OpenAI Responses reasoning.mode: %w", reasoningErr) } diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 79de86b45b..277645fa9c 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -147,6 +147,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( } // 3. Convert Anthropic → Responses after compatibility-only replay guard. + anthropicReq.Model = upstreamModel responsesReq, err := apicompat.AnthropicToResponses(&anthropicReq) if err != nil { return nil, fmt.Errorf("convert anthropic to responses: %w", err) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 608d8d1ff7..66c852ab13 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "net/http" "strings" "unicode/utf8" @@ -1331,13 +1332,52 @@ func normalizeOpenAIOAuthResponsesCompatibilityBody(body []byte) ([]byte, bool, return normalized, changed, nil } -func normalizeOpenAIResponsesReasoningMode(body []byte) ([]byte, bool, error) { +func normalizeGPT6ResponsesSampling(body []byte, model string) ([]byte, bool, error) { + if !openai.IsGPT6SolOrLunaModelSpelling(model) || gjson.GetBytes(body, "reasoning.effort").String() == "none" { + return body, false, nil + } + out := body + changed := false + for _, key := range []string{"temperature", "top_p", "top_logprobs", "logprobs"} { + if !gjson.GetBytes(out, key).Exists() { + continue + } + var err error + out, err = sjson.DeleteBytes(out, key) + if err != nil { + return body, false, fmt.Errorf("remove GPT-6 sampling parameter %s: %w", key, err) + } + changed = true + } + if include := gjson.GetBytes(out, "include"); include.IsArray() { + items := include.Array() + for i := len(items) - 1; i >= 0; i-- { + if items[i].String() != "message.output_text.logprobs" { + continue + } + var err error + out, err = sjson.DeleteBytes(out, fmt.Sprintf("include.%d", i)) + if err != nil { + return body, false, fmt.Errorf("remove GPT-6 logprobs include: %w", err) + } + changed = true + } + } + return out, changed, nil +} + +func normalizeOpenAIResponsesReasoningMode(body []byte, model string) ([]byte, bool, error) { if len(body) == 0 { return body, false, nil } - // Astra 的 reasoning.mode 与 reasoning.effort 是独立参数,不做兼容替换;非 Astra 维持旧 strip-mode/pro->max 行为。 - if isOpenAIGPT6AstraModel(gjson.GetBytes(body, "model").String()) { - return body, false, nil + // GPT-6 treats reasoning.mode and reasoning.effort as independent + // official fields. Preserve both verbatim; earlier models retain the + // established mode stripping and pro-to-max compatibility behavior. + if model == "" { + model = gjson.GetBytes(body, "model").String() + } + if isOpenAIGPT6Model(model) { + return normalizeGPT6ResponsesSampling(body, model) } mode := gjson.GetBytes(body, "reasoning.mode") if !mode.Exists() || mode.Type != gjson.String { @@ -1429,7 +1469,7 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac changed = true } if account != nil && account.IsOpenAI() && account.IsOAuth() { - if reasoningBody, reasoningChanged, err := normalizeOpenAIResponsesReasoningMode(normalized); err != nil { + if reasoningBody, reasoningChanged, err := normalizeOpenAIResponsesReasoningMode(normalized, account.GetMappedModel(gjson.GetBytes(normalized, "model").String())); err != nil { return body, false, err } else if reasoningChanged { normalized = reasoningBody @@ -1535,7 +1575,7 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, boo if err != nil { return body, false, err } - if reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(normalized); reasoningErr != nil { + if reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(normalized, ""); reasoningErr != nil { return body, false, reasoningErr } else if reasoningChanged { normalized = reasoningBody @@ -2504,6 +2544,9 @@ func normalizeOpenAIReasoningEffort(raw string) string { } func normalizeOpenAIReasoningEffortForModel(raw, model string) string { + if strings.EqualFold(strings.TrimSpace(raw), "none") && openai.IsGPT6SolOrLunaModelSpelling(model) { + return "none" + } if strings.EqualFold(strings.TrimSpace(raw), "max") && supportsOpenAIReasoningEffortMax(model) { return "max" } @@ -2513,7 +2556,7 @@ func normalizeOpenAIReasoningEffortForModel(raw, model string) string { // supportsOpenAIReasoningEffortMax reports model families whose upstream scale // has a distinct max level. Other models keep the legacy max -> xhigh behavior. func supportsOpenAIReasoningEffortMax(model string) bool { - if isOpenAIGPT6AstraModel(model) || isOpenAIGPT56Model(model) { + if isOpenAIGPT6Model(model) || isOpenAIGPT56Model(model) { return true } diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go index 4295fa9544..dba295ad63 100644 --- a/backend/internal/service/openai_gateway_responses_anthropic_native.go +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -20,6 +20,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" @@ -64,16 +65,19 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic( clientStream := responsesReq.Stream // 3. Convert Responses → Anthropic - anthropicReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq) - if err != nil { - writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", "Failed to convert request") - return nil, fmt.Errorf("convert responses to anthropic: %w", err) - } - - // 4. Model mapping(OpenAI 网关统一入口的映射语义) + // Resolve the mapped model before choosing its thinking/tool protocol. billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) - anthropicReq.Model = upstreamModel + if err := validateClaudeOpus55Request(body, upstreamModel); err != nil { + writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } + responsesReq.Model = upstreamModel + anthropicReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq) + if err != nil { + writeResponsesError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, fmt.Errorf("convert responses to anthropic: %w", err) + } // 5. Force upstream streaming(客户端原始终决定响应格式; // 上游恒为流式,非流式由缓冲路径组装)。 @@ -245,6 +249,8 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( finalResp.Content[idx].Text += event.Delta.Text case "thinking_delta": finalResp.Content[idx].Thinking += event.Delta.Thinking + case "signature_delta": + finalResp.Content[idx].Signature += event.Delta.Signature case "input_json_delta": finalResp.Content[idx].Input = appendRawJSON(finalResp.Content[idx].Input, event.Delta.PartialJSON) } @@ -266,6 +272,9 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( } } + if claude.IsOpus55(upstreamModel) { + finalResp.Model = upstreamModel + } responsesResp := apicompat.AnthropicToResponsesResponse(finalResp) responsesResp.Model = originalModel @@ -324,6 +333,7 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic( state := apicompat.NewAnthropicEventToResponsesState() state.Model = originalModel + state.PreserveThinkingSignatures = claude.IsOpus55(upstreamModel) clientToolRestorer := apicompat.NewResponsesClientToolStreamRestorer(clientToolMapping) var usage ClaudeUsage diff --git a/backend/internal/service/openai_model_alias.go b/backend/internal/service/openai_model_alias.go index f34a28abac..b7c4e32ef0 100644 --- a/backend/internal/service/openai_model_alias.go +++ b/backend/internal/service/openai_model_alias.go @@ -37,6 +37,13 @@ func normalizeKnownOpenAICodexModel(model string) string { } } + if openai.IsGPT6SolOrLunaModelSpelling(normalized) { + if strings.HasPrefix(normalized, "gpt-6-sol") { + return "gpt-6-sol" + } + return "gpt-6-luna" + } + switch { case normalized == "gpt-6" || normalized == "gpt-6-astra": return "gpt-6-astra" @@ -151,3 +158,7 @@ func firstUsageBillingModel(candidates []string) string { } return "" } + +func isOpenAIGPT6Model(model string) bool { + return isOpenAIGPT6AstraModel(model) || openai.IsGPT6SolOrLunaModelSpelling(model) +} diff --git a/backend/internal/service/openai_passthrough_normalization_test.go b/backend/internal/service/openai_passthrough_normalization_test.go index a05bee25ad..f2071443ad 100644 --- a/backend/internal/service/openai_passthrough_normalization_test.go +++ b/backend/internal/service/openai_passthrough_normalization_test.go @@ -129,7 +129,7 @@ func TestNormalizeOpenAIResponsesReasoningMode(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body)) + normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body), "") require.NoError(t, err) require.True(t, changed) require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists()) @@ -153,7 +153,7 @@ func TestNormalizeOpenAIResponsesReasoningMode_AstraPreservesBody(t *testing.T) } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body)) + normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body), "") require.NoError(t, err) require.False(t, changed) require.JSONEq(t, tt.body, string(normalized)) @@ -173,7 +173,7 @@ func TestNormalizeOpenAIResponsesReasoningMode_NonAstraKeepsLegacyBehavior(t *te } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body)) + normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body), "") require.NoError(t, err) require.True(t, changed) require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists()) diff --git a/backend/internal/service/openai_upstream_transport_error.go b/backend/internal/service/openai_upstream_transport_error.go index 752b6624ae..d72685b3a9 100644 --- a/backend/internal/service/openai_upstream_transport_error.go +++ b/backend/internal/service/openai_upstream_transport_error.go @@ -126,9 +126,10 @@ func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Co return err } - // Transport attempt reached the network path; count as Ollama Cloud activity. + // Transport attempt reached the network path; count as Ollama Cloud / OpenCode Go activity. if s != nil { scheduleOllamaCloudUsageActivity(s.deferredService, account) + scheduleOpenCodeGoUsageActivity(s.deferredService, account) } // 插件已把请求交给上游时,自动切换账号可能造成重复扣费或重复执行。 diff --git a/backend/internal/service/opencode_go.go b/backend/internal/service/opencode_go.go index 5e2dcc78a9..1364179b01 100644 --- a/backend/internal/service/opencode_go.go +++ b/backend/internal/service/opencode_go.go @@ -322,10 +322,16 @@ func (a *Account) ResolveOpenCodeGoUpstreamProtocol(model string) string { } } +// openCodeGoQuotaURL 根据 base_url 解析 OpenCode Go 额度端点。 +// /zen/go/v1(Chat 协议默认,DefaultOpenCodeGoBaseURL)与 /zen/go +// (Anthropic 协议默认,DefaultOpenCodeGoAnthropicBaseURL)两种 base 统一 +// 剥掉尾部 /v1 后拼回 /v1/usage(实测 /zen/go/usage → 404),协议切换不 +// 影响额度探测端点。与 kimiQuotaURL 同一惯例。 func openCodeGoQuotaURL(baseURL string) string { base := strings.TrimRight(strings.TrimSpace(baseURL), "/") if base == "" { base = DefaultOpenCodeGoBaseURL } - return base + openCodeGoUsagePath + base = strings.TrimSuffix(base, "/v1") + return base + "/v1" + openCodeGoUsagePath } diff --git a/backend/internal/service/opencode_go_test.go b/backend/internal/service/opencode_go_test.go index 47e8db85ce..796487c952 100644 --- a/backend/internal/service/opencode_go_test.go +++ b/backend/internal/service/opencode_go_test.go @@ -205,9 +205,26 @@ func TestNormalizeOpenCodeGoProtocolRulesCredentials(t *testing.T) { func TestOpenCodeGoQuotaURL(t *testing.T) { t.Parallel() - require.Equal(t, "https://opencode.ai/zen/go/v1/usage", openCodeGoQuotaURL("")) - require.Equal(t, "https://opencode.ai/zen/go/v1/usage", openCodeGoQuotaURL(DefaultOpenCodeGoBaseURL+"/")) - require.Equal(t, "https://custom.example/v1/usage", openCodeGoQuotaURL("https://custom.example/v1")) + const want = "https://opencode.ai/zen/go/v1/usage" + cases := []struct { + name string + baseURL string + want string + }{ + {"empty falls back to default", "", want}, + {"chat base (/v1)", DefaultOpenCodeGoBaseURL, want}, + {"chat base trailing slash", DefaultOpenCodeGoBaseURL + "/", want}, + {"anthropic base (no /v1)", DefaultOpenCodeGoAnthropicBaseURL, want}, + {"anthropic base trailing slash", DefaultOpenCodeGoAnthropicBaseURL + "/", want}, + {"zen base stays idempotent", DefaultOpenCodeZenBaseURL, "https://opencode.ai/zen/v1/usage"}, + {"custom base with /v1", "https://custom.example/v1", "https://custom.example/v1/usage"}, + {"custom base with /v1 trailing slash", "https://custom.example/v1/", "https://custom.example/v1/usage"}, + // 与 kimiQuotaURL 同语义:无条件补 /v1,非官方域名同样归一。 + {"custom base without /v1", "https://custom.example", "https://custom.example/v1/usage"}, + } + for _, tc := range cases { + require.Equal(t, tc.want, openCodeGoQuotaURL(tc.baseURL), "case=%s", tc.name) + } } func TestDefaultOpenCodeGoModelIDsCoverDocumentedCatalog(t *testing.T) { diff --git a/backend/internal/service/opencode_go_usage.go b/backend/internal/service/opencode_go_usage.go new file mode 100644 index 0000000000..f0408a0f30 --- /dev/null +++ b/backend/internal/service/opencode_go_usage.go @@ -0,0 +1,1095 @@ +package service + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "math/rand/v2" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/google/uuid" + "golang.org/x/sync/errgroup" + "golang.org/x/sync/singleflight" +) + +const ( + OpenCodeGoUsageAutoRefreshExtraKey = "opencode_go_usage_auto_refresh" + OpenCodeGoUsageSnapshotExtraKey = "opencode_go_usage_snapshot" + + // OpenCodeGoUsageMinFetchInterval is the hard floor between two successful + // fetches of the same group, mirroring the floor nextOpenCodeGoUsageDelay + // applies to next_refresh_at. Activity may bring a refresh forward to this + // bound but never past it. Exported so the repository can apply the same + // floor inside the SQL due filter. + OpenCodeGoUsageMinFetchInterval = opencodeGoUsageMinIntervalMinutes * time.Minute + + opencodeGoUsageAPIURL = "https://opencode.ai/zen/go/v1/usage" + opencodeGoUsageDefaultIntervalMinutes = 15 + opencodeGoUsageMinIntervalMinutes = 5 + opencodeGoUsageMaxIntervalMinutes = 24 * 60 + opencodeGoUsageDefaultDebounceMinutes = 1 + opencodeGoUsageMinDebounceMinutes = 1 + opencodeGoUsageMaxDebounceMinutes = 60 + opencodeGoUsageCycleInterval = time.Minute + opencodeGoUsageManualRefreshInterval = 30 * time.Second + opencodeGoUsageRequestTimeout = 15 * time.Second + opencodeGoUsageMaxBodyBytes = 512 * 1024 + opencodeGoUsageMaxPerCycle = 20 + opencodeGoUsageConcurrency = 4 + opencodeGoUsageMaxDelay = 24 * time.Hour + opencodeGoUsageLeaderLockKey = "opencode:go:usage:leader" + opencodeGoUsageLeaderLockTTL = 2 * time.Minute +) + +var ( + ErrOpenCodeGoUsageUnavailable = infraerrors.ServiceUnavailable( + "OPENCODE_GO_USAGE_UNAVAILABLE", "OpenCode Go usage is unavailable", + ) + ErrOpenCodeGoUsageAccountInvalid = infraerrors.BadRequest( + "OPENCODE_GO_USAGE_ACCOUNT_INVALID", + "account must be an OpenCode Go platform subscription (go mode) account, or an API key account under openai/anthropic/kimi/zhipu/deepseek/minimax with base_url pointing at the official OpenCode Go base URLs", + ) + ErrOpenCodeGoUsageIdentityChanged = infraerrors.Conflict( + "OPENCODE_GO_USAGE_IDENTITY_CHANGED", "account identity or proxy changed during refresh; retry", + ) + ErrOpenCodeGoUsageRefreshRateLimited = infraerrors.TooManyRequests( + "OPENCODE_GO_USAGE_REFRESH_RATE_LIMITED", "OpenCode Go usage can be refreshed manually once every 30 seconds", + ) +) + +const ( + OpenCodeGoUsageStatusOK = "ok" + OpenCodeGoUsageStatusUnauthorized = "unauthorized" + OpenCodeGoUsageStatusFailed = "failed" +) + +// OpenCodeGoUsageSettings controls the opt-in request-driven refresh runner. +// +// IntervalMinutes is the max-wait bound: when model requests keep arriving and +// the trailing debounce keeps sliding, a refresh is forced after this long. +// DebounceMinutes is the quiet period after the latest request in a group. +type OpenCodeGoUsageSettings struct { + Enabled bool `json:"enabled"` + IntervalMinutes int `json:"interval_minutes"` // max wait while requests continue + DebounceMinutes int `json:"debounce_minutes"` // trailing quiet period after last request +} + +// OpenCodeGoUsageWindow is a narrow, sanitized view of one official usage window. +type OpenCodeGoUsageWindow struct { + Status string `json:"status"` + Percent float64 `json:"percent"` + ResetsAt time.Time `json:"resets_at"` +} + +// OpenCodeGoUsageData intentionally excludes raw upstream payload details. +type OpenCodeGoUsageData struct { + Rolling OpenCodeGoUsageWindow `json:"rolling"` + Weekly OpenCodeGoUsageWindow `json:"weekly"` + Monthly OpenCodeGoUsageWindow `json:"monthly"` +} + +// OpenCodeGoUsageSnapshot is the only usage observation persisted in account extra. +// +// NextRefreshAt remains a persisted compatibility field. For status=ok it is a +// max-wait horizon marker only; automatic success refreshes are driven by model +// request activity (group last_used_at + debounce/max-wait), not by this field +// alone. For failed/unauthorized snapshots it is the failure not-before time +// (Retry-After / exponential backoff) and is enforced as max(activityDue, NextRefreshAt). +type OpenCodeGoUsageSnapshot struct { + Status string `json:"status"` + Data *OpenCodeGoUsageData `json:"data,omitempty"` + FetchedAt *time.Time `json:"fetched_at,omitempty"` + LastAttemptAt time.Time `json:"last_attempt_at"` + NextRefreshAt time.Time `json:"next_refresh_at"` + FailureCount int `json:"failure_count,omitempty"` + HTTPStatus int `json:"http_status,omitempty"` + LastError string `json:"last_error,omitempty"` +} + +// OpenCodeGoUsageState is the dedicated DTO exposed to administrators. +type OpenCodeGoUsageState struct { + AccountID int64 `json:"account_id"` + Eligible bool `json:"eligible"` + AutoRefreshEnabled bool `json:"auto_refresh_enabled"` + Snapshot *OpenCodeGoUsageSnapshot `json:"snapshot,omitempty"` +} + +type openCodeGoUsageRepository interface { + ListOpenCodeGoUsageGroupAccounts(context.Context, []*Account) ([]Account, error) + SetOpenCodeGoUsageAutoRefresh(context.Context, *Account, bool) error + UpdateOpenCodeGoUsageSnapshot(context.Context, *Account, *OpenCodeGoUsageSnapshot) error + DisableOpenCodeGoUsageAutoRefresh(context.Context, *Account) error + ListDueOpenCodeGoUsageAccounts(context.Context, time.Time, time.Duration, time.Duration, int) ([]Account, error) +} + +// GetOpenCodeGoUsageSettings returns fail-safe defaults when the setting is absent. +func (s *SettingService) GetOpenCodeGoUsageSettings(ctx context.Context) (*OpenCodeGoUsageSettings, error) { + defaults := defaultOpenCodeGoUsageSettings() + if s == nil || s.settingRepo == nil { + return defaults, nil + } + raw, err := s.settingRepo.GetValue(ctx, SettingKeyOpenCodeGoUsageSettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return defaults, nil + } + return nil, fmt.Errorf("get OpenCode Go usage settings: %w", err) + } + if strings.TrimSpace(raw) == "" { + return defaults, nil + } + settings := *defaults + if err := json.Unmarshal([]byte(raw), &settings); err != nil { + return nil, fmt.Errorf("parse OpenCode Go usage settings: %w", err) + } + if settings.IntervalMinutes == 0 { + settings.IntervalMinutes = defaults.IntervalMinutes + } + if settings.DebounceMinutes == 0 { + settings.DebounceMinutes = defaults.DebounceMinutes + } + normalizeOpenCodeGoUsageSettings(&settings) + return &settings, nil +} + +func (s *SettingService) SetOpenCodeGoUsageSettings(ctx context.Context, settings *OpenCodeGoUsageSettings) error { + if s == nil || s.settingRepo == nil { + return ErrOpenCodeGoUsageUnavailable + } + if settings == nil { + return infraerrors.BadRequest("INVALID_OPENCODE_GO_USAGE_SETTINGS", "settings cannot be nil") + } + if settings.DebounceMinutes == 0 { + // Legacy clients that omit debounce_minutes keep the fail-safe default. + settings.DebounceMinutes = opencodeGoUsageDefaultDebounceMinutes + } + if settings.IntervalMinutes < opencodeGoUsageMinIntervalMinutes || settings.IntervalMinutes > opencodeGoUsageMaxIntervalMinutes { + return infraerrors.BadRequest( + "INVALID_OPENCODE_GO_USAGE_INTERVAL", + fmt.Sprintf("interval_minutes must be between %d and %d", opencodeGoUsageMinIntervalMinutes, opencodeGoUsageMaxIntervalMinutes), + ) + } + if settings.DebounceMinutes < opencodeGoUsageMinDebounceMinutes || settings.DebounceMinutes > opencodeGoUsageMaxDebounceMinutes { + return infraerrors.BadRequest( + "INVALID_OPENCODE_GO_USAGE_DEBOUNCE", + fmt.Sprintf("debounce_minutes must be between %d and %d", opencodeGoUsageMinDebounceMinutes, opencodeGoUsageMaxDebounceMinutes), + ) + } + // The due time is min(lastUsed+debounce, fetchedAt+maxWait). Once the debounce + // reaches the max wait the debounce term can never win, so the knob would be + // silently inert instead of doing what the operator asked for. + if settings.DebounceMinutes >= settings.IntervalMinutes { + return infraerrors.BadRequest( + "INVALID_OPENCODE_GO_USAGE_DEBOUNCE", + fmt.Sprintf("debounce_minutes (%d) must be less than interval_minutes (%d)", settings.DebounceMinutes, settings.IntervalMinutes), + ) + } + normalizeOpenCodeGoUsageSettings(settings) + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal OpenCode Go usage settings: %w", err) + } + return s.settingRepo.Set(ctx, SettingKeyOpenCodeGoUsageSettings, string(data)) +} + +func defaultOpenCodeGoUsageSettings() *OpenCodeGoUsageSettings { + return &OpenCodeGoUsageSettings{ + Enabled: false, + IntervalMinutes: opencodeGoUsageDefaultIntervalMinutes, + DebounceMinutes: opencodeGoUsageDefaultDebounceMinutes, + } +} + +func normalizeOpenCodeGoUsageSettings(settings *OpenCodeGoUsageSettings) { + if settings.IntervalMinutes < opencodeGoUsageMinIntervalMinutes { + settings.IntervalMinutes = opencodeGoUsageMinIntervalMinutes + } + if settings.IntervalMinutes > opencodeGoUsageMaxIntervalMinutes { + settings.IntervalMinutes = opencodeGoUsageMaxIntervalMinutes + } + if settings.DebounceMinutes <= 0 { + settings.DebounceMinutes = opencodeGoUsageDefaultDebounceMinutes + } + if settings.DebounceMinutes < opencodeGoUsageMinDebounceMinutes { + settings.DebounceMinutes = opencodeGoUsageMinDebounceMinutes + } + if settings.DebounceMinutes > opencodeGoUsageMaxDebounceMinutes { + settings.DebounceMinutes = opencodeGoUsageMaxDebounceMinutes + } +} + +func openCodeGoUsageDurations(settings *OpenCodeGoUsageSettings) (debounce, maxWait time.Duration) { + normalized := defaultOpenCodeGoUsageSettings() + if settings != nil { + *normalized = *settings + } + normalizeOpenCodeGoUsageSettings(normalized) + return time.Duration(normalized.DebounceMinutes) * time.Minute, + time.Duration(normalized.IntervalMinutes) * time.Minute +} + +// openCodeGoUsageIsAutoRefreshDue decides whether a configured auto-refresh +// group should fetch now. groupLastUsedAt must be MAX(last_used_at) across the +// exact api_key group so shared multi-account groups do not miss activity. +// +// Success: a request must be newer than fetched_at; dueAt = min(lastUsed+debounce, fetchedAt+maxWait). +// Failure: a request must be newer than last_attempt_at; activity due uses the same min formula, +// then dueAt = max(activityDue, next_refresh_at) so Retry-After / exponential backoff win. +// Missing or invalid snapshots fail open to a first fetch. +func openCodeGoUsageIsAutoRefreshDue( + snapshot *OpenCodeGoUsageSnapshot, + groupLastUsedAt *time.Time, + now time.Time, + debounce, maxWait time.Duration, +) bool { + dueAt, ok := openCodeGoUsageAutoRefreshDueAt(snapshot, groupLastUsedAt, debounce, maxWait) + if !ok { + return false + } + return !now.Before(dueAt) +} + +func openCodeGoUsageAutoRefreshDueAt( + snapshot *OpenCodeGoUsageSnapshot, + groupLastUsedAt *time.Time, + debounce, maxWait time.Duration, +) (time.Time, bool) { + if debounce <= 0 { + debounce = time.Duration(opencodeGoUsageDefaultDebounceMinutes) * time.Minute + } + if maxWait <= 0 { + maxWait = time.Duration(opencodeGoUsageDefaultIntervalMinutes) * time.Minute + } + if snapshot == nil { + return time.Time{}, true + } + switch snapshot.Status { + case OpenCodeGoUsageStatusOK: + if snapshot.FetchedAt == nil || snapshot.FetchedAt.IsZero() { + return time.Time{}, true + } + fetchedAt := snapshot.FetchedAt.UTC() + if groupLastUsedAt == nil || !groupLastUsedAt.After(fetchedAt) { + return time.Time{}, false + } + lastUsed := groupLastUsedAt.UTC() + dueAt := minTime(lastUsed.Add(debounce), fetchedAt.Add(maxWait)) + // Keep the pre-existing hard floor between successful fetches. The success + // path no longer consults next_refresh_at, which is where + // nextOpenCodeGoUsageDelay used to apply opencodeGoUsageMinIntervalMinutes; + // without this, request traffic spaced slightly wider than the debounce + // drives the group's outbound rate far above the previous minimum. + if floor := fetchedAt.Add(OpenCodeGoUsageMinFetchInterval); dueAt.Before(floor) { + return floor, true + } + return dueAt, true + case OpenCodeGoUsageStatusFailed, OpenCodeGoUsageStatusUnauthorized: + if snapshot.LastAttemptAt.IsZero() { + return time.Time{}, true + } + lastAttempt := snapshot.LastAttemptAt.UTC() + if groupLastUsedAt == nil || !groupLastUsedAt.After(lastAttempt) { + return time.Time{}, false + } + lastUsed := groupLastUsedAt.UTC() + activityDue := minTime(lastUsed.Add(debounce), lastAttempt.Add(maxWait)) + if !snapshot.NextRefreshAt.IsZero() && snapshot.NextRefreshAt.UTC().After(activityDue) { + return snapshot.NextRefreshAt.UTC(), true + } + return activityDue, true + default: + return time.Time{}, true + } +} + +// maxOpenCodeGoUsageGroupLastUsed returns the newest last_used_at among group members. +func maxOpenCodeGoUsageGroupLastUsed(accounts []Account) *time.Time { + var latest *time.Time + for i := range accounts { + candidate := accounts[i].LastUsedAt + if candidate == nil || candidate.IsZero() { + continue + } + if latest == nil || candidate.After(*latest) { + ts := candidate.UTC() + latest = &ts + } + } + return latest +} + +// scheduleOpenCodeGoUsageActivity records that an OpenCode Go API-key account +// actually attempted an upstream model request (including 429/5xx/transport errors). +// Local auth/validation failures must not call this. DeferredService dedupes writes. +func scheduleOpenCodeGoUsageActivity(deferred *DeferredService, account *Account) { + if deferred == nil || account == nil || !IsOpenCodeGoUsageAccount(account) { + return + } + deferred.ScheduleLastUsedUpdate(account.ID) +} + +// OpenCodeGoUsageService refreshes the official usage JSON without affecting routing state. +type OpenCodeGoUsageService struct { + accountRepo AccountRepository + httpUpstream HTTPUpstream + settingService *SettingService + + parentCtx context.Context + parentCancel context.CancelFunc + wg sync.WaitGroup + mu sync.Mutex + started bool + stopped bool + cycleMu sync.Mutex + refreshGroup singleflight.Group + refreshSlots chan struct{} + now func() time.Time + lockCache LeaderLockCache + db *sql.DB + instanceID string +} + +func NewOpenCodeGoUsageService( + accountRepo AccountRepository, + httpUpstream HTTPUpstream, + settingService *SettingService, +) *OpenCodeGoUsageService { + ctx, cancel := context.WithCancel(context.Background()) + return &OpenCodeGoUsageService{ + accountRepo: accountRepo, + httpUpstream: httpUpstream, + settingService: settingService, + parentCtx: ctx, + parentCancel: cancel, + refreshSlots: make(chan struct{}, opencodeGoUsageConcurrency), + now: time.Now, + instanceID: uuid.NewString(), + } +} + +func ProvideOpenCodeGoUsageService( + accountRepo AccountRepository, + httpUpstream HTTPUpstream, + settingService *SettingService, + lockCache LeaderLockCache, + db *sql.DB, +) *OpenCodeGoUsageService { + svc := NewOpenCodeGoUsageService(accountRepo, httpUpstream, settingService) + svc.lockCache = lockCache + svc.db = db + svc.Start() + return svc +} + +func (s *OpenCodeGoUsageService) Start() { + if s == nil { + return + } + s.mu.Lock() + if s.started || s.stopped { + s.mu.Unlock() + return + } + s.started = true + s.wg.Add(1) + s.mu.Unlock() + go s.runLoop() +} + +func (s *OpenCodeGoUsageService) Stop() { + if s == nil { + return + } + s.mu.Lock() + if s.stopped { + s.mu.Unlock() + return + } + s.stopped = true + s.parentCancel() + s.mu.Unlock() + s.wg.Wait() +} + +func (s *OpenCodeGoUsageService) runLoop() { + defer s.wg.Done() + _ = s.RunDue(s.parentCtx) + ticker := time.NewTicker(opencodeGoUsageCycleInterval) + defer ticker.Stop() + for { + select { + case <-s.parentCtx.Done(): + return + case <-ticker.C: + if err := s.RunDue(s.parentCtx); err != nil { + logger.LegacyPrintf("service.opencode_go_usage", "run_due_failed: err=%v", err) + } + } + } +} + +func (s *OpenCodeGoUsageService) GetSettings(ctx context.Context) (*OpenCodeGoUsageSettings, error) { + if s == nil || s.settingService == nil { + return defaultOpenCodeGoUsageSettings(), nil + } + return s.settingService.GetOpenCodeGoUsageSettings(ctx) +} + +func (s *OpenCodeGoUsageService) UpdateSettings(ctx context.Context, settings *OpenCodeGoUsageSettings) error { + if s == nil || s.settingService == nil { + return ErrOpenCodeGoUsageUnavailable + } + return s.settingService.SetOpenCodeGoUsageSettings(ctx, settings) +} + +func (s *OpenCodeGoUsageService) GetState(ctx context.Context, accountID int64) (*OpenCodeGoUsageState, error) { + if s == nil || s.accountRepo == nil { + return nil, ErrOpenCodeGoUsageUnavailable + } + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil { + return nil, err + } + if err := s.ResolveOpenCodeGoUsageAccounts(ctx, []*Account{account}); err != nil { + return nil, err + } + return OpenCodeGoUsageStateFromAccount(account), nil +} + +// ResolveOpenCodeGoUsageAccounts overlays group-owned managed state onto the +// supplied account objects. The repository resolves all matching siblings in one +// bounded query, so account-list responses do not issue one query per row. +func (s *OpenCodeGoUsageService) ResolveOpenCodeGoUsageAccounts(ctx context.Context, accounts []*Account) error { + if s == nil || s.accountRepo == nil || len(accounts) == 0 { + return nil + } + writer, ok := s.accountRepo.(openCodeGoUsageRepository) + if !ok { + return nil + } + eligible := make([]*Account, 0, len(accounts)) + for _, account := range accounts { + if _, ok := openCodeGoUsageGroupFingerprint(account); ok { + eligible = append(eligible, account) + } + } + if len(eligible) == 0 { + return nil + } + siblings, err := writer.ListOpenCodeGoUsageGroupAccounts(ctx, eligible) + if err != nil { + return fmt.Errorf("resolve OpenCode Go usage groups: %w", err) + } + sources := make(map[string]*Account) + for index := range siblings { + candidate := &siblings[index] + fingerprint, valid := openCodeGoUsageGroupFingerprint(candidate) + if !valid { + continue + } + current := sources[fingerprint] + if current == nil || candidate.UpdatedAt.After(current.UpdatedAt) || + (candidate.UpdatedAt.Equal(current.UpdatedAt) && candidate.ID < current.ID) { + sources[fingerprint] = candidate + } + } + resolvedSources := make(map[string]*Account, len(sources)) + for fingerprint, source := range sources { + clone := *source + clone.Extra = make(map[string]any, len(source.Extra)) + maps.Copy(clone.Extra, source.Extra) + resolvedSources[fingerprint] = &clone + } + for index := range siblings { + candidate := &siblings[index] + fingerprint, valid := openCodeGoUsageGroupFingerprint(candidate) + source := resolvedSources[fingerprint] + if !valid || source == nil { + continue + } + candidateSnapshot := decodeOpenCodeGoUsageSnapshot(candidate.Extra) + currentSnapshot := decodeOpenCodeGoUsageSnapshot(source.Extra) + if candidateSnapshot != nil && (currentSnapshot == nil || candidateSnapshot.LastAttemptAt.After(currentSnapshot.LastAttemptAt)) { + source.Extra[OpenCodeGoUsageSnapshotExtraKey] = candidate.Extra[OpenCodeGoUsageSnapshotExtraKey] + } + } + for _, account := range eligible { + fingerprint, _ := openCodeGoUsageGroupFingerprint(account) + applyOpenCodeGoUsageManagedExtra(account, resolvedSources[fingerprint]) + } + return nil +} + +func applyOpenCodeGoUsageManagedExtra(target, source *Account) { + if target == nil { + return + } + if target.Extra == nil { + target.Extra = make(map[string]any) + } + for _, key := range []string{ + OpenCodeGoUsageAutoRefreshExtraKey, + OpenCodeGoUsageSnapshotExtraKey, + } { + delete(target.Extra, key) + if source != nil && source.Extra != nil { + if value, ok := source.Extra[key]; ok { + target.Extra[key] = value + } + } + } +} + +func (s *OpenCodeGoUsageService) SetAutoRefresh(ctx context.Context, accountID int64, enabled bool) (*OpenCodeGoUsageState, error) { + if s == nil || s.accountRepo == nil { + return nil, ErrOpenCodeGoUsageUnavailable + } + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil { + return nil, err + } + if !IsOpenCodeGoUsageAccount(account) { + return nil, ErrOpenCodeGoUsageAccountInvalid + } + if err := s.ResolveOpenCodeGoUsageAccounts(ctx, []*Account{account}); err != nil { + return nil, err + } + writer, ok := s.accountRepo.(openCodeGoUsageRepository) + if !ok { + return nil, ErrOpenCodeGoUsageUnavailable + } + if err := writer.SetOpenCodeGoUsageAutoRefresh(ctx, account, enabled); err != nil { + return nil, err + } + return s.GetState(ctx, accountID) +} + +func (s *OpenCodeGoUsageService) Refresh(ctx context.Context, accountID int64) (*OpenCodeGoUsageState, error) { + settings, err := s.GetSettings(ctx) + if err != nil { + return nil, err + } + if _, err := s.refreshAccount(ctx, accountID, settings, false); err != nil { + return nil, err + } + return s.GetState(ctx, accountID) +} + +func (s *OpenCodeGoUsageService) RunDue(ctx context.Context) error { + if s == nil || s.accountRepo == nil { + return nil + } + s.cycleMu.Lock() + defer s.cycleMu.Unlock() + settings, err := s.GetSettings(ctx) + if err != nil { + return err + } + if !settings.Enabled { + return nil + } + release, acquired := tryAcquireSingletonLeaderLock(ctx, s.lockCache, s.db, opencodeGoUsageLeaderLockKey, s.instanceID, opencodeGoUsageLeaderLockTTL) + if !acquired { + return nil + } + defer release() + + writer, ok := s.accountRepo.(openCodeGoUsageRepository) + if !ok { + return ErrOpenCodeGoUsageUnavailable + } + now := s.currentTime() + debounce, maxWait := openCodeGoUsageDurations(settings) + accounts, err := writer.ListDueOpenCodeGoUsageAccounts(ctx, now, debounce, maxWait, opencodeGoUsageMaxPerCycle) + if err != nil { + return fmt.Errorf("list due OpenCode Go usage accounts: %w", err) + } + var group errgroup.Group + seenGroups := make(map[string]struct{}, len(accounts)) + for index := range accounts { + account := accounts[index] + fingerprint, valid := openCodeGoUsageGroupFingerprint(&account) + if !valid || !account.IsActive() || !openCodeGoUsageAutoRefreshEnabled(&account) { + continue + } + if _, duplicate := seenGroups[fingerprint]; duplicate { + continue + } + seenGroups[fingerprint] = struct{}{} + snapshot := decodeOpenCodeGoUsageSnapshot(account.Extra) + // ListDue stamps Account.LastUsedAt with the api_key group MAX(last_used_at). + if !openCodeGoUsageIsAutoRefreshDue(snapshot, account.LastUsedAt, now, debounce, maxWait) { + continue + } + accountID := account.ID + expected := account + group.Go(func() error { + if _, refreshErr := s.refreshAccount(ctx, accountID, settings, true); refreshErr != nil { + if errors.Is(refreshErr, ErrOpenCodeGoUsageIdentityChanged) { + if disableErr := writer.DisableOpenCodeGoUsageAutoRefresh(ctx, &expected); disableErr != nil { + logger.LegacyPrintf("service.opencode_go_usage", "disable_auto_refresh_failed: account_id=%d err=%v", accountID, disableErr) + } + return nil + } + logger.LegacyPrintf("service.opencode_go_usage", "refresh_due_failed: account_id=%d err=%v", accountID, refreshErr) + } + return nil + }) + } + return group.Wait() +} + +func (s *OpenCodeGoUsageService) refreshAccount(ctx context.Context, accountID int64, settings *OpenCodeGoUsageSettings, requireEnabled bool) (*OpenCodeGoUsageSnapshot, error) { + if s == nil || s.accountRepo == nil { + return nil, ErrOpenCodeGoUsageUnavailable + } + if settings == nil { + settings = defaultOpenCodeGoUsageSettings() + } + intervalMinutes := settings.IntervalMinutes + debounce, maxWait := openCodeGoUsageDurations(settings) + anchor, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil { + return nil, err + } + key, valid := openCodeGoUsageGroupFingerprint(anchor) + if !valid { + return nil, ErrOpenCodeGoUsageAccountInvalid + } + value, err, _ := s.refreshGroup.Do(key, func() (any, error) { + select { + case s.refreshSlots <- struct{}{}: + defer func() { <-s.refreshSlots }() + case <-ctx.Done(): + return nil, ctx.Err() + } + account, loadErr := s.accountRepo.GetByID(ctx, accountID) + if loadErr != nil { + return nil, loadErr + } + currentKey, currentValid := openCodeGoUsageGroupFingerprint(account) + if !currentValid { + return nil, ErrOpenCodeGoUsageAccountInvalid + } + if currentKey != key { + return nil, ErrOpenCodeGoUsageIdentityChanged + } + if err := s.ResolveOpenCodeGoUsageAccounts(ctx, []*Account{account}); err != nil { + return nil, err + } + if !requireEnabled { + if snapshot := decodeOpenCodeGoUsageSnapshot(account.Extra); snapshot != nil && !snapshot.LastAttemptAt.IsZero() { + retryAt := snapshot.LastAttemptAt.Add(opencodeGoUsageManualRefreshInterval) + if now := s.currentTime(); now.Before(retryAt) { + remaining := retryAt.Sub(now) + seconds := int((remaining + time.Second - 1) / time.Second) + return nil, ErrOpenCodeGoUsageRefreshRateLimited.WithMetadata(map[string]string{ + "retry_after_seconds": strconv.Itoa(seconds), + }) + } + } + } + if requireEnabled { + if !account.IsActive() || !openCodeGoUsageAutoRefreshEnabled(account) { + return nil, nil + } + groupLastUsed := account.LastUsedAt + if writer, ok := s.accountRepo.(openCodeGoUsageRepository); ok { + siblings, listErr := writer.ListOpenCodeGoUsageGroupAccounts(ctx, []*Account{account}) + if listErr != nil { + // Fall back to this account's own last_used_at. That is a narrower + // activity signal than the group maximum, so the due check may skip a + // refresh it would otherwise have run; surface it rather than + // silently changing the due semantics. + logger.LegacyPrintf("service.opencode_go_usage", + "group_last_used_lookup_failed: account_id=%d err=%v", account.ID, listErr) + } else { + groupLastUsed = maxOpenCodeGoUsageGroupLastUsed(siblings) + } + } + if !openCodeGoUsageIsAutoRefreshDue(decodeOpenCodeGoUsageSnapshot(account.Extra), groupLastUsed, s.currentTime(), debounce, maxWait) { + return nil, nil + } + } + return s.refreshLoadedAccount(ctx, account, intervalMinutes) + }) + if err != nil || value == nil { + return nil, err + } + snapshot, ok := value.(*OpenCodeGoUsageSnapshot) + if !ok { + return nil, fmt.Errorf("invalid OpenCode Go usage refresh result") + } + return snapshot, nil +} + +func (s *OpenCodeGoUsageService) refreshLoadedAccount(ctx context.Context, account *Account, intervalMinutes int) (*OpenCodeGoUsageSnapshot, error) { + now := s.currentTime().UTC() + apiKey, _ := account.Credentials["api_key"].(string) + if apiKey == "" { + return nil, ErrOpenCodeGoUsageAccountInvalid + } + if s.httpUpstream == nil { + return nil, ErrOpenCodeGoUsageUnavailable + } + proxyURL := "" + if account.ProxyID != nil { + if account.Proxy == nil || account.Proxy.ID != *account.ProxyID { + return nil, ErrOpenCodeGoUsageIdentityChanged + } + proxyURL = account.Proxy.URL() + } + requestCtx, cancel := context.WithTimeout(WithHTTPUpstreamRedirectsDisabled(ctx), opencodeGoUsageRequestTimeout) + defer cancel() + req, err := http.NewRequestWithContext(requestCtx, http.MethodGet, opencodeGoUsageAPIURL, nil) + if err != nil || !isExactOpenCodeGoUsageURL(req.URL) { + return nil, ErrOpenCodeGoUsageUnavailable + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("User-Agent", "sub2api-opencode-go-usage/1") + resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + if err != nil { + return s.persistFailure(ctx, account, intervalMinutes, now, 0, "request_failed", 0, false) + } + if resp == nil || resp.Body == nil { + return s.persistFailure(ctx, account, intervalMinutes, now, 0, "empty_response", 0, false) + } + defer func() { _ = resp.Body.Close() }() + if resp.Request != nil && !isExactOpenCodeGoUsageURL(resp.Request.URL) { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "response_host_mismatch", 0, false) + } + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "redirect_blocked", retryAfter(resp.Header, now), false) + } + if resp.StatusCode == http.StatusUnauthorized { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "unauthorized", retryAfter(resp.Header, now), true) + } + if resp.StatusCode == http.StatusForbidden { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "OpenCode Go subscription required (403)", retryAfter(resp.Header, now), false) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "http_error", retryAfter(resp.Header, now), false) + } + body, readErr := io.ReadAll(io.LimitReader(resp.Body, opencodeGoUsageMaxBodyBytes+1)) + if readErr != nil { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "response_read_failed", 0, false) + } + if len(body) > opencodeGoUsageMaxBodyBytes { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "response_too_large", 0, false) + } + data, parseErr := parseOpenCodeGoUsageJSON(body) + if parseErr != nil { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "invalid_json", 0, false) + } + snapshot := &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, + Data: data, + FetchedAt: &now, + LastAttemptAt: now, + NextRefreshAt: now.Add(nextOpenCodeGoUsageDelay(intervalMinutes, 0, 0)), + HTTPStatus: resp.StatusCode, + } + if err := s.updateSnapshot(ctx, account, snapshot); err != nil { + return nil, err + } + return snapshot, nil +} + +func (s *OpenCodeGoUsageService) persistFailure( + ctx context.Context, + account *Account, + intervalMinutes int, + now time.Time, + httpStatus int, + reason string, + retryAfterDuration time.Duration, + unauthorized bool, +) (*OpenCodeGoUsageSnapshot, error) { + previous := decodeOpenCodeGoUsageSnapshot(account.Extra) + failureCount := 1 + if previous != nil { + failureCount = previous.FailureCount + 1 + } + status := OpenCodeGoUsageStatusFailed + if unauthorized { + status = OpenCodeGoUsageStatusUnauthorized + } + snapshot := &OpenCodeGoUsageSnapshot{ + Status: status, + LastAttemptAt: now, + NextRefreshAt: now.Add(nextOpenCodeGoUsageDelay(intervalMinutes, failureCount, retryAfterDuration)), + FailureCount: failureCount, + HTTPStatus: httpStatus, + LastError: reason, + } + if previous != nil { + snapshot.Data = previous.Data + snapshot.FetchedAt = previous.FetchedAt + } + if err := s.updateSnapshot(ctx, account, snapshot); err != nil { + return nil, err + } + return snapshot, nil +} + +func (s *OpenCodeGoUsageService) updateSnapshot(ctx context.Context, account *Account, snapshot *OpenCodeGoUsageSnapshot) error { + writer, ok := s.accountRepo.(openCodeGoUsageRepository) + if !ok { + return ErrOpenCodeGoUsageUnavailable + } + return writer.UpdateOpenCodeGoUsageSnapshot(ctx, account, snapshot) +} + +func OpenCodeGoUsageStateFromAccount(account *Account) *OpenCodeGoUsageState { + state := &OpenCodeGoUsageState{} + if account == nil { + return state + } + state.AccountID = account.ID + state.Eligible = IsOpenCodeGoUsageAccount(account) + if !state.Eligible { + return state + } + state.AutoRefreshEnabled = openCodeGoUsageAutoRefreshEnabled(account) + state.Snapshot = decodeOpenCodeGoUsageSnapshot(account.Extra) + return state +} + +// isOpenCodeGoUsageMountPlatform 收敛允许以 base_url 挂载 OpenCode Go 用量身份的 +// 平台白名单,与 ollama 的 isOllamaCloudUsagePlatform 对齐。repository 侧 SQL +// 白名单(opencodeGoUsageMountPlatformsSQL)是本列表的镜像,两侧必须同步修改。 +// opencode_go 平台本身不在名单内:平台账号走 IsOpenCodeGoPlan 判定,不依赖 +// base_url。 +func isOpenCodeGoUsageMountPlatform(platform string) bool { + switch platform { + case PlatformOpenAI, PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: + return true + default: + return false + } +} + +// IsOpenCodeGoUsageAccount 判定账号是否参与 OpenCode Go 用量窗口,资格来源分派: +// - platform == opencode_go:平台字段是权威来源,以 IsOpenCodeGoPlan 为准(必须是 +// Go 订阅;Zen 按量付费无订阅配额窗口,上游 CN 配额链路同样排除)。此分支不要求 +// base_url 匹配——opencode_go 账号的 base_url 可能是 CC/Responses 基址 +// (/zen/go/v1)或 Anthropic 基址(/zen/go),甚至为空。这是有意取舍:平台 + +// 模式已是权威来源,且账号指向自建代理/中转时 key 仍是官方 OpenCode key,从 +// 官方端点取用量恰恰是正确数据源,强制要求官方 host 会破坏这类合法用法; +// 前提假设是该账号的 api_key 为官方 OpenCode Go 订阅 key。 +// - 其它平台:仅限挂载白名单(isOpenCodeGoUsageMountPlatform)内的 API Key 账号, +// 以 base_url 严格指向官方 OpenCode Go 基址判定(见 isOpenCodeGoBaseURL)。 +// +// 两种情况都要求 account.Type == AccountTypeAPIKey。 +func IsOpenCodeGoUsageAccount(account *Account) bool { + if account == nil || account.Type != AccountTypeAPIKey { + return false + } + if account.IsOpenCodeGo() { + return account.IsOpenCodeGoPlan() + } + if !isOpenCodeGoUsageMountPlatform(account.Platform) { + return false + } + baseURL, _ := account.Credentials["base_url"].(string) + return isOpenCodeGoBaseURL(baseURL) +} + +func isOpenCodeGoBaseURL(raw string) bool { + raw = strings.TrimSpace(raw) + if raw == "" || strings.ContainsAny(raw, "?#") { + return false + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Opaque != "" || !strings.EqualFold(parsed.Scheme, "https") || parsed.User != nil || parsed.ForceQuery || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.RawFragment != "" { + return false + } + hostname := strings.ToLower(parsed.Hostname()) + if hostname != "opencode.ai" { + return false + } + authority := strings.ToLower(parsed.Host) + if authority != hostname && authority != hostname+":443" { + return false + } + if parsed.RawPath != "" { + return false + } + // 官方两个基址变体:CC/Responses 的 /zen/go/v1 与 Anthropic 的 /zen/go。 + // TrimSuffix 归一尾斜杠后,Zen 基址(/zen、/zen/v1)自然被拒绝。 + path := strings.TrimSuffix(parsed.Path, "/") + return strings.EqualFold(path, "/zen/go/v1") || strings.EqualFold(path, "/zen/go") +} + +// openCodeGoUsageIdentity 返回 OpenCode Go 用量组的身份。host 固定为 opencode.ai、 +// 以 api_key 聚合是平台无关的有意设计:同一个 OpenCode Go 订阅 key 无论以 +// opencode_go 平台账号存在,还是挂在 openai/anthropic 等挂载平台下,都属于 +// 同一组、共享一次外呼与同一份快照。 +func openCodeGoUsageIdentity(account *Account) map[string]any { + if !IsOpenCodeGoUsageAccount(account) { + return nil + } + apiKey, ok := account.Credentials["api_key"].(string) + if !ok || apiKey == "" { + return nil + } + return map[string]any{"host": "opencode.ai", "api_key": apiKey} +} + +// openCodeGoUsageGroupFingerprint 是组身份的指纹(sha256("opencode.ai\x00"+apiKey))。 +// 与 openCodeGoUsageIdentity 一样保持平台无关:同一订阅 key 跨平台(opencode_go +// 平台账号与挂载平台账号)必须得到相同指纹,才会被归入同一刷新组。 +func openCodeGoUsageGroupFingerprint(account *Account) (string, bool) { + identity := openCodeGoUsageIdentity(account) + if identity == nil { + return "", false + } + apiKey, _ := identity["api_key"].(string) + sum := sha256.Sum256([]byte("opencode.ai\x00" + apiKey)) + return hex.EncodeToString(sum[:]), true +} + +func isExactOpenCodeGoUsageURL(parsed *url.URL) bool { + return parsed != nil && parsed.Scheme == "https" && parsed.Host == "opencode.ai" && parsed.Path == "/zen/go/v1/usage" && + parsed.User == nil && parsed.RawQuery == "" && parsed.Fragment == "" && parsed.RawPath == "" +} + +func openCodeGoUsageAutoRefreshEnabled(account *Account) bool { + if account == nil || account.Extra == nil { + return false + } + enabled, ok := account.Extra[OpenCodeGoUsageAutoRefreshExtraKey].(bool) + return ok && enabled +} + +func decodeOpenCodeGoUsageSnapshot(extra map[string]any) *OpenCodeGoUsageSnapshot { + if extra == nil { + return nil + } + value, ok := extra[OpenCodeGoUsageSnapshotExtraKey] + if !ok || value == nil { + return nil + } + raw, err := json.Marshal(value) + if err != nil { + return nil + } + var snapshot OpenCodeGoUsageSnapshot + if err := json.Unmarshal(raw, &snapshot); err != nil { + return nil + } + if snapshot.Status != OpenCodeGoUsageStatusOK && snapshot.Status != OpenCodeGoUsageStatusUnauthorized && snapshot.Status != OpenCodeGoUsageStatusFailed { + return nil + } + return &snapshot +} + +// nextOpenCodeGoUsageDelay computes the not-before delay for the next refresh: +// interval * 2^min(failureCount-1, 6) capped at 24h, ±10% jitter capped at 5min, +// never below a Retry-After hint or one minute. +func nextOpenCodeGoUsageDelay(intervalMinutes, failureCount int, retryAfterDuration time.Duration) time.Duration { + minimumDelay := retryAfterDuration + base := time.Duration(intervalMinutes) * time.Minute + if base < opencodeGoUsageMinIntervalMinutes*time.Minute { + base = opencodeGoUsageMinIntervalMinutes * time.Minute + } + if failureCount > 0 { + shift := min(failureCount-1, 6) + base *= time.Duration(1 << shift) + } + if base > opencodeGoUsageMaxDelay { + base = opencodeGoUsageMaxDelay + } + if retryAfterDuration > base { + base = retryAfterDuration + } + jitterRange := base / 10 + if jitterRange > 5*time.Minute { + jitterRange = 5 * time.Minute + } + if jitterRange > 0 { + base += time.Duration(rand.Int64N(int64(jitterRange)*2+1)) - jitterRange + } + if base < minimumDelay { + return minimumDelay + } + if base < time.Minute { + return time.Minute + } + return base +} + +func (s *OpenCodeGoUsageService) currentTime() time.Time { + if s != nil && s.now != nil { + return s.now() + } + return time.Now() +} + +type openCodeGoUsageAPIWindow struct { + Status string `json:"status"` + Percent float64 `json:"percent"` + ResetsAt string `json:"resetsAt"` +} + +type openCodeGoUsageAPIUsage struct { + Rolling *openCodeGoUsageAPIWindow `json:"rolling"` + Weekly *openCodeGoUsageAPIWindow `json:"weekly"` + Monthly *openCodeGoUsageAPIWindow `json:"monthly"` +} + +type openCodeGoUsageAPIResponse struct { + Usage *openCodeGoUsageAPIUsage `json:"usage"` +} + +// parseOpenCodeGoUsageJSON parses the upstream usage payload leniently: the top +// level may wrap the windows in a "usage" object or expose them directly, and +// missing windows/fields degrade to zero values instead of failing the whole +// parse. Only structurally invalid JSON is an error. +func parseOpenCodeGoUsageJSON(body []byte) (*OpenCodeGoUsageData, error) { + var wrapped openCodeGoUsageAPIResponse + if err := json.Unmarshal(body, &wrapped); err != nil { + return nil, err + } + usage := wrapped.Usage + if usage == nil { + var direct openCodeGoUsageAPIUsage + if err := json.Unmarshal(body, &direct); err != nil { + return nil, err + } + usage = &direct + } + data := &OpenCodeGoUsageData{} + if usage.Rolling != nil { + data.Rolling = openCodeGoUsageWindowFromAPI(usage.Rolling) + } + if usage.Weekly != nil { + data.Weekly = openCodeGoUsageWindowFromAPI(usage.Weekly) + } + if usage.Monthly != nil { + data.Monthly = openCodeGoUsageWindowFromAPI(usage.Monthly) + } + return data, nil +} + +func openCodeGoUsageWindowFromAPI(window *openCodeGoUsageAPIWindow) OpenCodeGoUsageWindow { + out := OpenCodeGoUsageWindow{Status: window.Status, Percent: window.Percent} + if resetsAt, err := time.Parse(time.RFC3339, window.ResetsAt); err == nil { + out.ResetsAt = resetsAt.UTC() + } + return out +} diff --git a/backend/internal/service/opencode_go_usage_test.go b/backend/internal/service/opencode_go_usage_test.go new file mode 100644 index 0000000000..91b9047042 --- /dev/null +++ b/backend/internal/service/opencode_go_usage_test.go @@ -0,0 +1,981 @@ +package service + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "reflect" + "sync" + "sync/atomic" + "testing" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/stretchr/testify/require" +) + +// openCodeGoUsageFixture is the verified upstream 200 response (2026-08-13). +const openCodeGoUsageFixture = `{"usage":{"rolling":{"status":"ok","percent":6,"resetsAt":"2026-08-13T18:26:39.281Z"},"weekly":{"status":"ok","percent":2,"resetsAt":"2026-08-17T00:00:00.281Z"},"monthly":{"status":"ok","percent":1,"resetsAt":"2026-09-13T13:24:47.281Z"}}}` + +type openCodeGoUsageTestRepo struct { + AccountRepository + mu sync.Mutex + accounts map[int64]*Account + due []Account + groupResolveCalls atomic.Int64 + getByIDCalls atomic.Int64 + disableAutoCalls atomic.Int64 + disableAutoAttempt atomic.Int64 +} + +func (r *openCodeGoUsageTestRepo) GetByID(_ context.Context, id int64) (*Account, error) { + r.getByIDCalls.Add(1) + r.mu.Lock() + defer r.mu.Unlock() + account := r.accounts[id] + if account == nil { + return nil, ErrAccountNotFound + } + clone := *account + clone.Credentials = mergeMap(nil, account.Credentials) + clone.Extra = mergeMap(nil, account.Extra) + return &clone, nil +} + +func (r *openCodeGoUsageTestRepo) GetByIDs(_ context.Context, ids []int64) ([]*Account, error) { + r.mu.Lock() + defer r.mu.Unlock() + result := make([]*Account, 0, len(ids)) + for _, id := range ids { + if account := r.accounts[id]; account != nil { + result = append(result, account) + } + } + return result, nil +} + +func (r *openCodeGoUsageTestRepo) ListOpenCodeGoUsageGroupAccounts(_ context.Context, anchors []*Account) ([]Account, error) { + r.groupResolveCalls.Add(1) + r.mu.Lock() + defer r.mu.Unlock() + wanted := make(map[string]struct{}, len(anchors)) + for _, anchor := range anchors { + if fingerprint, ok := openCodeGoUsageGroupFingerprint(anchor); ok { + wanted[fingerprint] = struct{}{} + } + } + result := make([]Account, 0, len(r.accounts)) + for _, account := range r.accounts { + fingerprint, ok := openCodeGoUsageGroupFingerprint(account) + if _, match := wanted[fingerprint]; !ok || !match { + continue + } + result = append(result, cloneOpenCodeGoUsageTestAccount(*account)) + } + return result, nil +} + +func (r *openCodeGoUsageTestRepo) SetOpenCodeGoUsageAutoRefresh(_ context.Context, expected *Account, enabled bool) error { + r.mu.Lock() + defer r.mu.Unlock() + members, err := r.openCodeGoGroupMembersLocked(expected) + if err != nil { + return err + } + for _, account := range members { + applyOpenCodeGoUsageTestManagedExtra(account, expected) + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = enabled + } + return nil +} + +func (r *openCodeGoUsageTestRepo) UpdateOpenCodeGoUsageSnapshot(_ context.Context, expected *Account, snapshot *OpenCodeGoUsageSnapshot) error { + r.mu.Lock() + defer r.mu.Unlock() + members, err := r.openCodeGoGroupMembersLocked(expected) + if err != nil { + return err + } + for _, account := range members { + applyOpenCodeGoUsageTestManagedExtra(account, expected) + account.Extra[OpenCodeGoUsageSnapshotExtraKey] = snapshot + } + return nil +} + +func (r *openCodeGoUsageTestRepo) DisableOpenCodeGoUsageAutoRefresh(_ context.Context, expected *Account) error { + r.disableAutoAttempt.Add(1) + r.mu.Lock() + defer r.mu.Unlock() + members, err := r.openCodeGoGroupMembersLocked(expected) + if err != nil { + return err + } + for _, account := range members { + applyOpenCodeGoUsageTestManagedExtra(account, expected) + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = false + delete(account.Extra, OpenCodeGoUsageSnapshotExtraKey) + } + r.disableAutoCalls.Add(1) + return nil +} + +func (r *openCodeGoUsageTestRepo) openCodeGoGroupMembersLocked(expected *Account) ([]*Account, error) { + anchor := r.accounts[expected.ID] + if !sameOpenCodeGoUsageTestIdentity(anchor, expected) { + return nil, ErrOpenCodeGoUsageIdentityChanged + } + fingerprint, ok := openCodeGoUsageGroupFingerprint(expected) + if !ok { + return nil, ErrOpenCodeGoUsageAccountInvalid + } + members := make([]*Account, 0, len(r.accounts)) + for _, account := range r.accounts { + candidate, valid := openCodeGoUsageGroupFingerprint(account) + if valid && candidate == fingerprint { + if account.Extra == nil { + account.Extra = make(map[string]any) + } + members = append(members, account) + } + } + return members, nil +} + +func applyOpenCodeGoUsageTestManagedExtra(account, source *Account) { + for _, key := range []string{OpenCodeGoUsageAutoRefreshExtraKey, OpenCodeGoUsageSnapshotExtraKey} { + delete(account.Extra, key) + if value, ok := source.Extra[key]; ok { + account.Extra[key] = value + } + } +} + +func sameOpenCodeGoUsageTestIdentity(left, right *Account) bool { + return left != nil && right != nil && left.Platform == right.Platform && left.Type == right.Type && + reflect.DeepEqual(left.Credentials, right.Credentials) && reflect.DeepEqual(left.ProxyID, right.ProxyID) +} + +func (r *openCodeGoUsageTestRepo) ListDueOpenCodeGoUsageAccounts(_ context.Context, _ time.Time, _, _ time.Duration, limit int) ([]Account, error) { + r.mu.Lock() + defer r.mu.Unlock() + if len(r.due) > 0 { + out := make([]Account, 0, min(limit, len(r.due))) + for _, account := range r.due[:min(limit, len(r.due))] { + out = append(out, cloneOpenCodeGoUsageTestAccount(account)) + } + return out, nil + } + out := make([]Account, 0, len(r.accounts)) + for _, account := range r.accounts { + out = append(out, cloneOpenCodeGoUsageTestAccount(*account)) + if len(out) == limit { + break + } + } + return out, nil +} + +func cloneOpenCodeGoUsageTestAccount(account Account) Account { + account.Credentials = mergeMap(nil, account.Credentials) + account.Extra = mergeMap(nil, account.Extra) + return account +} + +type openCodeGoUsageHTTPStub struct { + status int + body []byte + header http.Header + calls atomic.Int64 + beforeResponse func(*http.Request) + lastRequest *http.Request + lastProxy string + mu sync.Mutex +} + +func (s *openCodeGoUsageHTTPStub) Do(req *http.Request, proxyURL string, _ int64, _ int) (*http.Response, error) { + s.calls.Add(1) + s.mu.Lock() + s.lastRequest = req + s.lastProxy = proxyURL + s.mu.Unlock() + if s.beforeResponse != nil { + s.beforeResponse(req) + } + status := s.status + if status == 0 { + status = http.StatusOK + } + header := s.header + if header == nil { + header = http.Header{"Content-Type": []string{"application/json"}} + } + return &http.Response{StatusCode: status, Header: header, Body: io.NopCloser(bytes.NewReader(s.body)), Request: req}, nil +} + +func (s *openCodeGoUsageHTTPStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return s.Do(req, proxyURL, accountID, concurrency) +} + +func openCodeGoUsageAccount(id int64) *Account { + return &Account{ + ID: id, Name: fmt.Sprintf("opencode-%d", id), Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://opencode.ai/zen/go/v1", "api_key": fmt.Sprintf("key-%d", id)}, + Extra: map[string]any{}, Status: StatusActive, Schedulable: true, Concurrency: 1, + } +} + +func newOpenCodeGoUsageTestService(t *testing.T, repo *openCodeGoUsageTestRepo, upstream HTTPUpstream, settingsRepo SettingRepository) *OpenCodeGoUsageService { + t.Helper() + svc := NewOpenCodeGoUsageService(repo, upstream, NewSettingService(settingsRepo, nil)) + t.Cleanup(svc.Stop) + return svc +} + +func TestOpenCodeGoUsageParseJSON200(t *testing.T) { + data, err := parseOpenCodeGoUsageJSON([]byte(openCodeGoUsageFixture)) + require.NoError(t, err) + require.Equal(t, "ok", data.Rolling.Status) + require.Equal(t, 6.0, data.Rolling.Percent) + require.Equal(t, "ok", data.Weekly.Status) + require.Equal(t, 2.0, data.Weekly.Percent) + require.Equal(t, "ok", data.Monthly.Status) + require.Equal(t, 1.0, data.Monthly.Percent) + rollingReset, err := time.Parse(time.RFC3339, "2026-08-13T18:26:39.281Z") + require.NoError(t, err) + require.Equal(t, rollingReset.UTC(), data.Rolling.ResetsAt) + weeklyReset, err := time.Parse(time.RFC3339, "2026-08-17T00:00:00.281Z") + require.NoError(t, err) + require.Equal(t, weeklyReset.UTC(), data.Weekly.ResetsAt) +} + +func TestOpenCodeGoUsageParseJSONLenient(t *testing.T) { + // top-level windows without a usage wrapper + data, err := parseOpenCodeGoUsageJSON([]byte(`{"rolling":{"status":"ok","percent":6,"resetsAt":"2026-08-13T18:26:39.281Z"}}`)) + require.NoError(t, err) + require.Equal(t, 6.0, data.Rolling.Percent) + require.Zero(t, data.Weekly.Percent) + + // missing windows/fields degrade to zero values instead of failing + data, err = parseOpenCodeGoUsageJSON([]byte(`{"usage":{"rolling":{"status":"ok"}}}`)) + require.NoError(t, err) + require.Equal(t, "ok", data.Rolling.Status) + require.Zero(t, data.Rolling.Percent) + require.True(t, data.Rolling.ResetsAt.IsZero()) + + // integer percent parses as float + data, err = parseOpenCodeGoUsageJSON([]byte(`{"usage":{"weekly":{"status":"ok","percent":2}}}`)) + require.NoError(t, err) + require.Equal(t, 2.0, data.Weekly.Percent) + + // malformed JSON is an error + _, err = parseOpenCodeGoUsageJSON([]byte(`{"usage": {broken`)) + require.Error(t, err) +} + +func TestOpenCodeGoUsageRefresh200Success(t *testing.T) { + account := openCodeGoUsageAccount(7) + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + state, err := svc.Refresh(context.Background(), 7) + require.NoError(t, err) + require.True(t, state.Eligible) + require.True(t, state.AutoRefreshEnabled) + require.True(t, openCodeGoUsageAutoRefreshEnabled(account)) + require.Equal(t, OpenCodeGoUsageStatusOK, state.Snapshot.Status) + require.Equal(t, 6.0, state.Snapshot.Data.Rolling.Percent) + require.Equal(t, 2.0, state.Snapshot.Data.Weekly.Percent) + require.Equal(t, 1.0, state.Snapshot.Data.Monthly.Percent) + require.Equal(t, http.StatusOK, state.Snapshot.HTTPStatus) + require.Equal(t, 0, state.Snapshot.FailureCount) + require.NotNil(t, state.Snapshot.FetchedAt) + require.False(t, state.Snapshot.NextRefreshAt.IsZero()) + require.Equal(t, "Bearer key-7", stub.lastRequest.Header.Get("Authorization")) + require.Equal(t, "https://opencode.ai/zen/go/v1/usage", stub.lastRequest.URL.String()) + require.Equal(t, "application/json", stub.lastRequest.Header.Get("Accept")) +} + +func TestOpenCodeGoUsageRefresh401Unauthorized(t *testing.T) { + account := openCodeGoUsageAccount(7) + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{status: http.StatusUnauthorized} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + state, err := svc.Refresh(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, OpenCodeGoUsageStatusUnauthorized, state.Snapshot.Status) + require.Equal(t, http.StatusUnauthorized, state.Snapshot.HTTPStatus) + require.Equal(t, "unauthorized", state.Snapshot.LastError) + require.Equal(t, 1, state.Snapshot.FailureCount) + require.False(t, state.Snapshot.NextRefreshAt.IsZero()) + // data is kept and auto-refresh stays enabled + require.True(t, openCodeGoUsageAutoRefreshEnabled(account)) +} + +func TestOpenCodeGoUsageRefresh403SubscriptionRequired(t *testing.T) { + account := openCodeGoUsageAccount(7) + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{status: http.StatusForbidden} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + state, err := svc.Refresh(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, OpenCodeGoUsageStatusFailed, state.Snapshot.Status) + require.Equal(t, http.StatusForbidden, state.Snapshot.HTTPStatus) + require.Equal(t, "OpenCode Go subscription required (403)", state.Snapshot.LastError) + require.Equal(t, 1, state.Snapshot.FailureCount) +} + +func TestOpenCodeGoUsageRefreshMalformedJSON(t *testing.T) { + account := openCodeGoUsageAccount(7) + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{status: http.StatusOK, body: []byte(`{"usage": {broken`)} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + state, err := svc.Refresh(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, OpenCodeGoUsageStatusFailed, state.Snapshot.Status) + require.Equal(t, http.StatusOK, state.Snapshot.HTTPStatus) + require.Equal(t, "invalid_json", state.Snapshot.LastError) + require.Equal(t, 1, state.Snapshot.FailureCount) +} + +func TestOpenCodeGoUsageRefreshFailureKeepsPreviousData(t *testing.T) { + now := time.Now().UTC() + account := openCodeGoUsageAccount(7) + account.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, Data: &OpenCodeGoUsageData{Rolling: OpenCodeGoUsageWindow{Status: "ok", Percent: 6}}, + FetchedAt: &now, LastAttemptAt: now.Add(-30 * time.Second), NextRefreshAt: now.Add(time.Hour), FailureCount: 1, + } + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{status: http.StatusInternalServerError} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + state, err := svc.Refresh(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, OpenCodeGoUsageStatusFailed, state.Snapshot.Status) + require.Equal(t, "http_error", state.Snapshot.LastError) + require.Equal(t, 2, state.Snapshot.FailureCount) + require.NotNil(t, state.Snapshot.Data, "previous data must be retained on failure") + require.Equal(t, 6.0, state.Snapshot.Data.Rolling.Percent) +} + +func TestNextOpenCodeGoUsageDelayBackoff(t *testing.T) { + // success: base interval with ±10% jitter + delay := nextOpenCodeGoUsageDelay(15, 0, 0) + require.GreaterOrEqual(t, delay, 13*time.Minute) + require.LessOrEqual(t, delay, 17*time.Minute) + + // failure 1: 1x interval (2^min(0,6)) + delay = nextOpenCodeGoUsageDelay(15, 1, 0) + require.GreaterOrEqual(t, delay, 13*time.Minute) + require.LessOrEqual(t, delay, 17*time.Minute) + + // failure 2: 2x interval + delay = nextOpenCodeGoUsageDelay(15, 2, 0) + require.GreaterOrEqual(t, delay, 27*time.Minute) + require.LessOrEqual(t, delay, 33*time.Minute) + + // failure 3: 4x interval + delay = nextOpenCodeGoUsageDelay(15, 3, 0) + require.GreaterOrEqual(t, delay, 55*time.Minute) + require.LessOrEqual(t, delay, 65*time.Minute) + + // failure 8: exponent capped at 6 → 64x interval (16h) + delay = nextOpenCodeGoUsageDelay(15, 8, 0) + require.GreaterOrEqual(t, delay, 15*time.Hour) + require.LessOrEqual(t, delay, 17*time.Hour) + + // hard cap at 24h even for huge intervals + delay = nextOpenCodeGoUsageDelay(1440, 8, 0) + require.GreaterOrEqual(t, delay, 23*time.Hour) + require.LessOrEqual(t, delay, 25*time.Hour) + + // Retry-After hint wins over the computed backoff + delay = nextOpenCodeGoUsageDelay(15, 0, 2*time.Hour) + require.GreaterOrEqual(t, delay, 2*time.Hour) + + // floor of one minute + delay = nextOpenCodeGoUsageDelay(5, 0, 0) + require.GreaterOrEqual(t, delay, time.Minute) +} + +func TestOpenCodeGoUsageManualRefreshThrottle(t *testing.T) { + now := time.Now().UTC() + account := openCodeGoUsageAccount(7) + account.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, LastAttemptAt: now.Add(-2 * time.Second), + } + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + svc.now = func() time.Time { return now } + + _, err := svc.Refresh(context.Background(), 7) + require.Error(t, err) + require.True(t, errors.Is(err, ErrOpenCodeGoUsageRefreshRateLimited)) + require.Equal(t, int64(0), stub.calls.Load(), "throttled refresh must not hit upstream") + var appErr *infraerrors.ApplicationError + require.ErrorAs(t, err, &appErr) + require.NotEmpty(t, appErr.Metadata["retry_after_seconds"]) +} + +func TestOpenCodeGoUsageManualRefreshNotThrottledAfterWindow(t *testing.T) { + now := time.Now().UTC() + account := openCodeGoUsageAccount(7) + account.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, LastAttemptAt: now.Add(-30 * time.Second), + } + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + svc.now = func() time.Time { return now } + + state, err := svc.Refresh(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, OpenCodeGoUsageStatusOK, state.Snapshot.Status) + require.Equal(t, int64(1), stub.calls.Load()) +} + +func TestOpenCodeGoUsageIsAutoRefreshDue(t *testing.T) { + debounce := time.Minute + maxWait := time.Hour + now := time.Date(2026, time.July, 25, 12, 0, 0, 0, time.UTC) + fetched := now.Add(-30 * time.Minute) + ptr := func(ts time.Time) *time.Time { return &ts } + + require.True(t, openCodeGoUsageIsAutoRefreshDue(nil, nil, now, debounce, maxWait), "missing snapshot first due") + require.True(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{Status: "bogus"}, nil, now, debounce, maxWait), "invalid status first due") + + okSnap := &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(fetched), + LastAttemptAt: fetched, NextRefreshAt: fetched.Add(maxWait), + } + require.False(t, openCodeGoUsageIsAutoRefreshDue(okSnap, nil, now, debounce, maxWait), "no request after success") + require.False(t, openCodeGoUsageIsAutoRefreshDue(okSnap, ptr(fetched), now, debounce, maxWait), "request not after fetched_at") + require.False(t, openCodeGoUsageIsAutoRefreshDue(okSnap, ptr(now.Add(-30*time.Second)), now, debounce, maxWait), "debounce not elapsed") + require.True(t, openCodeGoUsageIsAutoRefreshDue(okSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), "single request quiet for debounce") + + // Continuous requests: last used is now, but max-wait from old fetch forces due. + oldFetched := now.Add(-2 * time.Hour) + oldSnap := &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(oldFetched), + LastAttemptAt: oldFetched, NextRefreshAt: oldFetched.Add(maxWait), + } + require.True(t, openCodeGoUsageIsAutoRefreshDue(oldSnap, ptr(now), now, debounce, maxWait), "max-wait forces due while requests continue") + // First request after a very old snapshot is immediately due because fetched+maxWait is past. + require.True(t, openCodeGoUsageIsAutoRefreshDue(oldSnap, ptr(now.Add(-time.Second)), now, debounce, maxWait), "stale snapshot first request immediate") + + failSnap := &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusFailed, FetchedAt: ptr(fetched), + LastAttemptAt: now.Add(-10 * time.Minute), NextRefreshAt: now.Add(20 * time.Minute), + } + require.False(t, openCodeGoUsageIsAutoRefreshDue(failSnap, nil, now, debounce, maxWait), "failure without new request") + require.False(t, openCodeGoUsageIsAutoRefreshDue(failSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), "failure blocked by backoff") + failSnap.NextRefreshAt = now.Add(-time.Second) + require.True(t, openCodeGoUsageIsAutoRefreshDue(failSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), "failure after backoff with new request") + + require.True(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, LastAttemptAt: now, + }, nil, now, debounce, maxWait), "ok without fetched_at fails open") +} + +// The success path stopped consulting next_refresh_at, which is where +// nextOpenCodeGoUsageDelay used to apply the minimum interval. Activity may pull +// a refresh forward only as far as that floor, otherwise request traffic spaced +// just wider than the debounce drives the group's outbound rate far above the +// pre-existing minimum. +func TestOpenCodeGoUsageAutoRefreshDueAtHonoursMinFetchInterval(t *testing.T) { + debounce := time.Minute + maxWait := time.Hour + now := time.Date(2026, time.July, 25, 12, 0, 0, 0, time.UTC) + ptr := func(ts time.Time) *time.Time { return &ts } + + // Debounce elapsed, but the last successful fetch is inside the floor. + recent := now.Add(-2 * time.Minute) + recentSnap := &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(recent), LastAttemptAt: recent, + } + dueAt, ok := openCodeGoUsageAutoRefreshDueAt(recentSnap, ptr(now.Add(-time.Minute)), debounce, maxWait) + require.True(t, ok) + require.Equal(t, recent.Add(OpenCodeGoUsageMinFetchInterval), dueAt, + "due time must be clamped to fetched_at + min fetch interval") + require.False(t, openCodeGoUsageIsAutoRefreshDue(recentSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), + "debounce alone must not refresh within the min fetch interval") + + // Once the floor has passed the debounce governs again. + atFloor := now.Add(-OpenCodeGoUsageMinFetchInterval) + floorSnap := &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(atFloor), LastAttemptAt: atFloor, + } + require.True(t, openCodeGoUsageIsAutoRefreshDue(floorSnap, ptr(now.Add(-2*time.Minute)), now, debounce, maxWait), + "past the floor a quiet debounce window is due") + + // The floor never delays a refresh that max-wait has already forced. + oldFetched := now.Add(-2 * time.Hour) + oldSnap := &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(oldFetched), LastAttemptAt: oldFetched, + } + dueAt, ok = openCodeGoUsageAutoRefreshDueAt(oldSnap, ptr(now), debounce, maxWait) + require.True(t, ok) + require.Equal(t, oldFetched.Add(maxWait), dueAt, "max-wait due time must not be pushed out by the floor") +} + +func TestOpenCodeGoUsageGroupSharesStateAcrossSiblings(t *testing.T) { + source := openCodeGoUsageAccount(71) + source.Credentials["api_key"] = "shared-key" + source.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + source.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, + Data: &OpenCodeGoUsageData{Rolling: OpenCodeGoUsageWindow{Status: "ok", Percent: 6}}, + } + source.UpdatedAt = time.Now().Add(-time.Minute) + sibling := openCodeGoUsageAccount(72) + sibling.Credentials = map[string]any{"base_url": "HTTPS://OPENCODE.AI/ZEN/GO/V1/", "api_key": "shared-key"} + sibling.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + sibling.UpdatedAt = time.Now() + different := openCodeGoUsageAccount(73) + different.Credentials["api_key"] = "different-key" + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{ + source.ID: source, sibling.ID: sibling, different.ID: different, + }} + svc := newOpenCodeGoUsageTestService(t, repo, &openCodeGoUsageHTTPStub{}, &upstreamBillingProbeSettingRepo{}) + + state, err := svc.GetState(context.Background(), sibling.ID) + require.NoError(t, err) + require.True(t, state.Eligible) + require.True(t, state.AutoRefreshEnabled) + require.Equal(t, 6.0, state.Snapshot.Data.Rolling.Percent) + + differentState, err := svc.GetState(context.Background(), different.ID) + require.NoError(t, err) + require.False(t, differentState.AutoRefreshEnabled) + require.Nil(t, differentState.Snapshot) + + newSibling := openCodeGoUsageAccount(74) + newSibling.Credentials = map[string]any{"base_url": "https://opencode.ai/zen/go/v1", "api_key": "shared-key"} + repo.mu.Lock() + repo.accounts[newSibling.ID] = newSibling + repo.mu.Unlock() + newState, err := svc.GetState(context.Background(), newSibling.ID) + require.NoError(t, err) + require.True(t, newState.AutoRefreshEnabled) + require.Equal(t, state.Snapshot, newState.Snapshot) + + before := repo.groupResolveCalls.Load() + require.NoError(t, svc.ResolveOpenCodeGoUsageAccounts(context.Background(), []*Account{source, sibling, different, newSibling})) + require.Equal(t, before+1, repo.groupResolveCalls.Load(), "one list batch must issue one group lookup") +} + +func TestOpenCodeGoUsageSetAutoRefreshAndSnapshotAreGroupScoped(t *testing.T) { + first := openCodeGoUsageAccount(81) + first.Credentials["api_key"] = "shared-key" + second := openCodeGoUsageAccount(82) + second.Credentials = map[string]any{"base_url": "https://opencode.ai/zen/go/v1/", "api_key": "shared-key"} + different := openCodeGoUsageAccount(83) + different.Credentials["api_key"] = "different-key" + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{ + first.ID: first, second.ID: second, different.ID: different, + }} + svc := newOpenCodeGoUsageTestService(t, repo, &openCodeGoUsageHTTPStub{}, &upstreamBillingProbeSettingRepo{}) + + state, err := svc.SetAutoRefresh(context.Background(), second.ID, true) + require.NoError(t, err) + require.True(t, state.AutoRefreshEnabled) + require.Equal(t, true, first.Extra[OpenCodeGoUsageAutoRefreshExtraKey]) + require.Equal(t, true, second.Extra[OpenCodeGoUsageAutoRefreshExtraKey]) + require.NotContains(t, different.Extra, OpenCodeGoUsageAutoRefreshExtraKey) + + // A snapshot write must not wipe the auto-refresh switch (pure merge). + now := time.Now().UTC() + _, err = svc.Refresh(context.Background(), first.ID) + require.NoError(t, err) + require.Equal(t, true, first.Extra[OpenCodeGoUsageAutoRefreshExtraKey], "snapshot write must preserve auto_refresh") + require.Equal(t, true, second.Extra[OpenCodeGoUsageAutoRefreshExtraKey], "snapshot write must preserve auto_refresh on siblings") + require.NotNil(t, decodeOpenCodeGoUsageSnapshot(second.Extra), "snapshot must be shared with siblings") + require.Equal(t, decodeOpenCodeGoUsageSnapshot(first.Extra), decodeOpenCodeGoUsageSnapshot(second.Extra)) + require.NotNil(t, now) +} + +func TestOpenCodeGoUsageRefreshSingleflightAndRunnerDeduplicateSharedGroup(t *testing.T) { + first := openCodeGoUsageAccount(91) + first.Credentials["api_key"] = "shared-key" + first.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + second := openCodeGoUsageAccount(92) + second.Credentials = map[string]any{"base_url": "https://opencode.ai/zen/go/v1/", "api_key": "shared-key"} + second.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + repo := &openCodeGoUsageTestRepo{ + accounts: map[int64]*Account{first.ID: first, second.ID: second}, + due: []Account{*first, *second}, + } + settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{ + SettingKeyOpenCodeGoUsageSettings: `{"enabled":true,"interval_minutes":15}`, + }} + started := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + upstream := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture), beforeResponse: func(*http.Request) { + once.Do(func() { close(started) }) + <-release + }} + svc := newOpenCodeGoUsageTestService(t, repo, upstream, settingsRepo) + + errs := make(chan error, 2) + go func() { _, err := svc.Refresh(context.Background(), first.ID); errs <- err }() + <-started + // The first caller is now parked in the stub, having loaded the account twice + // (once to build the group key, once inside the singleflight function). + loadsBeforeSecond := repo.getByIDCalls.Load() + go func() { _, err := svc.Refresh(context.Background(), second.ID); errs <- err }() + // Only release the first caller once the second one has loaded its own + // account, which happens immediately before it joins the singleflight group. + require.Eventually(t, func() bool { + return repo.getByIDCalls.Load() > loadsBeforeSecond + }, 5*time.Second, time.Millisecond, "the second caller must reach the singleflight group before the first is released") + close(release) + require.NoError(t, <-errs) + require.NoError(t, <-errs) + require.Equal(t, int64(1), upstream.calls.Load()) + require.NotNil(t, decodeOpenCodeGoUsageSnapshot(first.Extra)) + require.Equal(t, decodeOpenCodeGoUsageSnapshot(first.Extra), decodeOpenCodeGoUsageSnapshot(second.Extra)) + + delete(first.Extra, OpenCodeGoUsageSnapshotExtraKey) + delete(second.Extra, OpenCodeGoUsageSnapshotExtraKey) + upstream.beforeResponse = nil + require.NoError(t, svc.RunDue(context.Background())) + require.Equal(t, int64(2), upstream.calls.Load(), "RunDue must issue one request for the shared group") +} + +func TestOpenCodeGoUsageRefreshRejectsGroupChangeBeforeUpstreamRequest(t *testing.T) { + account := openCodeGoUsageAccount(94) + base := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{account.ID: account}} + repo := &openCodeGoRefreshPreflightIdentityChangeRepo{openCodeGoUsageTestRepo: base} + upstream := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + svc := NewOpenCodeGoUsageService(repo, upstream, NewSettingService(&upstreamBillingProbeSettingRepo{}, nil)) + t.Cleanup(svc.Stop) + + _, err := svc.Refresh(context.Background(), account.ID) + + require.ErrorIs(t, err, ErrOpenCodeGoUsageIdentityChanged) + require.Zero(t, upstream.calls.Load()) + require.NotContains(t, account.Extra, OpenCodeGoUsageSnapshotExtraKey) +} + +type openCodeGoRefreshPreflightIdentityChangeRepo struct { + *openCodeGoUsageTestRepo + getCalls atomic.Int64 +} + +func (r *openCodeGoRefreshPreflightIdentityChangeRepo) GetByID(ctx context.Context, id int64) (*Account, error) { + if r.getCalls.Add(1) == 2 { + r.mu.Lock() + r.accounts[id].Credentials["api_key"] = "rotated-before-refresh" + r.mu.Unlock() + } + return r.openCodeGoUsageTestRepo.GetByID(ctx, id) +} + +func TestOpenCodeGoUsageRunnerDisablesAutoRefreshAfterIdentityError(t *testing.T) { + account := openCodeGoUsageAccount(14) + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + missingProxyID := int64(99) + account.ProxyID = &missingProxyID + account.Proxy = nil + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{14: account}} + settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{ + SettingKeyOpenCodeGoUsageSettings: `{"enabled":true,"interval_minutes":15}`, + }} + upstream := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + svc := newOpenCodeGoUsageTestService(t, repo, upstream, settingsRepo) + + require.NoError(t, svc.RunDue(context.Background())) + require.Equal(t, int64(1), repo.disableAutoCalls.Load()) + require.Equal(t, false, account.Extra[OpenCodeGoUsageAutoRefreshExtraKey]) + require.Zero(t, upstream.calls.Load()) + + require.NoError(t, svc.RunDue(context.Background())) + require.Equal(t, int64(1), repo.disableAutoCalls.Load()) + require.Zero(t, upstream.calls.Load()) +} + +func TestOpenCodeGoUsageRunDueRefreshesDueAccounts(t *testing.T) { + account := openCodeGoUsageAccount(7) + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + settingsRepo := &upstreamBillingProbeSettingRepo{} + require.NoError(t, settingsRepo.Set(context.Background(), SettingKeyOpenCodeGoUsageSettings, `{"enabled":true,"interval_minutes":15}`)) + svc := newOpenCodeGoUsageTestService(t, repo, stub, settingsRepo) + + require.NoError(t, svc.RunDue(context.Background())) + require.Equal(t, int64(1), stub.calls.Load()) + require.Equal(t, OpenCodeGoUsageStatusOK, decodeOpenCodeGoUsageSnapshot(account.Extra).Status) +} + +func TestOpenCodeGoUsageRunDueSkipsWhenDisabled(t *testing.T) { + account := openCodeGoUsageAccount(7) + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + require.NoError(t, svc.RunDue(context.Background())) + require.Equal(t, int64(0), stub.calls.Load()) +} + +func TestIsOpenCodeGoUsageAccount(t *testing.T) { + mount := func() *Account { + return &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://opencode.ai/zen/go/v1", "api_key": "k"}} + } + require.True(t, IsOpenCodeGoUsageAccount(mount())) + + // trailing slash on the path is allowed + account := mount() + account.Credentials["base_url"] = "https://opencode.ai/zen/go/v1/" + require.True(t, IsOpenCodeGoUsageAccount(account)) + + // anthropic base URL variant (/zen/go) is allowed + account = mount() + account.Credentials["base_url"] = "https://opencode.ai/zen/go" + require.True(t, IsOpenCodeGoUsageAccount(account)) + account = mount() + account.Credentials["base_url"] = "https://opencode.ai/zen/go/" + require.True(t, IsOpenCodeGoUsageAccount(account)) + + // explicit default port :443 is allowed for both path variants + account = mount() + account.Credentials["base_url"] = "https://opencode.ai:443/zen/go/v1" + require.True(t, IsOpenCodeGoUsageAccount(account)) + account = mount() + account.Credentials["base_url"] = "https://opencode.ai:443/zen/go" + require.True(t, IsOpenCodeGoUsageAccount(account)) + + // non-default port is rejected + account = mount() + account.Credentials["base_url"] = "https://opencode.ai:444/zen/go/v1" + require.False(t, IsOpenCodeGoUsageAccount(account)) + + // scheme/host/path are case-insensitive + account = mount() + account.Credentials["base_url"] = "HTTPS://OPENCODE.AI/ZEN/GO/V1" + require.True(t, IsOpenCodeGoUsageAccount(account)) + + // zen paths are rejected (no subscription quota window) + for _, raw := range []string{ + "https://opencode.ai/zen/v1", "https://opencode.ai/zen", "https://opencode.ai/zen/v1/", + } { + account = mount() + account.Credentials["base_url"] = raw + require.False(t, IsOpenCodeGoUsageAccount(account), raw) + } + + // wrong path + account = mount() + account.Credentials["base_url"] = "https://opencode.ai/v1" + require.False(t, IsOpenCodeGoUsageAccount(account)) + + // wrong host + account = mount() + account.Credentials["base_url"] = "https://ollama.com/zen/go/v1" + require.False(t, IsOpenCodeGoUsageAccount(account)) + + // query string rejected + account = mount() + account.Credentials["base_url"] = "https://opencode.ai/zen/go/v1?x=1" + require.False(t, IsOpenCodeGoUsageAccount(account)) + + // missing base_url + account = mount() + account.Credentials = map[string]any{"api_key": "k"} + require.False(t, IsOpenCodeGoUsageAccount(account)) + + // non-apikey type + account = mount() + account.Type = "oauth" + require.False(t, IsOpenCodeGoUsageAccount(account)) + + // mount platforms: every whitelist platform qualifies with the official base URL + for _, platform := range []string{ + PlatformOpenAI, PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, + } { + account = mount() + account.Platform = platform + require.True(t, IsOpenCodeGoUsageAccount(account), platform) + } + + // non-whitelist platforms never qualify, even with the official base URL + for _, platform := range []string{PlatformGemini, PlatformGrok, PlatformAntigravity} { + account = mount() + account.Platform = platform + require.False(t, IsOpenCodeGoUsageAccount(account), platform) + } + + // opencode_go platform: eligibility comes from platform + go mode, not base_url + platformAccount := func() *Account { + return &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "k"}} + } + require.True(t, IsOpenCodeGoUsageAccount(platformAccount())) // no base_url at all + account = platformAccount() + account.Credentials["base_url"] = "https://opencode.ai/zen/go" // anthropic variant + require.True(t, IsOpenCodeGoUsageAccount(account)) + account = platformAccount() + account.Credentials["base_url"] = "https://relay.example.com/v1" // arbitrary relay + require.True(t, IsOpenCodeGoUsageAccount(account)) + account = platformAccount() + account.Credentials["account_mode"] = AccountModeGo + require.True(t, IsOpenCodeGoUsageAccount(account)) + + // zen mode has no subscription quota window + account = platformAccount() + account.Credentials["account_mode"] = AccountModeZen + require.False(t, IsOpenCodeGoUsageAccount(account)) + + // opencode_go still requires the apikey type + account = platformAccount() + account.Type = "oauth" + require.False(t, IsOpenCodeGoUsageAccount(account)) +} + +func TestOpenCodeGoUsageGroupFingerprint(t *testing.T) { + first := openCodeGoUsageAccount(1) + first.Credentials["api_key"] = "shared-key" + second := openCodeGoUsageAccount(2) + second.Credentials = map[string]any{"base_url": "HTTPS://OPENCODE.AI/ZEN/GO/V1/", "api_key": "shared-key"} + // 同一订阅 key 以 opencode_go 平台账号存在:与挂载平台账号必须同组(身份平台无关) + third := openCodeGoUsageAccount(3) + third.Platform = PlatformOpenCodeGo + third.Credentials = map[string]any{"account_mode": AccountModeGo, "api_key": "shared-key"} + fourth := openCodeGoUsageAccount(4) + fourth.Credentials["api_key"] = "other-key" + // zen 模式的 opencode_go 账号不合格,无指纹 + zen := openCodeGoUsageAccount(5) + zen.Platform = PlatformOpenCodeGo + zen.Credentials = map[string]any{"account_mode": AccountModeZen, "api_key": "shared-key"} + + firstFP, firstOK := openCodeGoUsageGroupFingerprint(first) + secondFP, secondOK := openCodeGoUsageGroupFingerprint(second) + thirdFP, thirdOK := openCodeGoUsageGroupFingerprint(third) + fourthFP, fourthOK := openCodeGoUsageGroupFingerprint(fourth) + _, zenOK := openCodeGoUsageGroupFingerprint(zen) + require.True(t, firstOK) + require.True(t, secondOK) + require.True(t, thirdOK) + require.True(t, fourthOK) + require.False(t, zenOK) + require.Equal(t, firstFP, secondFP, "same api_key across base_url variants must share a group") + require.Equal(t, firstFP, thirdFP, "same api_key across platforms must share a group") + require.NotEqual(t, firstFP, fourthFP, "different api_key must not share a group") + + // ineligible accounts have no fingerprint + account := openCodeGoUsageAccount(6) + account.Credentials["base_url"] = "https://opencode.ai/v1" + _, ok := openCodeGoUsageGroupFingerprint(account) + require.False(t, ok) +} + +func TestScheduleOpenCodeGoUsageActivityOnlyForOpenCode(t *testing.T) { + deferred := &DeferredService{} + openCode := openCodeGoUsageAccount(1) + openCode.Credentials["api_key"] = "k" + other := openCodeGoUsageAccount(2) + other.Credentials["base_url"] = "https://opencode.ai/v1" + + scheduleOpenCodeGoUsageActivity(deferred, openCode) + scheduleOpenCodeGoUsageActivity(deferred, other) + scheduleOpenCodeGoUsageActivity(nil, openCode) + scheduleOpenCodeGoUsageActivity(deferred, nil) +} + +func TestOpenCodeGoUsageSettingsDefaultOffAndValidation(t *testing.T) { + repo := &upstreamBillingProbeSettingRepo{} + settingsService := NewSettingService(repo, nil) + settings, err := settingsService.GetOpenCodeGoUsageSettings(context.Background()) + require.NoError(t, err) + require.False(t, settings.Enabled) + require.Equal(t, 15, settings.IntervalMinutes) + require.Equal(t, 1, settings.DebounceMinutes) + + // below the minimum + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 1}) + require.Error(t, err) + // above the maximum + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 2000}) + require.Error(t, err) + // debounce out of range + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 30, DebounceMinutes: 61}) + require.Error(t, err) + // DebounceMinutes=0 (legacy omit) defaults to 1 on write. + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 30, DebounceMinutes: 0}) + require.NoError(t, err) + settings, err = settingsService.GetOpenCodeGoUsageSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, settings.DebounceMinutes) + // valid update round-trips + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 30, DebounceMinutes: 2}) + require.NoError(t, err) + settings, err = settingsService.GetOpenCodeGoUsageSettings(context.Background()) + require.NoError(t, err) + require.True(t, settings.Enabled) + require.Equal(t, 30, settings.IntervalMinutes) + require.Equal(t, 2, settings.DebounceMinutes) + + // debounce >= interval would make the debounce term unreachable in + // min(lastUsed+debounce, fetchedAt+maxWait), silently ignoring the operator's + // setting, so it is rejected rather than accepted and dropped. + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 5, DebounceMinutes: 5}) + require.Error(t, err, "debounce equal to interval must be rejected") + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 5, DebounceMinutes: 60}) + require.Error(t, err, "debounce greater than interval must be rejected") + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 6, DebounceMinutes: 5}) + require.NoError(t, err, "debounce below interval stays valid") + + // Legacy JSON without debounce_minutes defaults to 1. + repo.values[SettingKeyOpenCodeGoUsageSettings] = `{"enabled":true,"interval_minutes":45}` + settings, err = settingsService.GetOpenCodeGoUsageSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, 45, settings.IntervalMinutes) + require.Equal(t, 1, settings.DebounceMinutes) +} + +func TestOpenCodeGoUsageStateFromAccount(t *testing.T) { + account := openCodeGoUsageAccount(7) + state := OpenCodeGoUsageStateFromAccount(account) + require.True(t, state.Eligible) + require.False(t, state.AutoRefreshEnabled) + require.Nil(t, state.Snapshot) + + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + account.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{Status: OpenCodeGoUsageStatusOK} + state = OpenCodeGoUsageStateFromAccount(account) + require.True(t, state.AutoRefreshEnabled) + require.NotNil(t, state.Snapshot) + require.Equal(t, OpenCodeGoUsageStatusOK, state.Snapshot.Status) + + // ineligible account exposes no managed state + // (anthropic 已是挂载白名单成员,改用非白名单平台验证不合格路径) + account.Platform = PlatformGemini + state = OpenCodeGoUsageStateFromAccount(account) + require.False(t, state.Eligible) + require.Nil(t, state.Snapshot) +} + +func TestOpenCodeGoUsageRefreshRejectsIneligibleAccount(t *testing.T) { + account := openCodeGoUsageAccount(7) + account.Credentials["base_url"] = "https://opencode.ai/v1" + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + _, err := svc.Refresh(context.Background(), 7) + require.Error(t, err) + require.True(t, errors.Is(err, ErrOpenCodeGoUsageAccountInvalid)) + require.Equal(t, int64(0), stub.calls.Load()) +} diff --git a/backend/internal/service/payment_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go index b1366c3c96..0a7d1b8895 100644 --- a/backend/internal/service/payment_fulfillment_test.go +++ b/backend/internal/service/payment_fulfillment_test.go @@ -150,6 +150,10 @@ func (r *paymentFulfillmentAffiliateRepoStub) TransferQuotaToBalance(context.Con panic("unexpected TransferQuotaToBalance call") } +func (r *paymentFulfillmentAffiliateRepoStub) WithdrawQuota(context.Context, int64, float64, string) (*AffiliateWithdrawResult, error) { + panic("unexpected WithdrawQuota call") +} + func (r *paymentFulfillmentAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]AffiliateInvitee, error) { panic("unexpected ListInvitees call") } diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 701526252b..0b873a698b 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -19,6 +19,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" @@ -73,6 +74,48 @@ var ( Mode: "chat", SupportsPromptCaching: true, } + openAIGPT6SolFallbackPricing = &LiteLLMModelPricing{ + InputCostPerToken: 2e-6, + InputCostPerTokenPriority: 4e-6, + OutputCostPerToken: 10e-6, + OutputCostPerTokenPriority: 20e-6, + CacheCreationInputTokenCost: 2.5e-6, + CacheCreationInputTokenCostPriority: 5e-6, + CacheReadInputTokenCost: 0.2e-6, + CacheReadInputTokenCostPriority: 0.4e-6, + LongContextInputTokenThreshold: 272_000, + LongContextInputCostMultiplier: 2, + LongContextOutputCostMultiplier: 1.5, + SupportsServiceTier: true, + LiteLLMProvider: "openai", + Mode: "chat", + SupportsPromptCaching: true, + } + openAIGPT6LunaFallbackPricing = &LiteLLMModelPricing{ + InputCostPerToken: 0.1e-6, + InputCostPerTokenPriority: 0.2e-6, + OutputCostPerToken: 0.5e-6, + OutputCostPerTokenPriority: 1e-6, + CacheCreationInputTokenCost: 0.125e-6, + CacheCreationInputTokenCostPriority: 0.25e-6, + CacheReadInputTokenCost: 0.01e-6, + CacheReadInputTokenCostPriority: 0.02e-6, + LongContextInputTokenThreshold: 272_000, + LongContextInputCostMultiplier: 2, + LongContextOutputCostMultiplier: 1.5, + SupportsServiceTier: true, + LiteLLMProvider: "openai", + Mode: "chat", + SupportsPromptCaching: true, + } + claudeOpus55FallbackPricing = &LiteLLMModelPricing{ + InputCostPerToken: 4e-6, OutputCostPerToken: 20e-6, + CacheCreationInputTokenCost: 5e-6, CacheCreationInputTokenCostAbove1hr: 8e-6, + CacheReadInputTokenCost: 0.2e-6, + InputCostPerTokenPriority: 8e-6, OutputCostPerTokenPriority: 40e-6, + CacheCreationInputTokenCostPriority: 10e-6, CacheReadInputTokenCostPriority: 0.4e-6, + SupportsServiceTier: true, LiteLLMProvider: "anthropic", Mode: "chat", SupportsPromptCaching: true, + } openAIGPT56SolFallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 5e-06, InputCostPerTokenPriority: 1e-05, @@ -136,6 +179,7 @@ var ( // LiteLLMModelPricing LiteLLM价格数据结构 // 只保留我们需要的字段,使用指针来处理可能缺失的值 type LiteLLMModelPricing struct { + CacheCreationInputTokenCostExplicit bool `json:"-"` InputCostPerToken float64 `json:"input_cost_per_token"` InputCostPerTokenPriority float64 `json:"input_cost_per_token_priority"` OutputCostPerToken float64 `json:"output_cost_per_token"` @@ -628,6 +672,7 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel } if entry.CacheCreationInputTokenCost != nil { pricing.CacheCreationInputTokenCost = *entry.CacheCreationInputTokenCost + pricing.CacheCreationInputTokenCostExplicit = true } if entry.CacheCreationInputTokenCostPriority != nil { pricing.CacheCreationInputTokenCostPriority = *entry.CacheCreationInputTokenCostPriority @@ -1328,6 +1373,12 @@ func (s *PricingService) extractBaseName(model string) string { // matchByModelFamily 基于模型系列匹配 func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing { + if claude.IsOpus55(model) { + if pricing, ok := s.pricingData["claude-opus-5-5"]; ok { + return pricing + } + return claudeOpus55FallbackPricing + } // modelFamily 定义一个模型系列的匹配和定价查找规则。 type modelFamily struct { name string // 系列名称 @@ -1428,6 +1479,9 @@ func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing { for _, pattern := range lookups { for key, pricing := range s.pricingData { keyLower := strings.ToLower(key) + if matched.name == "opus-5" && claude.IsOpus55(keyLower) { + continue + } if strings.Contains(keyLower, pattern) { logger.LegacyPrintf("service.pricing", "[Pricing] Fuzzy matched %s -> %s", model, key) return pricing @@ -1456,6 +1510,16 @@ func (s *PricingService) matchOpenAIModel(model string) *LiteLLMModelPricing { } } + if openai.IsGPT6SolOrLunaModelSpelling(model) { + if pricing, ok := s.pricingData[normalizeKnownOpenAICodexModel(model)]; ok { + return pricing + } + if strings.HasPrefix(model, "gpt-6-sol") { + return openAIGPT6SolFallbackPricing + } + return openAIGPT6LunaFallbackPricing + } + // 尝试的回退变体 variants := s.generateOpenAIModelVariants(model, openAIModelDatePattern) diff --git a/backend/internal/service/setting_claude_code_version_test.go b/backend/internal/service/setting_claude_code_version_test.go new file mode 100644 index 0000000000..73d8ca9015 --- /dev/null +++ b/backend/internal/service/setting_claude_code_version_test.go @@ -0,0 +1,64 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/stretchr/testify/require" +) + +func TestGetClaudeCodeClientVersionPriority(t *testing.T) { + for _, tt := range []struct { + name string + manual string + synced string + want string + }{ + {name: "手动值优先", manual: "2.1.280", synced: "2.1.281", want: "2.1.280"}, + {name: "归一化手动值", manual: " v2.1.280 ", synced: "2.1.281", want: "2.1.280"}, + {name: "跟随同步值", synced: "2.1.281", want: "2.1.281"}, + {name: "无效手动值回退", manual: "invalid", synced: "2.1.281", want: "2.1.281"}, + {name: "无效同步值回退", synced: "2.1.9", want: claude.CLIVersion()}, + {name: "未配置时回退", want: claude.CLIVersion()}, + } { + t.Run(tt.name, func(t *testing.T) { + repo := &authSourceDefaultsRepoStub{values: map[string]string{ + SettingKeyClaudeCodeClientVersion: tt.manual, + SettingKeyClaudeCodeClientVersionSynced: tt.synced, + }} + svc := NewSettingService(repo, &config.Config{}) + require.Equal(t, tt.want, svc.GetClaudeCodeClientVersion(context.Background())) + }) + } +} + +func TestUpdateSettingsClaudeCodeVersionTakesEffectImmediately(t *testing.T) { + ctx := context.Background() + repo := &authSourceDefaultsRepoStub{values: map[string]string{ + SettingKeyClaudeCodeClientVersionSynced: "2.1.281", + }} + svc := NewSettingService(repo, &config.Config{}) + resetGatewayForwardingSettingsCacheForTest(t) + defer svc.refreshCachedSettings(&SystemSettings{}) + require.Equal(t, "2.1.281", svc.GetClaudeCodeClientVersion(ctx)) + + settings := &SystemSettings{ + ClaudeCodeClientVersion: "2.1.280", + ClaudeCodeVersionAutoSyncEnabled: true, + } + require.NoError(t, svc.UpdateSettings(ctx, settings)) + require.Equal(t, "2.1.280", svc.GetClaudeCodeClientVersion(ctx), "保存手动版本后应立即生效") + require.NotContains(t, repo.updates, SettingKeyClaudeCodeClientVersionSynced) + require.Equal(t, "2.1.281", repo.values[SettingKeyClaudeCodeClientVersionSynced]) + + // 清空手动值并关闭后续同步时,仍保留并使用已有同步值。 + settings.ClaudeCodeClientVersion = "" + settings.ClaudeCodeVersionAutoSyncEnabled = false + require.NoError(t, svc.UpdateSettings(ctx, settings)) + require.Equal(t, "2.1.281", svc.GetClaudeCodeClientVersion(ctx)) + require.Equal(t, "false", repo.values[SettingKeyClaudeCodeVersionAutoSyncEnabled]) +} diff --git a/backend/internal/service/setting_gateway_runtime.go b/backend/internal/service/setting_gateway_runtime.go index bb59ba8526..8f81c1c0bb 100644 --- a/backend/internal/service/setting_gateway_runtime.go +++ b/backend/internal/service/setting_gateway_runtime.go @@ -12,6 +12,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "golang.org/x/sync/singleflight" ) @@ -119,6 +120,19 @@ const openAICodexClientVersionDBTimeout = 5 * time.Second // openAICodexClientVersionSFKey singleflight 键。 const openAICodexClientVersionSFKey = "openai_codex_client_version" +// cachedClaudeCodeClientVersion 缓存出站 Claude Code 客户端版本号(进程内缓存,60s TTL) +type cachedClaudeCodeClientVersion struct { + version string + expiresAt int64 // unix nano +} + +const claudeCodeClientVersionCacheTTL = 60 * time.Second +const claudeCodeClientVersionErrorTTL = 5 * time.Second +const claudeCodeClientVersionDBTimeout = 5 * time.Second + +// claudeCodeClientVersionSFKey singleflight 键。 +const claudeCodeClientVersionSFKey = "claude_code_client_version" + type cachedOpenAIQuotaAutoPauseSettings struct { settings OpsOpenAIAccountQuotaAutoPauseSettings expiresAt int64 @@ -378,6 +392,101 @@ func (s *SettingService) InvalidateOpenAICodexClientVersionCache() { s.openAICodexVersionCache.Store((*cachedOpenAICodexClientVersion)(nil)) } +// NormalizeClaudeCodeClientVersion 校验并归一化 Claude Code 客户端版本号,非法值返回空串。 +// 容忍前导 "v" 与首尾空白;合法性复用 claude.IsSupportedCLIVersion(严格三段纯数字 semver、 +// 无预发布/构建后缀、且不低于内置基线)。 +func NormalizeClaudeCodeClientVersion(version string) string { + normalized := strings.TrimSpace(version) + normalized = strings.TrimPrefix(normalized, "v") + if normalized == "" { + return "" + } + if !claude.IsSupportedCLIVersion(normalized) { + return "" + } + return normalized +} + +// GetClaudeCodeClientVersion 返回出站声明的 Claude Code CLI 客户端版本号。 +// 优先级:管理员在面板覆写的版本 → 自动同步到的官方最新版本 → claude.CLIVersion() +// (环境变量 SUB2API_CLAUDE_CLI_VERSION 覆盖 + 内置基线)。 +// 版本太旧会被 Anthropic 拒绝(HTTP 400 claude_code_version_too_old),故该值需保持跟随官方发布。 +// +// ⚠️ 一致性约束:同一次请求里,拼 User-Agent(claude-cli/<版本>)的版本号和用于 +// billing attribution 的版本号必须是同一个值,否则 Anthropic 侧对不上、判为非正版客户端。 +// 因此调用点必须在一次请求内只取一次本函数并复用;本缓存的唯一主动变更点是 +// 同步任务写入后调用 InvalidateClaudeCodeClientVersionCache,60s TTL 不会在极短时间内抖动。 +func (s *SettingService) GetClaudeCodeClientVersion(ctx context.Context) string { + fallback := claude.CLIVersion() + if s == nil || s.settingRepo == nil { + return fallback + } + if cached, ok := s.claudeCodeVersionCache.Load().(*cachedClaudeCodeClientVersion); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.version + } + } + + result, _, _ := s.claudeCodeVersionSF.Do(claudeCodeClientVersionSFKey, func() (any, error) { + if cached, ok := s.claudeCodeVersionCache.Load().(*cachedClaudeCodeClientVersion); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.version, nil + } + } + if ctx == nil { + ctx = context.Background() + } + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), claudeCodeClientVersionDBTimeout) + defer cancel() + values, err := s.settingRepo.GetMultiple(dbCtx, []string{ + SettingKeyClaudeCodeClientVersion, + SettingKeyClaudeCodeClientVersionSynced, + }) + if err != nil { + slog.Warn("failed to get claude code client version setting", "error", err) + s.claudeCodeVersionCache.Store(&cachedClaudeCodeClientVersion{ + version: fallback, + expiresAt: time.Now().Add(claudeCodeClientVersionErrorTTL).UnixNano(), + }) + return fallback, nil + } + version := NormalizeClaudeCodeClientVersion(values[SettingKeyClaudeCodeClientVersion]) + if version == "" { + if raw := values[SettingKeyClaudeCodeClientVersion]; strings.TrimSpace(raw) != "" { + slog.Warn("ignoring invalid claude_code_client_version setting; falling back to the next layer", + "value", raw) + } + version = NormalizeClaudeCodeClientVersion(values[SettingKeyClaudeCodeClientVersionSynced]) + if version == "" && strings.TrimSpace(values[SettingKeyClaudeCodeClientVersionSynced]) != "" { + slog.Warn("ignoring invalid claude_code_client_version_synced setting; falling back to the built-in pin", + "value", values[SettingKeyClaudeCodeClientVersionSynced]) + } + } + if version == "" { + version = fallback + } + s.claudeCodeVersionCache.Store(&cachedClaudeCodeClientVersion{ + version: version, + expiresAt: time.Now().Add(claudeCodeClientVersionCacheTTL).UnixNano(), + }) + return version, nil + }) + if version, ok := result.(string); ok && version != "" { + return version + } + return fallback +} + +// InvalidateClaudeCodeClientVersionCache 丢弃版本号缓存,下次读取回源。 +// 面板保存与自动同步写入后调用。 +func (s *SettingService) InvalidateClaudeCodeClientVersionCache() { + if s == nil { + return + } + s.claudeCodeVersionSF.Forget(claudeCodeClientVersionSFKey) + s.claudeCodeVersionCache.Store((*cachedClaudeCodeClientVersion)(nil)) +} + // GetOpenAICodexCanonicalUserAgent 返回出站规范 Codex User-Agent。 // 未填面板 UA 时按当前生效的客户端版本号拼出标准 Codex TUI UA。 // diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index d6497775cf..7c62451d33 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -246,6 +246,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyOpenAICodexClientVersion: "", SettingKeyOpenAICodexClientVersionSynced: "", SettingKeyOpenAICodexVersionAutoSyncEnabled: "true", + SettingKeyClaudeCodeClientVersion: "", + SettingKeyClaudeCodeClientVersionSynced: "", + SettingKeyClaudeCodeVersionAutoSyncEnabled: "true", SettingPaymentVisibleMethodAlipaySource: "", SettingPaymentVisibleMethodWxpaySource: "", SettingPaymentVisibleMethodAlipayEnabled: "false", @@ -891,6 +894,14 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } else { result.OpenAICodexVersionAutoSyncEnabled = true } + result.ClaudeCodeClientVersion = NormalizeClaudeCodeClientVersion(settings[SettingKeyClaudeCodeClientVersion]) + result.ClaudeCodeClientVersionSynced = NormalizeClaudeCodeClientVersion(settings[SettingKeyClaudeCodeClientVersionSynced]) + // 自动同步默认开启:缺失/空值一律视为开启,与 openai_codex_version_auto_sync_enabled 同一惯例。 + if v, ok := settings[SettingKeyClaudeCodeVersionAutoSyncEnabled]; ok && v != "" { + result.ClaudeCodeVersionAutoSyncEnabled = v == "true" + } else { + result.ClaudeCodeVersionAutoSyncEnabled = true + } // codex_cli_only 加固 result.MinCodexVersion = settings[SettingKeyMinCodexVersion] result.MaxCodexVersion = settings[SettingKeyMaxCodexVersion] diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 369d255294..6559249ada 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -130,6 +130,8 @@ type SettingService struct { openAICodexUASF singleflight.Group openAICodexVersionCache atomic.Value // *cachedOpenAICodexClientVersion openAICodexVersionSF singleflight.Group + claudeCodeVersionCache atomic.Value // *cachedClaudeCodeClientVersion + claudeCodeVersionSF singleflight.Group codexRestrictionPolicyCache atomic.Value // *cachedCodexRestrictionPolicy codexRestrictionPolicySF singleflight.Group diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 873d40849a..421b226524 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -487,6 +487,10 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyOpenAICodexVersionAutoSyncEnabled] = strconv.FormatBool(settings.OpenAICodexVersionAutoSyncEnabled) // SettingKeyOpenAICodexClientVersionSynced 由自动同步任务独占写入,此处不得覆盖, // 否则面板保存会把同步结果清空。 + updates[SettingKeyClaudeCodeClientVersion] = NormalizeClaudeCodeClientVersion(settings.ClaudeCodeClientVersion) + updates[SettingKeyClaudeCodeVersionAutoSyncEnabled] = strconv.FormatBool(settings.ClaudeCodeVersionAutoSyncEnabled) + // SettingKeyClaudeCodeClientVersionSynced 由自动同步任务独占写入,此处不得覆盖, + // 否则面板保存会把同步结果清空。 // codex_cli_only 加固 updates[SettingKeyMinCodexVersion] = strings.TrimSpace(settings.MinCodexVersion) updates[SettingKeyMaxCodexVersion] = strings.TrimSpace(settings.MaxCodexVersion) @@ -742,6 +746,7 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { // 版本号缓存只做失效,不在此重算:生效值还取决于自动同步写入的 synced 键, // 这里没有它的最新值,重算会把同步结果覆盖成陈旧值。 s.InvalidateOpenAICodexClientVersionCache() + s.InvalidateClaudeCodeClientVersionCache() openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey) openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{ lowUpstreamRatePriorityEnabled: settings.OpenAILowUpstreamRatePriorityEnabled, diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index a305d390cf..883d680319 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -250,6 +250,9 @@ type SystemSettings struct { OpenAICodexClientVersion string // 出站声明的 Codex 客户端版本号(管理员覆写);空值跟随自动同步值 OpenAICodexClientVersionSynced string // 自动同步到的官方最新稳定版版本号(只读展示) OpenAICodexVersionAutoSyncEnabled bool // 是否启用 Codex 客户端版本号自动同步(默认 true) + ClaudeCodeClientVersion string // 出站声明的 Claude Code CLI 客户端版本号(管理员覆写);空值跟随自动同步值 + ClaudeCodeClientVersionSynced string // 自动同步到的官方最新版本号(只读展示) + ClaudeCodeVersionAutoSyncEnabled bool // 是否启用 Claude Code 客户端版本号自动同步(默认 true) MinCodexVersion string // codex_cli_only 最低 Codex 引擎版本;空=不检查 MaxCodexVersion string // codex_cli_only 最高 Codex 引擎版本;空=不检查 CodexCLIOnlyBlacklist string // codex_cli_only 全局黑名单 JSON([]AllowedClientEntry,OR deny) diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 23ae22fa5d..7596dea131 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -942,7 +942,7 @@ func (s *AccountTestService) buildAnthropicUpstreamModelsRequest(ctx context.Con if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid Anthropic model list URL", err) } - for key, value := range claude.DefaultHeaders { + for key, value := range claude.DefaultHeaders() { req.Header.Set(key, value) } req.Header.Set("Accept", "application/json") @@ -990,7 +990,7 @@ func (s *AccountTestService) buildAntigravityAPIKeyModelsRequest(ctx context.Con if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid Antigravity model list URL", err) } - for key, value := range claude.DefaultHeaders { + for key, value := range claude.DefaultHeaders() { req.Header.Set(key, value) } req.Header.Set("Accept", "application/json") diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index e5aa732098..31362723b5 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -10,6 +10,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/payment" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/google/wire" @@ -415,6 +416,18 @@ func ProvideOpenAICodexVersionSyncService( return svc } +// ProvideClaudeCodeVersionSyncService creates and starts ClaudeCodeVersionSyncService. +// 出站 Claude Code 身份的版本号靠它跟随官方发布,无需为了跟版本而发新版本;面板可关闭。 +func ProvideClaudeCodeVersionSyncService( + settingRepo SettingRepository, + settingService *SettingService, + githubClient GitHubReleaseClient, +) *ClaudeCodeVersionSyncService { + svc := NewClaudeCodeVersionSyncService(settingRepo, settingService, githubClient, claudeCodeVersionSyncInterval) + svc.Start() + return svc +} + // ProvideProxyExpiryService creates and starts ProxyExpiryService. func ProvideProxyExpiryService(proxyRepo ProxyRepository) *ProxyExpiryService { svc := NewProxyExpiryService(proxyRepo, time.Minute) @@ -794,6 +807,11 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit SetCodexCanonicalUserAgentResolver(func() string { return svc.GetOpenAICodexCanonicalUserAgent(context.Background()) }) + // Claude CLI 伪装版本号同理:运行期解析(面板手动值 → 后台同步值 → 内置基线), + // 解析器内部自带 60s TTL 缓存,热路径不触库。 + claude.SetCLIVersionResolver(func() string { + return svc.GetClaudeCodeClientVersion(context.Background()) + }) return svc } @@ -889,6 +907,7 @@ var ProviderSet = wire.NewSet( ProvideAccountTestService, ProvideUpstreamBillingProbeService, ProvideOllamaCloudUsageService, + ProvideOpenCodeGoUsageService, ProvideSettingService, NewDataManagementService, ProvideBackupService, @@ -920,6 +939,7 @@ var ProviderSet = wire.NewSet( wire.Bind(new(GrokOAuthReconciler), new(*TokenRefreshService)), ProvideAccountExpiryService, ProvideOpenAICodexVersionSyncService, + ProvideClaudeCodeVersionSyncService, ProvideProxyExpiryService, ProvideSubscriptionExpiryService, ProvideTimingWheelService, diff --git a/backend/migrations/240_affiliate_ledger_operation_id.sql b/backend/migrations/240_affiliate_ledger_operation_id.sql new file mode 100644 index 0000000000..5a97f41821 --- /dev/null +++ b/backend/migrations/240_affiliate_ledger_operation_id.sql @@ -0,0 +1,10 @@ +-- 管理员登记线下提现的幂等标识:同一 operation_id 只对应一条流水, +-- 重试同一次登记时据此返回首次登记的结果,不重复扣减。 +ALTER TABLE user_affiliate_ledger + ADD COLUMN IF NOT EXISTS operation_id VARCHAR(64); + +COMMENT ON COLUMN user_affiliate_ledger.operation_id IS '管理员登记线下提现的幂等标识(由 Idempotency-Key 派生的 SHA-256 十六进制串);其他流水为 NULL'; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_affiliate_ledger_operation_id + ON user_affiliate_ledger (operation_id) + WHERE operation_id IS NOT NULL; diff --git a/backend/resources/model-pricing/model_prices_and_context_window.json b/backend/resources/model-pricing/model_prices_and_context_window.json index bab05e4188..2ba65b23c2 100644 --- a/backend/resources/model-pricing/model_prices_and_context_window.json +++ b/backend/resources/model-pricing/model_prices_and_context_window.json @@ -10,6 +10,154 @@ "output_cost_per_token_above_200k_tokens": 1.2e-05, "supports_prompt_caching": true }, + "gpt-6-sol": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_batches": 1.25e-06, + "cache_creation_input_token_cost_flex": 1.25e-06, + "cache_creation_input_token_cost_priority": 5e-06, + "cache_read_input_token_cost": 2e-07, + "cache_read_input_token_cost_flex": 1e-07, + "cache_read_input_token_cost_priority": 4e-07, + "input_cost_per_token": 2e-06, + "input_cost_per_token_batches": 1e-06, + "input_cost_per_token_flex": 1e-06, + "input_cost_per_token_priority": 4e-06, + "long_context_input_token_threshold": 272000, + "long_context_input_cost_multiplier": 2.0, + "long_context_output_cost_multiplier": 1.5, + "litellm_provider": "openai", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-05, + "output_cost_per_token_batches": 5e-06, + "output_cost_per_token_flex": 5e-06, + "output_cost_per_token_priority": 2e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true, + "cache_read_input_token_cost_batches": 1e-07, + "context_window": 1050000 + }, + "gpt-6-luna": { + "cache_creation_input_token_cost": 1.25e-07, + "cache_creation_input_token_cost_batches": 6.25e-08, + "cache_creation_input_token_cost_flex": 6.25e-08, + "cache_creation_input_token_cost_priority": 2.5e-07, + "cache_read_input_token_cost": 1e-08, + "cache_read_input_token_cost_flex": 5e-09, + "cache_read_input_token_cost_priority": 2e-08, + "input_cost_per_token": 1e-07, + "input_cost_per_token_batches": 5e-08, + "input_cost_per_token_flex": 5e-08, + "input_cost_per_token_priority": 2e-07, + "long_context_input_token_threshold": 272000, + "long_context_input_cost_multiplier": 2.0, + "long_context_output_cost_multiplier": 1.5, + "litellm_provider": "openai", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-07, + "output_cost_per_token_batches": 2.5e-07, + "output_cost_per_token_flex": 2.5e-07, + "output_cost_per_token_priority": 1e-06, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/batch", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true, + "cache_read_input_token_cost_batches": 5e-09, + "context_window": 1050000 + }, + "claude-opus-5-5": { + "cache_creation_input_token_cost": 5e-06, + "cache_creation_input_token_cost_above_1hr": 8e-06, + "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 4e-06, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-05, + "provider_specific_entry": { + "fast": 2.0 + }, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": false, + "supports_output_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "input_cost_per_token_priority": 8e-06, + "output_cost_per_token_priority": 4e-05, + "cache_creation_input_token_cost_priority": 1e-05, + "cache_read_input_token_cost_priority": 4e-07, + "input_cost_per_token_batches": 2e-06, + "output_cost_per_token_batches": 1e-05 + }, "claude-3-7-sonnet-20250219": { "cache_creation_input_token_cost": 3.75e-06, "cache_creation_input_token_cost_above_1hr": 6e-06, diff --git a/frontend/src/api/__tests__/admin.accounts.opencodeGoUsage.spec.ts b/frontend/src/api/__tests__/admin.accounts.opencodeGoUsage.spec.ts new file mode 100644 index 0000000000..845f2656e2 --- /dev/null +++ b/frontend/src/api/__tests__/admin.accounts.opencodeGoUsage.spec.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { get, post, put } = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), + put: vi.fn() +})) + +vi.mock('@/api/client', () => ({ + apiClient: { get, post, put } +})) + +import { + getOpenCodeGoUsage, + getOpenCodeGoUsageSettings, + refreshOpenCodeGoUsage, + setOpenCodeGoUsageAutoRefresh, + updateOpenCodeGoUsageSettings +} from '@/api/admin/accounts' + +const state = { + account_id: 7, + eligible: true, + auto_refresh_enabled: false +} + +describe('admin OpenCode Go usage API', () => { + beforeEach(() => { + get.mockReset() + post.mockReset() + put.mockReset() + }) + + it('uses dedicated global settings endpoints', async () => { + const settings = { enabled: false, interval_minutes: 60 } + get.mockResolvedValueOnce({ data: settings }) + put.mockResolvedValueOnce({ data: settings }) + + await expect(getOpenCodeGoUsageSettings()).resolves.toEqual(settings) + await expect(updateOpenCodeGoUsageSettings(settings)).resolves.toEqual(settings) + expect(get).toHaveBeenCalledWith('/admin/accounts/opencode-go-usage/settings') + expect(put).toHaveBeenCalledWith('/admin/accounts/opencode-go-usage/settings', settings) + }) + + it('reads account state, toggles auto-refresh and triggers a manual refresh', async () => { + get.mockResolvedValueOnce({ data: state }) + put.mockResolvedValueOnce({ data: { ...state, auto_refresh_enabled: true } }) + post.mockResolvedValueOnce({ data: state }) + + await expect(getOpenCodeGoUsage(7)).resolves.toEqual(state) + await expect(setOpenCodeGoUsageAutoRefresh(7, true)).resolves.toMatchObject({ auto_refresh_enabled: true }) + await expect(refreshOpenCodeGoUsage(7)).resolves.toEqual(state) + + expect(get).toHaveBeenCalledWith('/admin/accounts/7/opencode-go-usage') + expect(put).toHaveBeenCalledWith('/admin/accounts/7/opencode-go-usage/auto-refresh', { enabled: true }) + expect(post).toHaveBeenCalledWith('/admin/accounts/7/opencode-go-usage/refresh') + }) +}) diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 349f4328f4..e504775fa3 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -29,7 +29,9 @@ import type { OllamaCloudUsageSettings, OllamaCloudUsageState, GrokMediaEligibilityMode, - GrokMediaEligibilityState + GrokMediaEligibilityState, + OpenCodeGoUsageSettings, + OpenCodeGoUsageState } from '@/types' /** @@ -1097,6 +1099,38 @@ export async function refreshOllamaCloudUsage(id: number): Promise { + const { data } = await apiClient.get('/admin/accounts/opencode-go-usage/settings') + return data +} + +export async function updateOpenCodeGoUsageSettings( + settings: OpenCodeGoUsageSettings +): Promise { + const { data } = await apiClient.put( + '/admin/accounts/opencode-go-usage/settings', + settings + ) + return data +} + +export async function getOpenCodeGoUsage(id: number): Promise { + const { data } = await apiClient.get(`/admin/accounts/${id}/opencode-go-usage`) + return data +} + +export async function setOpenCodeGoUsageAutoRefresh(id: number, enabled: boolean): Promise { + const { data } = await apiClient.put(`/admin/accounts/${id}/opencode-go-usage/auto-refresh`, { + enabled + }) + return data +} + +export async function refreshOpenCodeGoUsage(id: number): Promise { + const { data } = await apiClient.post(`/admin/accounts/${id}/opencode-go-usage/refresh`) + return data +} + export const accountsAPI = { list, listWithEtag, @@ -1160,7 +1194,12 @@ export const accountsAPI = { saveOllamaCloudUsageSession, deleteOllamaCloudUsageSession, setOllamaCloudUsageAutoRefresh, - refreshOllamaCloudUsage + refreshOllamaCloudUsage, + getOpenCodeGoUsageSettings, + updateOpenCodeGoUsageSettings, + getOpenCodeGoUsage, + setOpenCodeGoUsageAutoRefresh, + refreshOpenCodeGoUsage } export default accountsAPI diff --git a/frontend/src/api/admin/affiliates.ts b/frontend/src/api/admin/affiliates.ts index dadb0ae9e5..570bbf1fe7 100644 --- a/frontend/src/api/admin/affiliates.ts +++ b/frontend/src/api/admin/affiliates.ts @@ -46,25 +46,35 @@ export interface AffiliateInviteRecord { created_at: string } +/** + * One rebate accrual. Non-order sources (redeem codes, admin recharges) carry no + * order, so order fields are null; invitee_id is null when the invitee account + * was deleted. + */ export interface AffiliateRebateRecord { - order_id: number + order_id: number | null out_trade_no: string inviter_id: number inviter_email: string inviter_username: string - invitee_id: number + invitee_id: number | null invitee_email: string invitee_username: string - order_amount: number - pay_amount: number + order_amount: number | null + pay_amount: number | null rebate_amount: number payment_type: string order_status: string created_at: string } +/** transfer = user moved quota into balance; withdraw = admin-recorded offline withdrawal. */ +export type AffiliateOutflowAction = 'transfer' | 'withdraw' + +/** One affiliate quota outflow: a transfer into balance or an offline withdrawal. */ export interface AffiliateTransferRecord { ledger_id: number + action: AffiliateOutflowAction user_id: number user_email: string username: string @@ -77,6 +87,26 @@ export interface AffiliateTransferRecord { created_at: string } +export interface WithdrawAffiliateQuotaRequest { + /** Amount already paid to the user outside the site (USD). */ + amount: number +} + +export interface AffiliateWithdrawResult { + ledger_id: number + user_id: number + amount: number + available_quota_after: number + frozen_quota_after: number + history_quota_after: number +} + +export interface AffiliateWithdrawResponse { + result: AffiliateWithdrawResult + /** True when the key matched an earlier registration and nothing was deducted again. */ + replayed: boolean +} + export interface AffiliateUserOverview { user_id: number email: string @@ -215,6 +245,27 @@ export async function getUserOverview( return data } +/** + * Records an offline withdrawal. `idempotencyKey` identifies one registration: + * a retry with the same key deducts nothing and returns the first result, with + * `replayed` set. + */ +export async function withdrawUserQuota( + userId: number, + payload: WithdrawAffiliateQuotaRequest, + idempotencyKey: string, +): Promise { + const response = await apiClient.post( + `/admin/affiliates/users/${userId}/withdraw`, + payload, + { headers: { 'Idempotency-Key': idempotencyKey } }, + ) + return { + result: response.data, + replayed: response.headers?.['x-idempotency-replayed'] === 'true', + } +} + export const affiliatesAPI = { listUsers, lookupUsers, @@ -225,6 +276,7 @@ export const affiliatesAPI = { listRebateRecords, listTransferRecords, getUserOverview, + withdrawUserQuota, } export default affiliatesAPI diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 4f0e66b086..7e551c9c76 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -639,6 +639,9 @@ export interface SystemSettings { openai_codex_client_version: string; openai_codex_client_version_synced: string; openai_codex_version_auto_sync_enabled: boolean; + claude_code_client_version: string; + claude_code_client_version_synced: string; + claude_code_version_auto_sync_enabled: boolean; // codex_cli_only 加固 min_codex_version: string; max_codex_version: string; @@ -959,6 +962,8 @@ export interface UpdateSettingsRequest { openai_codex_user_agent?: string; openai_codex_client_version?: string; openai_codex_version_auto_sync_enabled?: boolean; + claude_code_client_version?: string; + claude_code_version_auto_sync_enabled?: boolean; // codex_cli_only 加固 min_codex_version?: string; max_codex_version?: string; diff --git a/frontend/src/components/account/AccountStatusIndicator.vue b/frontend/src/components/account/AccountStatusIndicator.vue index 64bcfbf594..2130e257ac 100644 --- a/frontend/src/components/account/AccountStatusIndicator.vue +++ b/frontend/src/components/account/AccountStatusIndicator.vue @@ -230,6 +230,7 @@ const formatScopeName = (scope: string): string => { 'claude-opus-4-6-thinking': 'COpus46T', 'claude-opus-4-7': 'COpus47', 'claude-opus-4-8': 'COpus48', + 'claude-opus-5-5': 'COpus55', 'claude-opus-5': 'COpus5', 'claude-sonnet-4-6': 'CSon46', 'claude-sonnet-4-5': 'CSon45', diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 72a92dd865..36e708e08a 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -441,6 +441,20 @@ :account="account" @updated="handleOllamaCloudUsageUpdated" /> + +
@@ -581,6 +595,13 @@ :account="account" @updated="handleOllamaCloudUsageUpdated" /> + +
-
@@ -662,6 +683,7 @@ import CNProviderQuotaCell from './CNProviderQuotaCell.vue' import CNProviderBalanceCell from './CNProviderBalanceCell.vue' import OllamaCloudUsageCell from './OllamaCloudUsageCell.vue' import { cnQuotaCellVisible as cnQuotaCellVisibleFn, cnBalanceCellVisible as cnBalanceCellVisibleFn } from './credentialsBuilder' +import OpenCodeGoUsageCell from './OpenCodeGoUsageCell.vue' // Module-level cache shared across all AccountUsageCell instances const _usageCache = new Map() @@ -1561,6 +1583,10 @@ const handleOllamaCloudUsageUpdated = (state: NonNullable) => { + emit('account-updated', { ...props.account, opencode_go_usage: state }) +} + // ===== Key account today stats formatters ===== const formatKeyRequests = computed(() => { diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 4e9bbff7a1..aced3a016d 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1992,6 +1992,91 @@ @updated="handleOllamaCloudUsageUpdated" /> +
+
+
+

+ {{ t('admin.accounts.opencodeGo.title') }} +

+

+ {{ t('admin.accounts.opencodeGo.panelHint') }} +

+
+ + {{ opencodeGoStatusLabel }} + +
+ +
+ +
+ +
+
diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts index 954b6e15ec..c961f1eda5 100644 --- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts +++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts @@ -74,12 +74,36 @@ function makeOllamaUsage(accountId: number, overrides: Partial> = {}) { + return { + account_id: accountId, + eligible: true, + auto_refresh_enabled: true, + snapshot: { + status: 'ok' as const, + fetched_at: '2026-07-22T12:00:00Z', + last_attempt_at: '2026-07-22T12:00:00Z', + next_refresh_at: '2026-07-22T13:00:00Z', + data: { + rolling: { percent: 5.6, resets_at: '2026-07-23T03:00:00Z' }, + weekly: { percent: 14.2, resets_at: '2026-07-29T00:00:00Z' }, + monthly: { percent: 33.3, resets_at: '2026-08-01T00:00:00Z' } + } + }, + ...overrides, + } +} + +// CN 平台 Ollama Cloud / OpenCode Go 用例共用的子组件 stub:按 data-test 断言渲染与否 const cnUsageCellStubs = { OllamaCloudUsageCell: { props: ['account'], template: '
ollama
' }, + OpenCodeGoUsageCell: { + props: ['account'], + template: '
' + }, CNProviderQuotaCell: { template: '
' }, @@ -240,6 +264,84 @@ describe('AccountUsageCell', () => { expect(wrapper.find('[data-test="embedded-ollama"]').exists()).toBe(false) }) + it.each(['opencode_go', 'kimi', 'zhipu', 'deepseek', 'minimax'] as const)( + '%s 平台 OpenCode Go eligible 时只渲染 OpenCode 用量单元格并跳过 CN 子单元格', + async (platform) => { + const wrapper = mount(AccountUsageCell, { + props: { + account: makeAccount({ + id: 9100, + platform, + type: 'apikey', + credentials: { account_mode: platform === 'opencode_go' ? 'go' : 'coding' }, + opencode_go_usage: makeOpenCodeGoUsage(9100) + }) + }, + global: { + stubs: { ...cnUsageCellStubs, UsageProgressBar: true, AccountQuotaInfo: true } + } + }) + + await flushPromises() + + // 同一账号只渲染一次 OpenCode 用量单元格 + expect(wrapper.findAll('[data-test="opencode-go-cell"]')).toHaveLength(1) + expect(wrapper.find('[data-test="cn-quota-cell"]').exists()).toBe(false) + expect(wrapper.find('[data-test="cn-balance-cell"]').exists()).toBe(false) + expect(wrapper.find('[data-test="embedded-ollama"]').exists()).toBe(false) + expect(wrapper.find('div[title="admin.accounts.cnProviders.noBalanceEndpoint"]').exists()).toBe(false) + expect(getUsage).not.toHaveBeenCalled() + } + ) + + it.each([ + { name: 'opencode_go(go 模式)', platform: 'opencode_go' as const, mode: 'go' }, + { name: 'kimi(coding 模式)', platform: 'kimi' as const, mode: 'coding' } + ])('OpenCode Go 不合格时($name)仍渲染 CN 子单元格', async ({ platform, mode }) => { + const wrapper = mount(AccountUsageCell, { + props: { + account: makeAccount({ + id: 9101, + platform, + type: 'apikey', + credentials: { account_mode: mode }, + opencode_go_usage: makeOpenCodeGoUsage(9101, { eligible: false }) + }) + }, + global: { + stubs: { ...cnUsageCellStubs, UsageProgressBar: true, AccountQuotaInfo: true } + } + }) + + await flushPromises() + + expect(wrapper.find('[data-test="cn-quota-cell"]').exists()).toBe(true) + expect(wrapper.findAll('[data-test="opencode-go-cell"]')).toHaveLength(0) + }) + + it('openai apikey 挂载 OpenCode Go 时在非 CN 分支渲染一次且不渲染占位符', async () => { + const wrapper = mount(AccountUsageCell, { + props: { + account: makeAccount({ + id: 9102, + platform: 'openai', + type: 'apikey', + opencode_go_usage: makeOpenCodeGoUsage(9102) + }) + }, + global: { + stubs: { ...cnUsageCellStubs, UsageProgressBar: true, AccountQuotaInfo: true } + } + }) + + await flushPromises() + + expect(wrapper.findAll('[data-test="opencode-go-cell"]')).toHaveLength(1) + expect(wrapper.find('[data-test="cn-quota-cell"]').exists()).toBe(false) + // 用量单元格已渲染时不再叠加 `-` 占位符 + expect(wrapper.text()).not.toContain('-') + }) + it('Antigravity 图片用量会聚合新旧 image 模型', async () => { getUsage.mockResolvedValue({ antigravity_quota: { diff --git a/frontend/src/components/account/__tests__/OpenCodeGoUsageCell.spec.ts b/frontend/src/components/account/__tests__/OpenCodeGoUsageCell.spec.ts new file mode 100644 index 0000000000..999b14ef1c --- /dev/null +++ b/frontend/src/components/account/__tests__/OpenCodeGoUsageCell.spec.ts @@ -0,0 +1,168 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import OpenCodeGoUsageCell from '../OpenCodeGoUsageCell.vue' +import UsageProgressBar from '../UsageProgressBar.vue' +import type { Account, OpenCodeGoUsageState } from '@/types' + +const { refreshOpenCodeGoUsage } = vi.hoisted(() => ({ + refreshOpenCodeGoUsage: vi.fn() +})) + +vi.mock('@/api/admin', () => ({ + adminAPI: { + accounts: { + refreshOpenCodeGoUsage + } + } +})) + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => { + const short: Record = { + 'admin.accounts.opencodeGo.rollingShort': '5h', + 'admin.accounts.opencodeGo.weeklyShort': '7d', + 'admin.accounts.opencodeGo.monthlyShort': '1m', + 'admin.accounts.opencodeGo.unauthorized': 'unauthorized', + 'admin.accounts.opencodeGo.failed': 'failed', + 'admin.accounts.opencodeGo.ok': 'ok' + } + return short[key] ?? key + } + }) + } +}) + +const usageState = (overrides: Partial = {}): OpenCodeGoUsageState => ({ + account_id: 7, + eligible: true, + auto_refresh_enabled: false, + snapshot: { + status: 'ok', + fetched_at: '2026-07-22T12:00:00Z', + last_attempt_at: '2026-07-22T12:00:00Z', + next_refresh_at: '2026-07-22T13:00:00Z', + data: { + rolling: { percent: 5.6, resets_at: '2026-07-23T03:00:00Z' }, + weekly: { percent: 14.2, resets_at: '2026-07-29T00:00:00Z' }, + monthly: { percent: 33.3, resets_at: '2026-08-01T00:00:00Z' } + } + }, + ...overrides +}) + +const account = (state = usageState()): Account => ({ + id: 7, + name: 'opencode', + platform: 'openai', + type: 'apikey', + opencode_go_usage: state, + proxy_id: null, + concurrency: 1, + priority: 1, + status: 'active', + error_message: null, + last_used_at: null, + expires_at: null, + auto_pause_on_expired: false, + created_at: '2026-07-22T00:00:00Z', + updated_at: '2026-07-22T00:00:00Z', + schedulable: true, + rate_limited_at: null, + rate_limit_reset_at: null, + overload_until: null, + temp_unschedulable_until: null, + temp_unschedulable_reason: null, + session_window_start: null, + session_window_end: null, + session_window_status: null +}) + +describe('OpenCodeGoUsageCell', () => { + beforeEach(() => { + refreshOpenCodeGoUsage.mockReset() + }) + + it('renders rolling, weekly and monthly windows in a shrinkable mobile-safe cell', () => { + const wrapper = mount(OpenCodeGoUsageCell, { props: { account: account() } }) + const cell = wrapper.get('[data-testid="opencode-go-usage-cell"]') + expect(cell.classes()).toEqual(expect.arrayContaining(['min-w-0', 'max-w-full'])) + expect(cell.classes()).not.toContain('min-w-[12rem]') + + const bars = wrapper.findAllComponents(UsageProgressBar) + expect(bars).toHaveLength(3) + expect(bars[0].props()).toMatchObject({ + label: '5h', + utilization: 5.6, + resetsAt: '2026-07-23T03:00:00Z', + color: 'indigo' + }) + expect(bars[1].props()).toMatchObject({ + label: '7d', + utilization: 14.2, + resetsAt: '2026-07-29T00:00:00Z', + color: 'emerald' + }) + expect(bars[2].props()).toMatchObject({ + label: '1m', + utilization: 33.3, + resetsAt: '2026-08-01T00:00:00Z', + color: 'amber' + }) + + expect(wrapper.find('[data-testid="opencode-go-status-badge"]').exists()).toBe(false) + const query = wrapper.get('[data-testid="opencode-go-usage-query"]') + expect(query.text()).toContain('admin.accounts.usageWindow.activeQuery') + }) + + it('refreshes usage on demand and emits the new state', async () => { + const next = usageState() + next.snapshot!.data!.rolling!.percent = 42 + refreshOpenCodeGoUsage.mockResolvedValueOnce(next) + const wrapper = mount(OpenCodeGoUsageCell, { props: { account: account() } }) + + await wrapper.get('[data-testid="opencode-go-usage-query"]').trigger('click') + await flushPromises() + + expect(refreshOpenCodeGoUsage).toHaveBeenCalledWith(7) + expect(wrapper.findAllComponents(UsageProgressBar)[0].props('utilization')).toBe(42) + expect(wrapper.emitted('updated')?.[0]?.[0]).toEqual(next) + }) + + it('shows a status badge when the snapshot status is not ok', () => { + const wrapper = mount(OpenCodeGoUsageCell, { + props: { account: account(usageState({ snapshot: { status: 'unauthorized', last_attempt_at: '2026-07-22T12:00:00Z', next_refresh_at: '2026-07-22T13:00:00Z' } })) } + }) + const badge = wrapper.get('[data-testid="opencode-go-status-badge"]') + expect(badge.text()).toBe('unauthorized') + expect(badge.classes()).toEqual(expect.arrayContaining(['bg-amber-100', 'text-amber-700'])) + }) + + it('shows a red badge for failed snapshots', () => { + const wrapper = mount(OpenCodeGoUsageCell, { + props: { account: account(usageState({ snapshot: { status: 'failed', last_attempt_at: '2026-07-22T12:00:00Z', next_refresh_at: '2026-07-22T13:00:00Z', last_error: 'http_error' } })) } + }) + const badge = wrapper.get('[data-testid="opencode-go-status-badge"]') + expect(badge.text()).toBe('failed') + expect(badge.classes()).toEqual(expect.arrayContaining(['bg-red-100', 'text-red-700'])) + }) + + it('renders a dash when the account is not eligible or has no state', () => { + const wrapper = mount(OpenCodeGoUsageCell, { props: { account: account(usageState({ eligible: false })) } }) + expect(wrapper.find('[data-testid="opencode-go-usage-cell"]').exists()).toBe(false) + expect(wrapper.text()).toBe('-') + }) + + it('reacts to an account snapshot update', async () => { + const wrapper = mount(OpenCodeGoUsageCell, { props: { account: account() } }) + const next = usageState() + next.snapshot!.data!.rolling!.percent = 43 + + await wrapper.setProps({ account: account(next) }) + + expect(wrapper.findAllComponents(UsageProgressBar)[0].props('utilization')).toBe(43) + }) +}) diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 03544573b7..d008fddbbd 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -1396,6 +1396,24 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin max: {} } }, + 'gpt-6-sol': { + name: 'GPT-6 Sol', + limit: { + context: 1050000, + output: 128000 + }, + options: { + store: false + }, + variants: { + none: {}, + low: {}, + medium: {}, + high: {}, + xhigh: {}, + max: {} + } + }, 'gpt-5.6-sol': { name: 'GPT-5.6 Sol', limit: { @@ -1430,6 +1448,24 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin max: {} } }, + 'gpt-6-luna': { + name: 'GPT-6 Luna', + limit: { + context: 1050000, + output: 128000 + }, + options: { + store: false + }, + variants: { + none: {}, + low: {}, + medium: {}, + high: {}, + xhigh: {}, + max: {} + } + }, 'gpt-5.6-luna': { name: 'GPT-5.6 Luna', limit: { @@ -1861,6 +1897,21 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin provider[platform].models = geminiModels } else if (platform === 'anthropic') { provider[platform].npm = '@ai-sdk/anthropic' + provider[platform].models = { + 'claude-opus-5-5': { + name: 'Claude Opus 5.5', + limit: { context: 1000000, output: 128000 }, + modalities: { input: ['text', 'image', 'pdf'], output: ['text'] }, + options: { thinking: { type: 'adaptive' }, effort: 'medium' }, + variants: { + low: { effort: 'low' }, + medium: { effort: 'medium' }, + high: { effort: 'high' }, + xhigh: { effort: 'xhigh' }, + max: { effort: 'max' } + } + } + } } else if (platform === 'antigravity-claude') { provider[platform].npm = '@ai-sdk/anthropic' provider[platform].name = 'Antigravity (Claude)' diff --git a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts index 12b6c47888..efc32a0f57 100644 --- a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts +++ b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts @@ -668,12 +668,14 @@ describe('UseKeyModal', () => { const parsed = JSON.parse(wrapper.find('pre code').text()) const models = parsed.provider.openai.models - for (const model of ['gpt-5.6', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna']) { + for (const model of ['gpt-5.6', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-6-sol', 'gpt-6-luna']) { expect(models[model]).toBeDefined() expect(models[model].variants).toHaveProperty('max') expect(models[model].variants).toHaveProperty('xhigh') } expect(models['gpt-5.6'].name).toBe('GPT-5.6 (Sol)') + expect(models['gpt-6-sol'].variants).toHaveProperty('none') + expect(models['gpt-6-luna'].limit).toEqual({ context: 1050000, output: 128000 }) expect(models['gpt-6']).toEqual({ name: 'GPT-6 (Astra)', limit: { context: 1050000, output: 128000 }, @@ -688,6 +690,22 @@ describe('UseKeyModal', () => { }) }) + it('exports Opus 5.5 only on the Anthropic provider with adaptive defaults', async () => { + const wrapper = mount(UseKeyModal, { + props: { show: true, apiKey: 'sk-test', baseUrl: 'https://example.com/v1', platform: 'anthropic' }, + global: { stubs: { BaseDialog: { template: '
' }, Icon: { template: '' } } } + }) + const tab = wrapper.findAll('button').find(button => button.text().includes('keys.useKeyModal.cliTabs.opencode')) + expect(tab).toBeDefined() + await tab!.trigger('click') + await nextTick() + const model = JSON.parse(wrapper.find('pre code').text()).provider.anthropic.models['claude-opus-5-5'] + expect(model.limit).toEqual({ context: 1000000, output: 128000 }) + expect(model.options).toEqual({ thinking: { type: 'adaptive' }, effort: 'medium' }) + expect(model.variants.xhigh.effort).toBe('xhigh') + expect(model.variants).not.toHaveProperty('none') + }) + it('renders Claude Fable 5 OpenCode config with adaptive thinking', async () => { const wrapper = mount(UseKeyModal, { props: { diff --git a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts index df9f347d5a..bb73136e0a 100644 --- a/frontend/src/composables/__tests__/useModelWhitelist.spec.ts +++ b/frontend/src/composables/__tests__/useModelWhitelist.spec.ts @@ -17,6 +17,8 @@ describe('useModelWhitelist', () => { expect(models).toContain('gpt-5.6') expect(models).toContain('gpt-6') expect(models).toContain('gpt-6-astra') + expect(models).toContain('gpt-6-sol') + expect(models).toContain('gpt-6-luna') }) it('openai 预设映射包含 GPT-6 别名和 Astra', () => { @@ -50,6 +52,8 @@ describe('useModelWhitelist', () => { expect(getModelsByPlatform('antigravity')).toContain('claude-fable-5-1') expect(getModelsByPlatform('claude')).toContain('claude-fable-5') expect(getModelsByPlatform('antigravity')).toContain('claude-fable-5') + expect(getModelsByPlatform('claude')).toContain('claude-opus-5-5') + expect(getModelsByPlatform('antigravity')).not.toContain('claude-opus-5-5') expect(getModelsByPlatform('claude')).toContain('claude-opus-4-8') expect(getModelsByPlatform('antigravity')).toContain('claude-opus-4-8') }) diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index 0b312d21bf..e99695652e 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -10,7 +10,7 @@ const openaiModels = [ // GPT-5.6 系列 'gpt-5.6', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', // GPT-6 系列 - 'gpt-6', 'gpt-6-astra', + 'gpt-6', 'gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna', // GPT-5.5 系列 'gpt-5.5', // GPT-5.4 系列 @@ -34,6 +34,7 @@ export const claudeModels = [ 'claude-opus-4-6', 'claude-opus-4-7', 'claude-opus-4-8', + 'claude-opus-5-5', 'claude-opus-5', 'claude-sonnet-4-6', 'claude-sonnet-5', @@ -290,6 +291,7 @@ const anthropicPresetMappings = [ { label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'claude-opus-4-6', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, { label: 'Opus 4.7', from: 'claude-opus-4-7', to: 'claude-opus-4-7', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, { label: 'Opus 4.8', from: 'claude-opus-4-8', to: 'claude-opus-4-8', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, + { label: 'Opus 5.5', from: 'claude-opus-5-5', to: 'claude-opus-5-5', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, { label: 'Opus 5', from: 'claude-opus-5', to: 'claude-opus-5', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, { label: 'Haiku 3.5', from: 'claude-3-5-haiku-20241022', to: 'claude-3-5-haiku-20241022', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' }, { label: 'Haiku 4.5', from: 'claude-haiku-4-5-20251001', to: 'claude-haiku-4-5-20251001', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' }, @@ -306,8 +308,10 @@ const openaiPresetMappings = [ { label: 'GPT-5.2', from: 'gpt-5.2', to: 'gpt-5.2', color: 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400' }, { label: 'GPT-6', from: 'gpt-6', to: 'gpt-6', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' }, { label: 'GPT-5.6', from: 'gpt-5.6', to: 'gpt-5.6', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }, + { label: 'GPT-6 Sol', from: 'gpt-6-sol', to: 'gpt-6-sol', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' }, { label: 'GPT-5.6 Sol', from: 'gpt-5.6-sol', to: 'gpt-5.6-sol', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' }, { label: 'GPT-5.6 Terra', from: 'gpt-5.6-terra', to: 'gpt-5.6-terra', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' }, + { label: 'GPT-6 Luna', from: 'gpt-6-luna', to: 'gpt-6-luna', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' }, { label: 'GPT-5.6 Luna', from: 'gpt-5.6-luna', to: 'gpt-5.6-luna', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' }, { label: 'GPT-6 Astra', from: 'gpt-6-astra', to: 'gpt-6-astra', color: 'bg-fuchsia-100 text-fuchsia-700 hover:bg-fuchsia-200 dark:bg-fuchsia-900/30 dark:text-fuchsia-400' }, { label: 'GPT-5.5', from: 'gpt-5.5', to: 'gpt-5.5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }, diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index b25f0f8c87..40fd7b6f0b 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -173,6 +173,31 @@ export default { restoreDefaults: 'Restore defaults', fallback: 'Unmatched models → Chat Completions (/v1/chat/completions)', }, + title: 'OpenCode Go usage', + panelHint: 'Usage windows reported by the upstream OpenCode Go account. Refreshed on demand or automatically when enabled.', + notRefreshed: 'Not refreshed', + refreshNow: 'Refresh usage', + autoRefresh: 'Automatic usage refresh', + autoRefreshHint: 'Runs only when the account switch and the global switch are both enabled.', + rolling: '5 hour', + rollingShort: '5h', + weekly: 'Week', + weeklyShort: '7d', + monthly: 'Month', + monthlyShort: '1m', + status: 'Status', + updatedAt: 'Updated', + ok: 'Current', + unauthorized: 'Session expired', + failed: 'Refresh failed', + windowWithReset: '{percent} used, resets {reset}', + loadFailed: 'Failed to load OpenCode Go usage settings', + autoRefreshFailed: 'Failed to update automatic usage refresh', + refreshSuccess: 'OpenCode Go usage refreshed', + refreshFailed: 'Failed to refresh OpenCode Go usage', + errors: { + OPENCODE_GO_USAGE_REFRESH_RATE_LIMITED: 'Refresh is limited. Try again in {retry_after_seconds} seconds.' + } }, types: { oauth: 'OAuth', diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 3df37d96b1..8c04ed51e3 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -422,10 +422,12 @@ export default { affiliates: { invitesDescription: 'View site-wide inviter and invitee relationships', - rebatesDescription: 'View recharge orders that generated affiliate rebates', - transfersDescription: 'View affiliate quota transfers into account balance', + rebatesDescription: 'View every affiliate rebate accrual from recharge orders, redeem codes, and admin recharges', + transfersDescription: 'View affiliate quota transfers into balance and offline withdrawals', errors: { - loadFailed: 'Failed to load affiliate records' + loadFailed: 'Failed to load affiliate records', + AFFILIATE_QUOTA_INSUFFICIENT: 'Insufficient available affiliate quota', + AFFILIATE_WITHDRAW_AMOUNT_INVALID: 'Invalid withdrawal amount' }, records: { search: 'Search', @@ -450,7 +452,33 @@ export default { historyQuotaAfter: 'Historical Rebate After', invitedAt: 'Invited At', rebatedAt: 'Rebated At', - transferredAt: 'Transferred At' + transferredAt: 'Transferred At', + outflowType: 'Type' + }, + outflowTypes: { + transfer: 'To Balance', + withdraw: 'Offline Withdrawal' + }, + withdraw: { + button: 'Record Offline Withdrawal', + title: 'Record Offline Withdrawal', + user: 'User', + userPlaceholder: 'Search by email or username', + noUserFound: 'No matching users', + changeUser: 'Change user', + availableQuota: 'Available quota', + frozenHint: 'Rebates still in the freeze period are not included in the available quota', + amount: 'Withdrawal amount (USD)', + amountHint: 'Enter the amount already paid to this user outside the site', + fillAll: 'All', + warning: 'Recording deducts this amount from the user\'s available affiliate quota and cannot be undone. Make sure the off-site payment is complete.', + submit: 'Confirm', + submitting: 'Recording...', + success: 'Recorded offline withdrawal of {amount}; {remaining} still available', + replayed: 'This offline withdrawal of {amount} was already recorded and was not deducted again; {remaining} was left available after it', + uncertainHint: 'The last submission returned no result and may already be recorded. The user and amount are locked; submitting again retries the same registration and never deducts twice.', + amountRequired: 'Enter an amount greater than 0', + amountExceeds: 'Amount cannot exceed the available quota' }, overview: { title: 'Affiliate User Overview', diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 7037d2e368..ced6771230 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -477,6 +477,18 @@ export default { saved: 'Ollama Cloud usage refresh settings saved', saveFailed: 'Failed to save Ollama Cloud usage refresh settings' }, + opencodeGoUsage: { + title: 'OpenCode Go Usage Refresh', + description: 'Refresh usage windows reported by the upstream OpenCode Go account for individually opted-in accounts. Disabled by default.', + enabled: 'Enable global automatic refresh', + enabledHint: 'Only accounts with their own automatic refresh switch enabled are refreshed. Manual refresh remains available.', + intervalMinutes: 'Max wait while requests continue (minutes)', + intervalHint: 'Range: 5–1440 minutes. When continuous requests keep sliding the debounce, force a refresh after this wait.', + debounceMinutes: 'Quiet period after last request (minutes)', + debounceHint: 'Range: 1–60 minutes, and must be less than the refresh interval. Refresh after the latest model request has been quiet for this long.', + saved: 'OpenCode Go usage refresh settings saved', + saveFailed: 'Failed to save OpenCode Go usage refresh settings' + }, gatewayForwarding: { title: 'Request Forwarding', description: 'Control how requests are forwarded to upstream OAuth accounts', @@ -542,6 +554,11 @@ export default { openaiCodexVersionAutoSync: 'Auto-sync Codex version', openaiCodexVersionAutoSyncHint: 'Fetches the latest stable client version from the official repository every 6 hours, so you never need to upgrade this service just to keep the version current. When disabled, only the version above or the built-in default is used.', openaiCodexVersionSyncedValue: 'Currently synced: {version}', + claudeCodeClientVersion: 'Claude Code client version', + claudeCodeClientVersionHint: "The client version this gateway declares upstream when impersonating the official Claude Code CLI. Leave empty to use the auto-synced latest official release; setting a value pins it and stops following auto-sync. The SUB2API_CLAUDE_CLI_VERSION environment variable or built-in version is used only when neither the manual nor synced value is valid.", + claudeCodeVersionAutoSync: 'Auto-sync Claude Code version', + claudeCodeVersionAutoSyncHint: 'Fetches the latest Claude Code client version from the official release channel every hour, so you never need to upgrade this service just to keep the version current. When disabled, fetching stops but the previously synced version remains available. The manual version above always takes priority.', + claudeCodeVersionSyncedValue: 'Currently synced: {version}', codexHardeningTitle: "Codex Settings", codexClientRestrictionTitle: "Codex client restriction", codexHardeningDesc: diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index fc4bb94426..bc374be293 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -376,6 +376,31 @@ export default { restoreDefaults: '恢复默认', fallback: '未命中以上规则 → Chat Completions(/v1/chat/completions)', }, + title: 'OpenCode Go 用量', + panelHint: '上游 OpenCode Go 账号上报的用量窗口。可手动刷新,或开启自动刷新。', + notRefreshed: '尚未刷新', + refreshNow: '刷新用量', + autoRefresh: '自动刷新用量', + autoRefreshHint: '只有账号开关和全局开关同时启用时才会定时刷新。', + rolling: '5 小时', + rollingShort: '5h', + weekly: '周', + weeklyShort: '7d', + monthly: '月', + monthlyShort: '1m', + status: '状态', + updatedAt: '更新时间', + ok: '正常', + unauthorized: '会话已过期', + failed: '刷新失败', + windowWithReset: '已用 {percent},{reset} 重置', + loadFailed: '加载 OpenCode Go 用量设置失败', + autoRefreshFailed: '更新自动刷新设置失败', + refreshSuccess: 'OpenCode Go 用量已刷新', + refreshFailed: '刷新 OpenCode Go 用量失败', + errors: { + OPENCODE_GO_USAGE_REFRESH_RATE_LIMITED: '刷新过于频繁,请在 {retry_after_seconds} 秒后重试。' + } }, types: { oauth: 'OAuth', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 2f5b44f495..1ec1b74283 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -422,10 +422,12 @@ export default { affiliates: { invitesDescription: '查看全站邀请关系和被邀请用户累计返利', - rebatesDescription: '查看每一笔产生返利的充值订单', - transfersDescription: '查看返利额度转入账户余额的提取流水', + rebatesDescription: '查看每一笔返利入账,包括充值订单、兑换码和管理员充值产生的返利', + transfersDescription: '查看返利额度转入余额与线下提现的流水', errors: { - loadFailed: '加载邀请返利记录失败' + loadFailed: '加载邀请返利记录失败', + AFFILIATE_QUOTA_INSUFFICIENT: '可提取返利额度不足', + AFFILIATE_WITHDRAW_AMOUNT_INVALID: '提现金额无效' }, records: { search: '搜索', @@ -450,7 +452,33 @@ export default { historyQuotaAfter: '提取后历史返利', invitedAt: '邀请时间', rebatedAt: '返利时间', - transferredAt: '提取时间' + transferredAt: '提取时间', + outflowType: '类型' + }, + outflowTypes: { + transfer: '转入余额', + withdraw: '线下提现' + }, + withdraw: { + button: '登记线下提现', + title: '登记线下提现', + user: '用户', + userPlaceholder: '输入邮箱或用户名搜索', + noUserFound: '未找到匹配的用户', + changeUser: '更换用户', + availableQuota: '当前可提取额度', + frozenHint: '冻结期内的返利不计入可提取额度', + amount: '提现金额(USD)', + amountHint: '填写已在站外实际打款给该用户的金额', + fillAll: '全部', + warning: '登记后将从该用户的可提取返利额度中扣除,且无法撤销。请确认已完成站外打款。', + submit: '确认登记', + submitting: '登记中...', + success: '已登记线下提现 {amount},剩余可提取 {remaining}', + replayed: '这笔线下提现此前已登记({amount}),本次未重复扣减;登记后剩余可提取 {remaining}', + uncertainHint: '上次提交没有收到结果,可能已经登记成功。用户与金额已锁定,重新提交会沿用同一笔登记,已登记时不会重复扣减。', + amountRequired: '请输入大于 0 的金额', + amountExceeds: '提现金额不能超过当前可提取额度' }, overview: { title: '用户返利概览', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index ec7614a102..39007377c4 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -470,6 +470,18 @@ export default { saved: 'Ollama Cloud 用量刷新设置已保存', saveFailed: '保存 Ollama Cloud 用量刷新设置失败' }, + opencodeGoUsage: { + title: 'OpenCode Go 用量刷新', + description: '刷新上游 OpenCode Go 账号上报的用量窗口;默认关闭,仅对单独开启的账号生效。', + enabled: '启用全局自动刷新', + enabledHint: '仅刷新账号自身也开启自动刷新的账号。手动刷新不受影响。', + intervalMinutes: '请求持续时的最长等待(分钟)', + intervalHint: '范围 5–1440 分钟。请求持续不断导致 debounce 一直后移时,最晚在此时间强制刷新。', + debounceMinutes: '请求安静等待(分钟)', + debounceHint: '范围 1–60 分钟,且必须小于刷新间隔。最后一次模型请求安静满此时长后再抓取用量。', + saved: 'OpenCode Go 用量刷新设置已保存', + saveFailed: '保存 OpenCode Go 用量刷新设置失败' + }, gatewayForwarding: { title: '请求转发行为', description: '控制请求转发到上游 OAuth 账号时的行为', @@ -535,6 +547,11 @@ export default { openaiCodexVersionAutoSync: '自动同步 Codex 版本号', openaiCodexVersionAutoSyncHint: '每 6 小时从官方仓库获取最新稳定版客户端版本号,无需为了跟版本而升级本服务。关闭后仅使用上方手填版本或内置版本。', openaiCodexVersionSyncedValue: '当前同步到:{version}', + claudeCodeClientVersion: 'Claude Code 客户端版本号', + claudeCodeClientVersionHint: '网关伪装为官方 Claude Code CLI 时对上游声明的客户端版本号。留空表示使用自动同步到的官方最新版本;填写后固定为该版本,不再跟随同步。仅在手填值和同步值均不可用时,才回退到环境变量 SUB2API_CLAUDE_CLI_VERSION 或内置版本。', + claudeCodeVersionAutoSync: '自动同步 Claude Code 版本号', + claudeCodeVersionAutoSyncHint: '每小时从官方发布渠道获取最新版本的 Claude Code 客户端版本号,无需为了跟版本而升级本服务。关闭后停止获取新版本,已同步的版本仍可使用;上方手填版本始终优先。', + claudeCodeVersionSyncedValue: '当前同步到:{version}', codexHardeningTitle: 'Codex 设置', codexClientRestrictionTitle: 'Codex 客户端限制', codexHardeningDesc: diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 80cef707bd..bc7fbf2ac8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1155,6 +1155,46 @@ export interface OllamaCloudUsageSettings { debounce_minutes: number } +export type OpenCodeGoUsageStatus = 'ok' | 'unauthorized' | 'failed' + +export interface OpenCodeGoUsageWindow { + status?: string + percent: number + resets_at?: string +} + +export interface OpenCodeGoUsageData { + rolling?: OpenCodeGoUsageWindow + weekly?: OpenCodeGoUsageWindow + monthly?: OpenCodeGoUsageWindow +} + +export interface OpenCodeGoUsageSnapshot { + status: OpenCodeGoUsageStatus + data?: OpenCodeGoUsageData + fetched_at?: string + last_attempt_at?: string + next_refresh_at?: string + failure_count?: number + http_status?: number + last_error?: string +} + +export interface OpenCodeGoUsageState { + account_id: number + eligible: boolean + auto_refresh_enabled: boolean + snapshot?: OpenCodeGoUsageSnapshot +} + +export interface OpenCodeGoUsageSettings { + enabled: boolean + /** Max wait while model requests keep arriving (minutes). */ + interval_minutes: number + /** Trailing quiet period after the latest model request (minutes). */ + debounce_minutes: number +} + export interface Account { id: number name: string @@ -1168,6 +1208,7 @@ export interface Account { credentials?: Record credentials_status?: Record ollama_cloud_usage?: OllamaCloudUsageState + opencode_go_usage?: OpenCodeGoUsageState // Extra fields including Codex usage, OpenAI compact capability, and model-level rate limits. extra?: (CodexUsageSnapshot & OpenAICompactState & { model_rate_limits?: Record diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 15ed5582b9..9f04a341da 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -4910,6 +4910,90 @@
+ +
+
+

+ {{ t("admin.settings.opencodeGoUsage.title") }} +

+

+ {{ t("admin.settings.opencodeGoUsage.description") }} +

+
+
+
+
+ {{ t("common.loading") }} +
+ +
+
+
+ +
+ + +

+ {{ + t( + "admin.settings.gatewayForwarding.claudeCodeClientVersionHint", + ) + }} +

+
+ + +
+
+ +

+ {{ + t( + "admin.settings.gatewayForwarding.claudeCodeVersionAutoSyncHint", + ) + }} +

+

+ {{ claudeSyncedVersionLabel }} +

+
+ +
+
@@ -9033,6 +9172,14 @@ const ollamaCloudUsageForm = reactive({ debounce_minutes: 1, }); +const opencodeGoUsageLoading = ref(true); +const opencodeGoUsageSaving = ref(false); +const opencodeGoUsageForm = reactive({ + enabled: false, + interval_minutes: 15, + debounce_minutes: 1, +}); + // Overload Cooldown (529) 状态 const overloadCooldownLoading = ref(true); const overloadCooldownSaving = ref(false); @@ -9840,6 +9987,10 @@ const form = reactive({ // 只读展示:自动同步任务写入的官方最新稳定版,不参与提交(提交载荷按字段显式构造) openai_codex_client_version_synced: "", openai_codex_version_auto_sync_enabled: true, + claude_code_client_version: "", + // 只读展示:自动同步任务写入的官方最新稳定版,不参与提交(提交载荷按字段显式构造) + claude_code_client_version_synced: "", + claude_code_version_auto_sync_enabled: true, // codex_cli_only 加固 min_codex_version: "", max_codex_version: "", @@ -10827,6 +10978,14 @@ const codexSyncedVersionLabel = computed(() => { }); }); +const claudeSyncedVersionLabel = computed(() => { + const synced = form.claude_code_client_version_synced?.trim(); + if (!synced) return ""; + return t("admin.settings.gatewayForwarding.claudeCodeVersionSyncedValue", { + version: synced, + }); +}); + async function loadSettings() { loading.value = true; loadFailed.value = false; @@ -11460,6 +11619,9 @@ async function saveSettings() { form.openai_codex_client_version?.trim() || "", openai_codex_version_auto_sync_enabled: form.openai_codex_version_auto_sync_enabled, + claude_code_client_version: form.claude_code_client_version?.trim() || "", + claude_code_version_auto_sync_enabled: + form.claude_code_version_auto_sync_enabled, min_codex_version: form.min_codex_version?.trim() || "", max_codex_version: form.max_codex_version?.trim() || "", codex_cli_only_allow_app_server_clients: @@ -11910,6 +12072,37 @@ async function saveOllamaCloudUsageSettings() { } } +async function loadOpenCodeGoUsageSettings() { + opencodeGoUsageLoading.value = true; + try { + Object.assign( + opencodeGoUsageForm, + await adminAPI.accounts.getOpenCodeGoUsageSettings(), + ); + } catch (_error: unknown) { + // Keep the fail-safe disabled defaults when this optional setting cannot be loaded. + } finally { + opencodeGoUsageLoading.value = false; + } +} + +async function saveOpenCodeGoUsageSettings() { + opencodeGoUsageSaving.value = true; + try { + const updated = await adminAPI.accounts.updateOpenCodeGoUsageSettings({ + ...opencodeGoUsageForm, + }); + Object.assign(opencodeGoUsageForm, updated); + appStore.showSuccess(t("admin.settings.opencodeGoUsage.saved")); + } catch (error: unknown) { + appStore.showError( + extractApiErrorMessage(error, t("admin.settings.opencodeGoUsage.saveFailed")), + ); + } finally { + opencodeGoUsageSaving.value = false; + } +} + // Overload Cooldown 方法 async function loadOverloadCooldownSettings() { overloadCooldownLoading.value = true; @@ -12660,6 +12853,7 @@ onMounted(() => { loadAdminApiKey(); loadUpstreamBillingProbeSettings(); loadOllamaCloudUsageSettings(); + loadOpenCodeGoUsageSettings(); loadOverloadCooldownSettings(); loadRateLimit429CooldownSettings(); loadPanelRateLimitSettings(); diff --git a/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue b/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue index 789df41a0d..ce105853fe 100644 --- a/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue +++ b/frontend/src/views/admin/affiliates/AdminAffiliateRecordsTable.vue @@ -12,6 +12,15 @@ +
@@ -36,7 +45,9 @@ /> + @@ -113,6 +133,13 @@ + + (null) +const withdrawDialog = ref(false) let debounceTimer: ReturnType | null = null const columns = computed(() => { @@ -201,6 +230,7 @@ const columns = computed(() => { } return [ { key: 'user', label: t('admin.affiliates.records.user'), sortable: true }, + { key: 'action', label: t('admin.affiliates.records.outflowType'), sortable: true }, { key: 'amount', label: t('admin.affiliates.records.transferAmount'), sortable: true }, { key: 'balance_after', label: t('admin.affiliates.records.balanceAfter'), sortable: true }, { key: 'available_quota_after', label: t('admin.affiliates.records.availableQuotaAfter'), sortable: true }, @@ -303,6 +333,17 @@ function handleSort(key: string, order: 'asc' | 'desc') { void loadRecords() } +function handleWithdrawSuccess() { + withdrawDialog.value = false + reloadFromFirstPage() +} + +function outflowTypeLabel(action: string | null | undefined): string { + return action === 'withdraw' + ? t('admin.affiliates.outflowTypes.withdraw') + : t('admin.affiliates.outflowTypes.transfer') +} + function formatAmount(value: number | null | undefined): string { return Number(value || 0).toFixed(2) } diff --git a/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue b/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue new file mode 100644 index 0000000000..a9fe09d7b5 --- /dev/null +++ b/frontend/src/views/admin/affiliates/AffiliateOfflineWithdrawDialog.vue @@ -0,0 +1,357 @@ + + + diff --git a/frontend/src/views/admin/affiliates/__tests__/AdminAffiliateRecordsTable.spec.ts b/frontend/src/views/admin/affiliates/__tests__/AdminAffiliateRecordsTable.spec.ts new file mode 100644 index 0000000000..6306a5364b --- /dev/null +++ b/frontend/src/views/admin/affiliates/__tests__/AdminAffiliateRecordsTable.spec.ts @@ -0,0 +1,179 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import AdminAffiliateRecordsTable from '../AdminAffiliateRecordsTable.vue' + +const { listInviteRecords, listRebateRecords, listTransferRecords, getUserOverview } = vi.hoisted(() => ({ + listInviteRecords: vi.fn(), + listRebateRecords: vi.fn(), + listTransferRecords: vi.fn(), + getUserOverview: vi.fn(), +})) + +vi.mock('@/api/admin/affiliates', () => { + const affiliatesAPI = { listInviteRecords, listRebateRecords, listTransferRecords, getUserOverview } + return { affiliatesAPI, default: affiliatesAPI } +}) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ showError: vi.fn(), showSuccess: vi.fn() }), +})) + +vi.mock('vue-i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key, + }), + } +}) + +const TablePageLayoutStub = { + template: '
', +} + +const DataTableStub = { + props: ['columns', 'data'], + template: ` + + + + +
+ +
+ `, +} + +const WithdrawDialogStub = { + name: 'AffiliateOfflineWithdrawDialog', + props: ['show'], + emits: ['close', 'success'], + template: '
', +} + +function mountTable(type: 'invites' | 'rebates' | 'transfers') { + return mount(AdminAffiliateRecordsTable, { + props: { type }, + global: { + stubs: { + AppLayout: { template: '
' }, + TablePageLayout: TablePageLayoutStub, + DataTable: DataTableStub, + Pagination: true, + Icon: true, + OrderStatusBadge: true, + BaseDialog: true, + AffiliateOfflineWithdrawDialog: WithdrawDialogStub, + }, + }, + }) +} + +function page(items: T[]) { + return { items, total: items.length, page: 1, page_size: 20, pages: 1 } +} + +describe('AdminAffiliateRecordsTable', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + it('renders non-order rebate accruals with empty order fields', async () => { + listRebateRecords.mockResolvedValue(page([ + { + order_id: null, + out_trade_no: '', + inviter_id: 1, + inviter_email: 'inviter@example.com', + inviter_username: 'inviter', + invitee_id: 2, + invitee_email: 'invitee@example.com', + invitee_username: 'invitee', + order_amount: null, + pay_amount: null, + rebate_amount: 2, + payment_type: '', + order_status: '', + created_at: '2026-09-17T08:00:00Z', + }, + ])) + + const wrapper = mountTable('rebates') + await flushPromises() + + const row = wrapper.get('[data-test="row-0"]') + expect(row.get('[data-test="cell-order"]').text()).toBe('-') + expect(row.get('[data-test="cell-order_amount"]').text()).toBe('-') + expect(row.get('[data-test="cell-pay_amount"]').text()).toBe('-') + expect(row.get('[data-test="cell-payment_type"]').text()).toBe('-') + expect(row.get('[data-test="cell-order_status"]').text()).toBe('-') + expect(row.get('[data-test="cell-rebate_amount"]').text()).toBe('$2.00') + expect(wrapper.find('[data-test="affiliate-withdraw-open"]').exists()).toBe(false) + }) + + it('lists offline withdrawals next to balance transfers with their type', async () => { + listTransferRecords.mockResolvedValue(page([ + { + ledger_id: 11, + action: 'withdraw', + user_id: 42, + user_email: 'inviter@example.com', + username: 'inviter', + amount: 12.5, + balance_after: 5.5, + available_quota_after: 7.5, + frozen_quota_after: 0, + history_quota_after: 30, + snapshot_available: true, + created_at: '2026-09-17T08:00:00Z', + }, + { + ledger_id: 10, + action: 'transfer', + user_id: 42, + user_email: 'inviter@example.com', + username: 'inviter', + amount: 3, + balance_after: 5.5, + available_quota_after: 20, + frozen_quota_after: 0, + history_quota_after: 30, + snapshot_available: true, + created_at: '2026-09-16T08:00:00Z', + }, + ])) + + const wrapper = mountTable('transfers') + await flushPromises() + + const withdrawRow = wrapper.get('[data-test="row-0"]') + expect(withdrawRow.get('[data-test="cell-action"]').text()).toBe('admin.affiliates.outflowTypes.withdraw') + expect(withdrawRow.get('[data-test="cell-amount"]').text()).toBe('$12.50') + + const transferRow = wrapper.get('[data-test="row-1"]') + expect(transferRow.get('[data-test="cell-action"]').text()).toBe('admin.affiliates.outflowTypes.transfer') + }) + + it('opens the offline withdrawal dialog and reloads the first page after recording', async () => { + listTransferRecords.mockResolvedValue(page([])) + + const wrapper = mountTable('transfers') + await flushPromises() + expect(listTransferRecords).toHaveBeenCalledTimes(1) + + const button = wrapper.get('[data-test="affiliate-withdraw-open"]') + expect(button.text()).toBe('admin.affiliates.withdraw.button') + await button.trigger('click') + expect(wrapper.get('[data-test="withdraw-dialog"]').attributes('data-show')).toBe('true') + + wrapper.findComponent(WithdrawDialogStub).vm.$emit('success', { ledger_id: 12 }) + await flushPromises() + + expect(wrapper.get('[data-test="withdraw-dialog"]').attributes('data-show')).toBe('false') + expect(listTransferRecords).toHaveBeenCalledTimes(2) + expect(listTransferRecords.mock.calls[1][0]).toMatchObject({ page: 1 }) + }) +}) diff --git a/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts b/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts new file mode 100644 index 0000000000..d1b5b2e722 --- /dev/null +++ b/frontend/src/views/admin/affiliates/__tests__/AffiliateOfflineWithdrawDialog.spec.ts @@ -0,0 +1,225 @@ +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import AffiliateOfflineWithdrawDialog from '../AffiliateOfflineWithdrawDialog.vue' + +const { lookupUsers, getUserOverview, withdrawUserQuota, showError, showSuccess } = vi.hoisted(() => ({ + lookupUsers: vi.fn(), + getUserOverview: vi.fn(), + withdrawUserQuota: vi.fn(), + showError: vi.fn(), + showSuccess: vi.fn(), +})) + +vi.mock('@/api/admin/affiliates', () => ({ + affiliatesAPI: { lookupUsers, getUserOverview, withdrawUserQuota }, +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ showError, showSuccess }), +})) + +vi.mock('vue-i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useI18n: () => ({ + t: (key: string, params?: Record) => + params ? `${key} ${JSON.stringify(params)}` : key, + }), + } +}) + +const BaseDialogStub = { + props: ['show', 'title'], + emits: ['close'], + template: '
', +} + +function mountDialog() { + return mount(AffiliateOfflineWithdrawDialog, { + props: { show: true }, + global: { stubs: { BaseDialog: BaseDialogStub } }, + }) +} + +function overviewWithQuota(availableQuota: number) { + return { + user_id: 42, + email: 'inviter@example.com', + username: 'inviter', + aff_code: 'INVITER', + rebate_rate_percent: 20, + invited_count: 3, + rebated_invitee_count: 2, + available_quota: availableQuota, + history_quota: 50, + } +} + +function withdrawResponse(amount: number, availableAfter: number, replayed = false) { + return { + result: { + ledger_id: 9, + user_id: 42, + amount, + available_quota_after: availableAfter, + frozen_quota_after: 0, + history_quota_after: 50, + }, + replayed, + } +} + +async function pickUser(wrapper: VueWrapper, availableQuota: number) { + lookupUsers.mockResolvedValue([{ id: 42, email: 'inviter@example.com', username: 'inviter' }]) + getUserOverview.mockResolvedValue(overviewWithQuota(availableQuota)) + await wrapper.get('[data-test="withdraw-user-search"]').setValue('inviter') + vi.advanceTimersByTime(300) + await flushPromises() + await wrapper.get('[data-test="withdraw-user-option"]').trigger('click') + await flushPromises() +} + +describe('AffiliateOfflineWithdrawDialog', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('records the exact available quota as the withdrawal amount', async () => { + const wrapper = mountDialog() + await pickUser(wrapper, 12.34567891) + + expect(lookupUsers).toHaveBeenCalledWith('inviter') + expect(getUserOverview).toHaveBeenCalledWith(42) + expect(wrapper.get('[data-test="withdraw-available-quota"]').text()).toBe('$12.34567891') + + await wrapper.get('[data-test="withdraw-fill-all"]').trigger('click') + expect((wrapper.get('[data-test="withdraw-amount"]').element as HTMLInputElement).value).toBe('12.34567891') + + withdrawUserQuota.mockResolvedValue(withdrawResponse(12.34567891, 0)) + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + + expect(withdrawUserQuota).toHaveBeenCalledWith(42, { amount: 12.34567891 }, expect.stringMatching(/^affiliate-withdraw-/)) + expect(showSuccess).toHaveBeenCalledWith( + 'admin.affiliates.withdraw.success {"amount":"$12.34567891","remaining":"$0.00"}', + ) + expect(wrapper.emitted('success')?.[0]?.[0]).toMatchObject({ ledger_id: 9 }) + }) + + it('blocks amounts above the available quota', async () => { + const wrapper = mountDialog() + await pickUser(wrapper, 10) + + await wrapper.get('[data-test="withdraw-amount"]').setValue('10.00000001') + + expect(wrapper.get('[data-test="withdraw-amount-error"]').text()).toBe('admin.affiliates.withdraw.amountExceeds') + expect(wrapper.get('[data-test="withdraw-submit"]').attributes('disabled')).toBeDefined() + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + expect(withdrawUserQuota).not.toHaveBeenCalled() + }) + + it('treats a user without an affiliate profile as having no available quota', async () => { + lookupUsers.mockResolvedValue([{ id: 7, email: 'fresh@example.com', username: 'fresh' }]) + getUserOverview.mockRejectedValue({ reason: 'USER_NOT_FOUND', message: 'user not found' }) + const wrapper = mountDialog() + + await wrapper.get('[data-test="withdraw-user-search"]').setValue('fresh') + vi.advanceTimersByTime(300) + await flushPromises() + await wrapper.get('[data-test="withdraw-user-option"]').trigger('click') + await flushPromises() + + expect(showError).not.toHaveBeenCalled() + expect(wrapper.get('[data-test="withdraw-selected-user"]').text()).toContain('fresh@example.com') + expect(wrapper.get('[data-test="withdraw-available-quota"]').text()).toBe('$0.00') + expect(wrapper.get('[data-test="withdraw-fill-all"]').attributes('disabled')).toBeDefined() + + await wrapper.get('[data-test="withdraw-amount"]').setValue('1') + expect(wrapper.get('[data-test="withdraw-amount-error"]').text()).toBe('admin.affiliates.withdraw.amountExceeds') + expect(wrapper.get('[data-test="withdraw-submit"]').attributes('disabled')).toBeDefined() + }) + + it('shows the API error and reloads the available quota when recording fails', async () => { + const wrapper = mountDialog() + await pickUser(wrapper, 10) + + withdrawUserQuota.mockRejectedValue({ status: 400, reason: 'AFFILIATE_QUOTA_INSUFFICIENT', message: 'insufficient' }) + await wrapper.get('[data-test="withdraw-amount"]').setValue('5') + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + + expect(showError).toHaveBeenCalledWith('admin.affiliates.errors.AFFILIATE_QUOTA_INSUFFICIENT {}') + expect(getUserOverview).toHaveBeenCalledTimes(2) + expect(wrapper.emitted('success')).toBeUndefined() + }) + + it.each([ + ['a network error', { status: 0, message: 'Network error. Please check your connection.' }], + ['a gateway error', { status: 502, message: 'Bad Gateway' }], + ['a request timeout', { status: 408, message: 'Request Timeout' }], + ])('retries the same registration with the same key after %s', async (_label, failure) => { + const wrapper = mountDialog() + await pickUser(wrapper, 10) + await wrapper.get('[data-test="withdraw-fill-all"]').trigger('click') + + // 服务端其实已登记,只是响应丢失:刷新后的可提取额度已扣到 0。 + withdrawUserQuota.mockRejectedValueOnce(failure) + getUserOverview.mockResolvedValue(overviewWithQuota(0)) + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + + expect(showError).toHaveBeenCalledTimes(1) + expect(wrapper.get('[data-test="withdraw-available-quota"]').text()).toBe('$0.00') + expect(wrapper.get('[data-test="withdraw-uncertain-hint"]').text()).toBe('admin.affiliates.withdraw.uncertainHint') + expect(wrapper.get('[data-test="withdraw-amount"]').attributes('disabled')).toBeDefined() + expect(wrapper.get('[data-test="withdraw-fill-all"]').attributes('disabled')).toBeDefined() + expect(wrapper.get('[data-test="withdraw-clear-user"]').attributes('disabled')).toBeDefined() + expect(wrapper.find('[data-test="withdraw-amount-error"]').exists()).toBe(false) + expect(wrapper.get('[data-test="withdraw-submit"]').attributes('disabled')).toBeUndefined() + + withdrawUserQuota.mockResolvedValueOnce(withdrawResponse(10, 0, true)) + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + + expect(withdrawUserQuota).toHaveBeenCalledTimes(2) + const [firstCall, retryCall] = withdrawUserQuota.mock.calls + expect(firstCall).toEqual([42, { amount: 10 }, expect.stringMatching(/^affiliate-withdraw-/)]) + expect(retryCall).toEqual(firstCall) + expect(showSuccess).toHaveBeenCalledWith( + 'admin.affiliates.withdraw.replayed {"amount":"$10.00","remaining":"$0.00"}', + ) + expect(wrapper.emitted('success')).toHaveLength(1) + }) + + it('starts a new registration with a fresh key after a definite rejection', async () => { + const wrapper = mountDialog() + await pickUser(wrapper, 10) + await wrapper.get('[data-test="withdraw-amount"]').setValue('6') + + withdrawUserQuota.mockRejectedValueOnce({ status: 400, reason: 'AFFILIATE_QUOTA_INSUFFICIENT', message: 'insufficient' }) + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-test="withdraw-uncertain-hint"]').exists()).toBe(false) + expect(wrapper.get('[data-test="withdraw-amount"]').attributes('disabled')).toBeUndefined() + + await wrapper.get('[data-test="withdraw-amount"]').setValue('5') + withdrawUserQuota.mockResolvedValueOnce(withdrawResponse(5, 5)) + await wrapper.get('[data-test="withdraw-submit"]').trigger('click') + await flushPromises() + + expect(withdrawUserQuota).toHaveBeenCalledTimes(2) + const [rejectedCall, nextCall] = withdrawUserQuota.mock.calls + expect(nextCall[1]).toEqual({ amount: 5 }) + expect(nextCall[2]).not.toBe(rejectedCall[2]) + expect(showSuccess).toHaveBeenCalledWith('admin.affiliates.withdraw.success {"amount":"$5.00","remaining":"$5.00"}') + }) +}) diff --git a/frontend/src/views/admin/affiliates/__tests__/affiliateWithdrawOperation.spec.ts b/frontend/src/views/admin/affiliates/__tests__/affiliateWithdrawOperation.spec.ts new file mode 100644 index 0000000000..99424f1ef0 --- /dev/null +++ b/frontend/src/views/admin/affiliates/__tests__/affiliateWithdrawOperation.spec.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { completeAffiliateWithdrawOperation, prepareAffiliateWithdrawOperation } from '../affiliateWithdrawOperation' + +let adminId = 2000 + +beforeEach(() => { + localStorage.setItem('auth_user', JSON.stringify({ id: ++adminId })) + sessionStorage.clear() +}) + +describe('affiliate offline withdrawal operation identity', () => { + it('keeps the key of an unfinished registration for the same user and ledger amount', () => { + const first = prepareAffiliateWithdrawOperation(42, 10) + const retry = prepareAffiliateWithdrawOperation(42, 10.000000001) + + expect(first.outcomeUncertain).toBe(false) + expect(first.key).toMatch(new RegExp(`^affiliate-withdraw-${adminId}-`)) + expect(retry.key).toBe(first.key) + expect(retry.amount).toBe(10) + expect(retry.outcomeUncertain).toBe(true) + + completeAffiliateWithdrawOperation(retry) + const next = prepareAffiliateWithdrawOperation(42, 10) + expect(next.key).not.toBe(first.key) + expect(next.outcomeUncertain).toBe(false) + }) + + it('isolates different users, amounts and administrators', () => { + const first = prepareAffiliateWithdrawOperation(42, 10) + const differentAmount = prepareAffiliateWithdrawOperation(42, 10.5) + const differentUser = prepareAffiliateWithdrawOperation(43, 10) + localStorage.setItem('auth_user', JSON.stringify({ id: ++adminId })) + const differentAdmin = prepareAffiliateWithdrawOperation(42, 10) + + expect(new Set([first, differentAmount, differentUser, differentAdmin].map(operation => operation.key)).size).toBe(4) + }) + + it('resumes a key stored by an earlier page and clears it after completion', () => { + const scope = `sub2api:admin:affiliate-withdraw:${adminId}:42:12.5` + sessionStorage.setItem(scope, 'saved-withdraw-key') + + const resumed = prepareAffiliateWithdrawOperation(42, 12.5) + expect(resumed.key).toBe('saved-withdraw-key') + expect(resumed.outcomeUncertain).toBe(true) + + completeAffiliateWithdrawOperation(resumed) + expect(sessionStorage.getItem(scope)).toBeNull() + expect(prepareAffiliateWithdrawOperation(42, 12.5).key).not.toBe('saved-withdraw-key') + }) +}) diff --git a/frontend/src/views/admin/affiliates/affiliateWithdrawOperation.ts b/frontend/src/views/admin/affiliates/affiliateWithdrawOperation.ts new file mode 100644 index 0000000000..ab3f9bbe64 --- /dev/null +++ b/frontend/src/views/admin/affiliates/affiliateWithdrawOperation.ts @@ -0,0 +1,68 @@ +/** 一笔线下提现登记及标识它的幂等键。 */ +export interface AffiliateWithdrawOperation { + userId: number + amount: number + key: string + storageKey: string | null + outcomeUncertain: boolean +} + +// 返利额度按 8 位小数记账。 +const LEDGER_SCALE = 1e8 + +const pendingKeys = new Map() + +function currentAdminId(): number | null { + try { + const user = JSON.parse(globalThis.localStorage?.getItem('auth_user') ?? 'null') as { id?: unknown } | null + const id = user?.id + return typeof id === 'number' && Number.isSafeInteger(id) && id > 0 ? id : null + } catch { + return null + } +} + +function readStoredKey(storageKey: string): string | null { + try { + return globalThis.sessionStorage?.getItem(storageKey) ?? null + } catch { + return null + } +} + +function storeKey(storageKey: string, key: string | null) { + try { + if (key) globalThis.sessionStorage?.setItem(storageKey, key) + else globalThis.sessionStorage?.removeItem(storageKey) + } catch { + // 浏览器存储不可用时,同一页面内的重试仍由内存中的键保护。 + } +} + +/** + * 取得该用户与金额对应的登记。结果未知的登记在完成前一直保留它的键, + * 再次提交同一用户与金额时沿用该键,服务端据此返回首次结果而不重复扣减。 + */ +export function prepareAffiliateWithdrawOperation(userId: number, amount: number): AffiliateWithdrawOperation { + const normalizedAmount = Math.round(amount * LEDGER_SCALE) / LEDGER_SCALE + const adminId = currentAdminId() + const storageKey = adminId ? `sub2api:admin:affiliate-withdraw:${adminId}:${userId}:${normalizedAmount}` : null + let key = storageKey ? pendingKeys.get(storageKey) ?? readStoredKey(storageKey) : null + const outcomeUncertain = !!key + if (!key) { + const requestId = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` + key = `affiliate-withdraw-${adminId ?? 'unknown'}-${requestId}` + } + if (storageKey) { + pendingKeys.set(storageKey, key) + storeKey(storageKey, key) + } + return { userId, amount: normalizedAmount, key, storageKey, outcomeUncertain } +} + +/** 登记有了确定结果后释放它的键。 */ +export function completeAffiliateWithdrawOperation(operation: AffiliateWithdrawOperation) { + if (!operation.storageKey) return + pendingKeys.delete(operation.storageKey) + storeKey(operation.storageKey, null) +}