mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-21 06:56:58 +00:00
Merge upstream main and retain upstream scheduler test fix
This commit is contained in:
@@ -1 +1 @@
|
||||
0.2.7
|
||||
0.2.8
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"`)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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/")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 值为准")
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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`).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": []
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,其余保持
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(), "更低的版本号不得写入")
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 一次避免白名单透传值残留)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 可能被截断,错误分类会基于不完整数据;记录日志以便排查,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(客户端原始终决定响应格式;
|
||||
// 上游恒为流式,非流式由缓冲路径组装)。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 插件已把请求交给上游时,自动切换账号可能造成重复扣费或重复执行。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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。
|
||||
//
|
||||
|
||||
@@ -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]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user