Merge upstream main and retain upstream scheduler test fix

This commit is contained in:
Achord Chan
2026-09-26 15:47:52 +08:00
135 changed files with 9871 additions and 268 deletions
+1 -1
View File
@@ -1 +1 @@
0.2.7
0.2.8
+12
View File
@@ -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{
+16 -2
View File
@@ -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{
+3
View File
@@ -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"`)
}
+10 -3
View File
@@ -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,
+3
View File
@@ -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"`
+2
View File
@@ -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))
})
}
}
+2
View File
@@ -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
+12 -7
View File
@@ -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)
}
}
+26 -15
View File
@@ -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/")
+20
View File
@@ -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"))
}
+177 -17
View File
@@ -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(
&currentOllamaSession,
&currentOllamaAutoRefresh,
&currentOllamaSnapshot,
&opencodeGroupIdentityUnchanged,
&currentOpenCodeAutoRefresh,
&currentOpenCodeSnapshot,
); 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`).
+196 -12
View File
@@ -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")
}
+6 -1
View File
@@ -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": []
},
+6
View File
@@ -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,其余保持
+22
View File
@@ -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"])
}
+65 -4
View File
@@ -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)
}
}
+60 -5
View File
@@ -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())
}
}
}
}
+30 -4
View File
@@ -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 可能被截断,错误分类会基于不完整数据;记录日志以便排查,
+35 -24
View File
@@ -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)
}
// 插件已把请求交给上游时,自动切换账号可能造成重复扣费或重复执行。
+7 -1
View File
@@ -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
}
+20 -3
View File
@@ -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。
//
+11
View File
@@ -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