mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 15:18:25 +08:00
Merge pull request #4776 from alfadb/feature/ollama-cloud-usage
feat(ollama): 支持 Cloud 官方用量自动刷新
This commit is contained in:
@@ -111,6 +111,7 @@ func provideCleanup(
|
||||
channelMonitorRunner *service.ChannelMonitorRunner,
|
||||
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
|
||||
upstreamBillingProbe *service.UpstreamBillingProbeService,
|
||||
ollamaCloudUsage *service.OllamaCloudUsageService,
|
||||
auditLog *service.AuditLogService,
|
||||
promptAudit *securityaudit.PromptService,
|
||||
) func() {
|
||||
@@ -331,6 +332,12 @@ func provideCleanup(
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"OllamaCloudUsageService", func() error {
|
||||
if ollamaCloudUsage != nil {
|
||||
ollamaCloudUsage.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
}
|
||||
|
||||
infraSteps := []cleanupStep{
|
||||
|
||||
@@ -269,7 +269,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, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService)
|
||||
ollamaCloudUsageService := service.ProvideOllamaCloudUsageService(accountRepository, httpUpstream, settingService, secretEncryptor, configConfig, leaderLockCache, db)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService)
|
||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||
@@ -317,7 +318,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db)
|
||||
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, 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, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, auditLogService, promptService)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService)
|
||||
application := &Application{
|
||||
Server: httpServer,
|
||||
PromptAudit: promptService,
|
||||
@@ -384,6 +385,7 @@ func provideCleanup(
|
||||
channelMonitorRunner *service.ChannelMonitorRunner,
|
||||
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
|
||||
upstreamBillingProbe *service.UpstreamBillingProbeService,
|
||||
ollamaCloudUsage *service.OllamaCloudUsageService,
|
||||
auditLog *service.AuditLogService,
|
||||
promptAudit *securityaudit.PromptService,
|
||||
) func() {
|
||||
@@ -603,6 +605,12 @@ func provideCleanup(
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"OllamaCloudUsageService", func() error {
|
||||
if ollamaCloudUsage != nil {
|
||||
ollamaCloudUsage.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
}
|
||||
|
||||
infraSteps := []cleanupStep{
|
||||
|
||||
@@ -88,6 +88,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
nil, // channelMonitorRunner
|
||||
nil, // quotaFlusher
|
||||
nil, // upstreamBillingProbe
|
||||
nil, // ollamaCloudUsage
|
||||
nil, // auditLog
|
||||
nil, // promptAudit
|
||||
)
|
||||
|
||||
@@ -63,6 +63,7 @@ type AccountHandler struct {
|
||||
tokenCacheInvalidator service.TokenCacheInvalidator
|
||||
grokImportProber grokImportProber
|
||||
upstreamBillingProbe *service.UpstreamBillingProbeService
|
||||
ollamaCloudUsage *service.OllamaCloudUsageService
|
||||
}
|
||||
|
||||
// SetUpstreamBillingProbeService attaches the optional remote billing probe service.
|
||||
@@ -70,6 +71,10 @@ func (h *AccountHandler) SetUpstreamBillingProbeService(probe *service.UpstreamB
|
||||
h.upstreamBillingProbe = probe
|
||||
}
|
||||
|
||||
func (h *AccountHandler) SetOllamaCloudUsageService(usage *service.OllamaCloudUsageService) {
|
||||
h.ollamaCloudUsage = usage
|
||||
}
|
||||
|
||||
// NewAccountHandler creates a new admin account handler
|
||||
func NewAccountHandler(
|
||||
adminService service.AdminService,
|
||||
@@ -208,9 +213,17 @@ type AccountSchedulerGroupScore struct {
|
||||
|
||||
const accountListGroupUngroupedQueryValue = "ungrouped"
|
||||
|
||||
func (h *AccountHandler) accountResponseFromService(account *service.Account) *dto.Account {
|
||||
out := dto.AccountFromService(account)
|
||||
if h != nil && h.ollamaCloudUsage != nil && out != nil {
|
||||
h.ollamaCloudUsage.EnrichState(out.OllamaCloudUsage)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency {
|
||||
item := AccountWithConcurrency{
|
||||
Account: dto.AccountFromService(account),
|
||||
Account: h.accountResponseFromService(account),
|
||||
CurrentConcurrency: 0,
|
||||
}
|
||||
if account == nil {
|
||||
@@ -523,6 +536,16 @@ func (h *AccountHandler) List(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if h.ollamaCloudUsage != nil && 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
|
||||
}
|
||||
}
|
||||
|
||||
// Get current concurrency counts for all accounts
|
||||
accountIDs := make([]int64, len(accounts))
|
||||
@@ -626,7 +649,7 @@ func (h *AccountHandler) List(c *gin.Context) {
|
||||
for i := range accounts {
|
||||
acc := &accounts[i]
|
||||
item := AccountWithConcurrency{
|
||||
Account: dto.AccountFromService(acc),
|
||||
Account: h.accountResponseFromService(acc),
|
||||
CurrentConcurrency: concurrencyCounts[acc.ID],
|
||||
SchedulerScore: schedulerScores[acc.ID],
|
||||
SchedulerScores: schedulerGroupScores[acc.ID],
|
||||
@@ -740,6 +763,12 @@ func (h *AccountHandler) GetByID(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if h.ollamaCloudUsage != nil {
|
||||
if err := h.ollamaCloudUsage.ResolveAccounts(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,162 @@
|
||||
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 ollamaCloudUsageSessionRequest struct {
|
||||
Session string `json:"session" binding:"required"`
|
||||
}
|
||||
|
||||
type ollamaCloudUsageAutoRefreshRequest struct {
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *AccountHandler) GetOllamaCloudUsageSettings(c *gin.Context) {
|
||||
if h.ollamaCloudUsage == nil {
|
||||
response.ErrorFrom(c, service.ErrOllamaCloudUsageUnavailable)
|
||||
return
|
||||
}
|
||||
settings, err := h.ollamaCloudUsage.GetSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, settings)
|
||||
}
|
||||
|
||||
func (h *AccountHandler) UpdateOllamaCloudUsageSettings(c *gin.Context) {
|
||||
if h.ollamaCloudUsage == nil {
|
||||
response.ErrorFrom(c, service.ErrOllamaCloudUsageUnavailable)
|
||||
return
|
||||
}
|
||||
var req service.OllamaCloudUsageSettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.ollamaCloudUsage.UpdateSettings(c.Request.Context(), &req); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
settings, err := h.ollamaCloudUsage.GetSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, settings)
|
||||
}
|
||||
|
||||
func (h *AccountHandler) GetOllamaCloudUsage(c *gin.Context) {
|
||||
if !h.requireOllamaCloudUsage(c) {
|
||||
return
|
||||
}
|
||||
accountID, ok := ollamaCloudUsageAccountID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
state, err := h.ollamaCloudUsage.GetState(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, state)
|
||||
}
|
||||
|
||||
func (h *AccountHandler) SaveOllamaCloudUsageSession(c *gin.Context) {
|
||||
if !h.requireOllamaCloudUsage(c) {
|
||||
return
|
||||
}
|
||||
accountID, ok := ollamaCloudUsageAccountID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req ollamaCloudUsageSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
state, err := h.ollamaCloudUsage.SaveSession(c.Request.Context(), accountID, req.Session)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, state)
|
||||
}
|
||||
|
||||
func (h *AccountHandler) DeleteOllamaCloudUsageSession(c *gin.Context) {
|
||||
if !h.requireOllamaCloudUsage(c) {
|
||||
return
|
||||
}
|
||||
accountID, ok := ollamaCloudUsageAccountID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
state, err := h.ollamaCloudUsage.DeleteSession(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, state)
|
||||
}
|
||||
|
||||
func (h *AccountHandler) SetOllamaCloudUsageAutoRefresh(c *gin.Context) {
|
||||
if !h.requireOllamaCloudUsage(c) {
|
||||
return
|
||||
}
|
||||
accountID, ok := ollamaCloudUsageAccountID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req ollamaCloudUsageAutoRefreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
state, err := h.ollamaCloudUsage.SetAutoRefresh(c.Request.Context(), accountID, *req.Enabled)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, state)
|
||||
}
|
||||
|
||||
func (h *AccountHandler) RefreshOllamaCloudUsage(c *gin.Context) {
|
||||
if !h.requireOllamaCloudUsage(c) {
|
||||
return
|
||||
}
|
||||
accountID, ok := ollamaCloudUsageAccountID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
state, err := h.ollamaCloudUsage.Refresh(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, state)
|
||||
}
|
||||
|
||||
func (h *AccountHandler) requireOllamaCloudUsage(c *gin.Context) bool {
|
||||
if h != nil && h.ollamaCloudUsage != nil {
|
||||
return true
|
||||
}
|
||||
response.ErrorFrom(c, service.ErrOllamaCloudUsageUnavailable)
|
||||
return false
|
||||
}
|
||||
|
||||
func ollamaCloudUsageAccountID(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,276 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type ollamaCloudUsageHandlerTestRepo struct {
|
||||
service.AccountRepository
|
||||
account *service.Account
|
||||
accounts []*service.Account
|
||||
groupResolveCalls int
|
||||
}
|
||||
|
||||
func (r *ollamaCloudUsageHandlerTestRepo) 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 *ollamaCloudUsageHandlerTestRepo) ListOllamaCloudUsageGroupAccounts(_ context.Context, _ []*service.Account) ([]service.Account, error) {
|
||||
r.groupResolveCalls++
|
||||
result := make([]service.Account, 0, len(r.accounts)+1)
|
||||
if r.account != nil {
|
||||
result = append(result, *r.account)
|
||||
}
|
||||
for _, account := range r.accounts {
|
||||
result = append(result, *account)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *ollamaCloudUsageHandlerTestRepo) SaveOllamaCloudUsageSession(context.Context, *service.Account, string, bool) error {
|
||||
return nil
|
||||
}
|
||||
func (r *ollamaCloudUsageHandlerTestRepo) DeleteOllamaCloudUsageSession(context.Context, *service.Account) error {
|
||||
return nil
|
||||
}
|
||||
func (r *ollamaCloudUsageHandlerTestRepo) SetOllamaCloudUsageAutoRefresh(context.Context, *service.Account, bool) error {
|
||||
return nil
|
||||
}
|
||||
func (r *ollamaCloudUsageHandlerTestRepo) UpdateOllamaCloudUsageSnapshot(context.Context, *service.Account, *service.OllamaCloudUsageSnapshot) error {
|
||||
return nil
|
||||
}
|
||||
func (r *ollamaCloudUsageHandlerTestRepo) DisableOllamaCloudUsageAutoRefresh(context.Context, *service.Account) error {
|
||||
return nil
|
||||
}
|
||||
func (r *ollamaCloudUsageHandlerTestRepo) ListDueOllamaCloudUsageAccounts(context.Context, time.Time, int) ([]service.Account, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newOllamaCloudUsageHandlerTestService(t *testing.T) *service.OllamaCloudUsageService {
|
||||
t.Helper()
|
||||
svc := service.NewOllamaCloudUsageService(nil, nil, nil, nil, false)
|
||||
t.Cleanup(svc.Stop)
|
||||
return svc
|
||||
}
|
||||
|
||||
func newOllamaCloudUsageHandlerContext(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 TestOllamaCloudUsageHandlersValidateRequestsAndDependencies(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newOllamaCloudUsageHandlerTestService(t)
|
||||
|
||||
t.Run("invalid account id", func(t *testing.T) {
|
||||
ctx, recorder := newOllamaCloudUsageHandlerContext(http.MethodGet, "/admin/accounts/not-an-id/ollama-cloud-usage", "", "not-an-id")
|
||||
(&AccountHandler{ollamaCloudUsage: svc}).GetOllamaCloudUsage(ctx)
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
})
|
||||
|
||||
t.Run("empty session", func(t *testing.T) {
|
||||
ctx, recorder := newOllamaCloudUsageHandlerContext(http.MethodPut, "/admin/accounts/7/ollama-cloud-usage/session", `{"session":""}`, "7")
|
||||
(&AccountHandler{ollamaCloudUsage: svc}).SaveOllamaCloudUsageSession(ctx)
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
})
|
||||
|
||||
t.Run("missing enabled", func(t *testing.T) {
|
||||
ctx, recorder := newOllamaCloudUsageHandlerContext(http.MethodPut, "/admin/accounts/7/ollama-cloud-usage/auto-refresh", `{}`, "7")
|
||||
(&AccountHandler{ollamaCloudUsage: svc}).SetOllamaCloudUsageAutoRefresh(ctx)
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
})
|
||||
|
||||
t.Run("service unavailable", func(t *testing.T) {
|
||||
ctx, recorder := newOllamaCloudUsageHandlerContext(http.MethodGet, "/admin/accounts/7/ollama-cloud-usage", "", "7")
|
||||
(&AccountHandler{}).GetOllamaCloudUsage(ctx)
|
||||
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), "OLLAMA_CLOUD_USAGE_UNAVAILABLE")
|
||||
})
|
||||
}
|
||||
|
||||
func TestOllamaCloudUsageEncryptionKeyStateConsistentAcrossAccountResponses(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
for _, configured := range []bool{false, true} {
|
||||
t.Run("configured="+strconv.FormatBool(configured), func(t *testing.T) {
|
||||
account := &service.Account{
|
||||
ID: 7,
|
||||
Name: "ollama",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"base_url": "https://ollama.com", "api_key": "test-key"},
|
||||
Extra: map[string]any{},
|
||||
Status: service.StatusActive,
|
||||
}
|
||||
adminService := newStubAdminService()
|
||||
adminService.accounts = []service.Account{*account}
|
||||
adminService.getAccountResult = account
|
||||
usageService := service.NewOllamaCloudUsageService(
|
||||
&ollamaCloudUsageHandlerTestRepo{account: account}, nil, nil, nil, configured,
|
||||
)
|
||||
t.Cleanup(usageService.Stop)
|
||||
|
||||
handler := NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler.SetOllamaCloudUsageService(usageService)
|
||||
router := gin.New()
|
||||
router.GET("/accounts", handler.List)
|
||||
router.GET("/accounts/:id", handler.GetByID)
|
||||
router.GET("/accounts/:id/ollama-cloud-usage", handler.GetOllamaCloudUsage)
|
||||
|
||||
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 {
|
||||
OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_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].OllamaCloudUsage)
|
||||
|
||||
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 {
|
||||
OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(detailRecorder.Body.Bytes(), &detailPayload))
|
||||
require.NotNil(t, detailPayload.Data.OllamaCloudUsage)
|
||||
|
||||
stateRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(stateRecorder, httptest.NewRequest(http.MethodGet, "/accounts/7/ollama-cloud-usage", nil))
|
||||
require.Equal(t, http.StatusOK, stateRecorder.Code)
|
||||
var statePayload struct {
|
||||
Data service.OllamaCloudUsageState `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(stateRecorder.Body.Bytes(), &statePayload))
|
||||
|
||||
listConfigured := listPayload.Data.Items[0].OllamaCloudUsage.EncryptionKeyConfigured
|
||||
detailConfigured := detailPayload.Data.OllamaCloudUsage.EncryptionKeyConfigured
|
||||
require.Equal(t, configured, listConfigured)
|
||||
require.Equal(t, statePayload.Data.EncryptionKeyConfigured, listConfigured)
|
||||
require.Equal(t, statePayload.Data.EncryptionKeyConfigured, detailConfigured)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOllamaCloudUsageSharedStateMatchesListDetailAndSpecialEndpointWithoutListNPlusOne(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
now := time.Now().UTC()
|
||||
source := &service.Account{
|
||||
ID: 7, Name: "source", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"base_url": "https://ollama.com", "api_key": "shared-secret-key"},
|
||||
Extra: map[string]any{
|
||||
service.OllamaCloudUsageSessionExtraKey: "ciphertext-secret",
|
||||
service.OllamaCloudUsageAutoRefreshExtraKey: true,
|
||||
service.OllamaCloudUsageSnapshotExtraKey: &service.OllamaCloudUsageSnapshot{
|
||||
Status: service.OllamaCloudUsageStatusOK, Data: &service.OllamaCloudUsageData{Plan: "pro"},
|
||||
LastAttemptAt: now, NextRefreshAt: now.Add(time.Hour),
|
||||
},
|
||||
},
|
||||
Status: service.StatusActive,
|
||||
}
|
||||
sibling := &service.Account{
|
||||
ID: 8, Name: "sibling", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"base_url": "HTTPS://WWW.OLLAMA.COM:443/v1", "api_key": "shared-secret-key"},
|
||||
Extra: map[string]any{}, Status: service.StatusActive,
|
||||
}
|
||||
repo := &ollamaCloudUsageHandlerTestRepo{accounts: []*service.Account{source, sibling}}
|
||||
adminService := newStubAdminService()
|
||||
adminService.accounts = []service.Account{*source, *sibling}
|
||||
adminService.getAccountResult = sibling
|
||||
usageService := service.NewOllamaCloudUsageService(repo, nil, nil, nil, true)
|
||||
t.Cleanup(usageService.Stop)
|
||||
handler := NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler.SetOllamaCloudUsageService(usageService)
|
||||
router := gin.New()
|
||||
router.GET("/accounts", handler.List)
|
||||
router.GET("/accounts/:id", handler.GetByID)
|
||||
router.GET("/accounts/:id/ollama-cloud-usage", handler.GetOllamaCloudUsage)
|
||||
|
||||
listRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, "/accounts?page=1&page_size=20", nil))
|
||||
require.Equal(t, http.StatusOK, listRecorder.Code)
|
||||
require.Equal(t, 1, repo.groupResolveCalls, "the full list page must use one group-resolution batch")
|
||||
var listPayload struct {
|
||||
Data struct {
|
||||
Items []struct {
|
||||
ID int64 `json:"id"`
|
||||
OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(listRecorder.Body.Bytes(), &listPayload))
|
||||
require.Len(t, listPayload.Data.Items, 2)
|
||||
for _, item := range listPayload.Data.Items {
|
||||
require.True(t, item.OllamaCloudUsage.Configured)
|
||||
require.Equal(t, "pro", item.OllamaCloudUsage.Snapshot.Data.Plan)
|
||||
}
|
||||
|
||||
detailRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(detailRecorder, httptest.NewRequest(http.MethodGet, "/accounts/8", nil))
|
||||
require.Equal(t, http.StatusOK, detailRecorder.Code)
|
||||
var detailPayload struct {
|
||||
Data struct {
|
||||
OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(detailRecorder.Body.Bytes(), &detailPayload))
|
||||
|
||||
stateRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(stateRecorder, httptest.NewRequest(http.MethodGet, "/accounts/8/ollama-cloud-usage", nil))
|
||||
require.Equal(t, http.StatusOK, stateRecorder.Code)
|
||||
var statePayload struct {
|
||||
Data service.OllamaCloudUsageState `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(stateRecorder.Body.Bytes(), &statePayload))
|
||||
require.Equal(t, statePayload.Data.Configured, detailPayload.Data.OllamaCloudUsage.Configured)
|
||||
require.Equal(t, statePayload.Data.Snapshot, detailPayload.Data.OllamaCloudUsage.Snapshot)
|
||||
for _, body := range []string{listRecorder.Body.String(), detailRecorder.Body.String(), stateRecorder.Body.String()} {
|
||||
require.NotContains(t, body, "shared-secret-key")
|
||||
require.NotContains(t, body, "ciphertext-secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOllamaCloudUsageSettingsHandlerSuccess(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, recorder := newOllamaCloudUsageHandlerContext(http.MethodGet, "/admin/accounts/ollama-cloud-usage/settings", "", "")
|
||||
handler := &AccountHandler{ollamaCloudUsage: newOllamaCloudUsageHandlerTestService(t)}
|
||||
|
||||
handler.GetOllamaCloudUsageSettings(ctx)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), `"enabled":false`)
|
||||
require.Contains(t, recorder.Body.String(), `"interval_minutes":60`)
|
||||
}
|
||||
@@ -58,6 +58,41 @@ func TestAccountFromServiceShallow_RedactsSensitiveCredentials(t *testing.T) {
|
||||
require.Equal(t, "rt-secret", src.Credentials["refresh_token"])
|
||||
}
|
||||
|
||||
func TestAccountFromServiceShallow_RedactsOllamaCloudManagedExtra(t *testing.T) {
|
||||
snapshot := map[string]any{
|
||||
"status": service.OllamaCloudUsageStatusOK,
|
||||
"last_attempt_at": "2026-07-22T12:00:00Z",
|
||||
"next_refresh_at": "2026-07-22T13:00:00Z",
|
||||
"data": map[string]any{"plan": "Pro"},
|
||||
}
|
||||
src := &service.Account{
|
||||
ID: 9, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"base_url": "https://ollama.com", "api_key": "secret-key"},
|
||||
Extra: map[string]any{
|
||||
service.OllamaCloudUsageSessionExtraKey: "ciphertext-secret",
|
||||
service.OllamaCloudUsageAutoRefreshExtraKey: true,
|
||||
service.OllamaCloudUsageSnapshotExtraKey: snapshot,
|
||||
"ordinary": "kept",
|
||||
},
|
||||
}
|
||||
|
||||
got := AccountFromServiceShallow(src)
|
||||
require.NotContains(t, got.Extra, service.OllamaCloudUsageSessionExtraKey)
|
||||
require.NotContains(t, got.Extra, service.OllamaCloudUsageAutoRefreshExtraKey)
|
||||
require.NotContains(t, got.Extra, service.OllamaCloudUsageSnapshotExtraKey)
|
||||
require.Equal(t, "kept", got.Extra["ordinary"])
|
||||
require.NotNil(t, got.OllamaCloudUsage)
|
||||
require.True(t, got.OllamaCloudUsage.Configured)
|
||||
require.True(t, got.OllamaCloudUsage.AutoRefreshEnabled)
|
||||
require.Equal(t, "Pro", got.OllamaCloudUsage.Snapshot.Data.Plan)
|
||||
|
||||
raw, err := json.Marshal(got)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(raw), "ciphertext-secret")
|
||||
require.NotContains(t, string(raw), "secret-key")
|
||||
require.Contains(t, src.Extra, service.OllamaCloudUsageSessionExtraKey)
|
||||
}
|
||||
|
||||
func TestAccountFromServiceShallow_NilCredentialsOmitsStatus(t *testing.T) {
|
||||
src := &service.Account{ID: 1, Name: "n", Platform: "anthropic", Type: "oauth"}
|
||||
got := AccountFromServiceShallow(src)
|
||||
|
||||
@@ -219,6 +219,11 @@ func AccountFromServiceShallow(a *service.Account) *Account {
|
||||
return nil
|
||||
}
|
||||
redactedCreds, credsStatus := RedactCredentials(a.Credentials)
|
||||
extra := redactAccountManagedExtra(a.Extra)
|
||||
var ollamaCloudUsage *service.OllamaCloudUsageState
|
||||
if state := service.OllamaCloudUsageStateFromAccount(a); state.Eligible {
|
||||
ollamaCloudUsage = state
|
||||
}
|
||||
out := &Account{
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
@@ -227,7 +232,8 @@ func AccountFromServiceShallow(a *service.Account) *Account {
|
||||
Type: a.Type,
|
||||
Credentials: redactedCreds,
|
||||
CredentialsStatus: credsStatus,
|
||||
Extra: a.Extra,
|
||||
Extra: extra,
|
||||
OllamaCloudUsage: ollamaCloudUsage,
|
||||
ProxyID: a.ProxyID,
|
||||
ProxyFallbackOriginID: a.ProxyFallbackOriginID,
|
||||
ProxyFallbackOriginName: a.ProxyFallbackOriginName,
|
||||
@@ -385,6 +391,24 @@ func AccountFromServiceShallow(a *service.Account) *Account {
|
||||
return out
|
||||
}
|
||||
|
||||
func redactAccountManagedExtra(extra map[string]any) map[string]any {
|
||||
if extra == nil {
|
||||
return nil
|
||||
}
|
||||
redacted := make(map[string]any, len(extra))
|
||||
for key, value := range extra {
|
||||
switch key {
|
||||
case service.OllamaCloudUsageSessionExtraKey,
|
||||
service.OllamaCloudUsageAutoRefreshExtraKey,
|
||||
service.OllamaCloudUsageSnapshotExtraKey:
|
||||
continue
|
||||
default:
|
||||
redacted[key] = value
|
||||
}
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
|
||||
func AccountFromService(a *service.Account) *Account {
|
||||
if a == nil {
|
||||
return nil
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
@@ -183,23 +184,24 @@ type Account struct {
|
||||
Type string `json:"type"`
|
||||
// Credentials 经 RedactCredentials 处理后只含非敏感子键;敏感 token / api_key / 私钥
|
||||
// 的存在性通过 CredentialsStatus(has_<key>)暴露,原始值不返回前端。
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
CredentialsStatus map[string]bool `json:"credentials_status,omitempty"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"`
|
||||
ProxyFallbackOriginName *string `json:"proxy_fallback_origin_name,omitempty"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
LoadFactor *int `json:"load_factor,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
Status string `json:"status"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
ExpiresAt *int64 `json:"expires_at"`
|
||||
AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
CredentialsStatus map[string]bool `json:"credentials_status,omitempty"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage,omitempty"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"`
|
||||
ProxyFallbackOriginName *string `json:"proxy_fallback_origin_name,omitempty"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
LoadFactor *int `json:"load_factor,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
Status string `json:"status"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
ExpiresAt *int64 `json:"expires_at"`
|
||||
AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
Schedulable bool `json:"schedulable"`
|
||||
|
||||
|
||||
@@ -46,8 +46,10 @@ func ProvideAdminHandlers(
|
||||
complianceHandler *admin.ComplianceHandler,
|
||||
auditLogHandler *admin.AuditLogHandler,
|
||||
upstreamBillingProbe *service.UpstreamBillingProbeService,
|
||||
ollamaCloudUsage *service.OllamaCloudUsageService,
|
||||
) *AdminHandlers {
|
||||
accountHandler.SetUpstreamBillingProbeService(upstreamBillingProbe)
|
||||
accountHandler.SetOllamaCloudUsageService(ollamaCloudUsage)
|
||||
return &AdminHandlers{
|
||||
Dashboard: dashboardHandler,
|
||||
User: userHandler,
|
||||
|
||||
@@ -58,6 +58,7 @@ var schedulerNeutralExtraKeyPrefixes = []string{
|
||||
"codex_7d_",
|
||||
"passive_usage_",
|
||||
"upstream_billing_probe",
|
||||
"ollama_cloud_usage",
|
||||
}
|
||||
|
||||
var schedulerNeutralExtraKeys = map[string]struct{}{
|
||||
@@ -556,8 +557,22 @@ func lockAndMergeAccountProbeExtra(ctx context.Context, client *dbent.Client, ac
|
||||
AND type = $3
|
||||
AND credentials = $4::jsonb
|
||||
AND proxy_id IS NOT DISTINCT FROM $5,
|
||||
COALESCE(
|
||||
platform IN ('openai', 'anthropic')
|
||||
AND $2 IN ('openai', 'anthropic')
|
||||
AND type = 'apikey'
|
||||
AND $3 = 'apikey'
|
||||
AND credentials -> 'api_key' IS NOT DISTINCT FROM $4::jsonb -> 'api_key'
|
||||
AND `+ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'")+`
|
||||
AND `+ollamaCloudBaseURLMatchesSQL("$4::jsonb ->> 'base_url'")+`,
|
||||
false
|
||||
),
|
||||
proxy_id IS NOT DISTINCT FROM $5,
|
||||
extra -> 'upstream_billing_probe_enabled',
|
||||
extra -> 'upstream_billing_probe'
|
||||
extra -> 'upstream_billing_probe',
|
||||
extra -> 'ollama_cloud_usage_session',
|
||||
extra -> 'ollama_cloud_usage_auto_refresh',
|
||||
extra -> 'ollama_cloud_usage_snapshot'
|
||||
FROM accounts
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
FOR NO KEY UPDATE
|
||||
@@ -574,11 +589,25 @@ func lockAndMergeAccountProbeExtra(ctx context.Context, client *dbent.Client, ac
|
||||
}
|
||||
|
||||
var (
|
||||
identityUnchanged bool
|
||||
currentEnabled []byte
|
||||
currentSnapshot []byte
|
||||
identityUnchanged bool
|
||||
ollamaGroupIdentityUnchanged bool
|
||||
ollamaProxyIdentityUnchanged bool
|
||||
currentEnabled []byte
|
||||
currentSnapshot []byte
|
||||
currentOllamaSession []byte
|
||||
currentOllamaAutoRefresh []byte
|
||||
currentOllamaSnapshot []byte
|
||||
)
|
||||
if err := rows.Scan(&identityUnchanged, ¤tEnabled, ¤tSnapshot); err != nil {
|
||||
if err := rows.Scan(
|
||||
&identityUnchanged,
|
||||
&ollamaGroupIdentityUnchanged,
|
||||
&ollamaProxyIdentityUnchanged,
|
||||
¤tEnabled,
|
||||
¤tSnapshot,
|
||||
¤tOllamaSession,
|
||||
¤tOllamaAutoRefresh,
|
||||
¤tOllamaSnapshot,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -586,34 +615,71 @@ func lockAndMergeAccountProbeExtra(ctx context.Context, client *dbent.Client, ac
|
||||
}
|
||||
|
||||
extra := copyJSONMap(normalizeJSONMap(account.Extra))
|
||||
delete(extra, service.UpstreamBillingProbeEnabledExtraKey)
|
||||
delete(extra, service.UpstreamBillingProbeExtraKey)
|
||||
for _, key := range []string{
|
||||
service.UpstreamBillingProbeEnabledExtraKey,
|
||||
service.UpstreamBillingProbeExtraKey,
|
||||
service.OllamaCloudUsageSessionExtraKey,
|
||||
service.OllamaCloudUsageAutoRefreshExtraKey,
|
||||
service.OllamaCloudUsageSnapshotExtraKey,
|
||||
} {
|
||||
delete(extra, key)
|
||||
}
|
||||
probeExplicitlyDisabled := false
|
||||
probeAccount := account.Platform == service.PlatformOpenAI && account.Type == service.AccountTypeAPIKey
|
||||
if probeAccount && explicitProbeEnabled != nil {
|
||||
extra[service.UpstreamBillingProbeEnabledExtraKey] = *explicitProbeEnabled
|
||||
probeExplicitlyDisabled = !*explicitProbeEnabled
|
||||
} else if probeAccount && len(currentEnabled) > 0 && string(currentEnabled) != "null" {
|
||||
var enabled any
|
||||
if err := json.Unmarshal(currentEnabled, &enabled); err != nil {
|
||||
} else if probeAccount {
|
||||
if enabled, ok, err := decodeAccountExtraJSON(currentEnabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
extra[service.UpstreamBillingProbeEnabledExtraKey] = enabled
|
||||
if value, ok := enabled.(bool); ok && !value {
|
||||
probeExplicitlyDisabled = true
|
||||
} else if ok {
|
||||
extra[service.UpstreamBillingProbeEnabledExtraKey] = enabled
|
||||
if value, isBool := enabled.(bool); isBool && !value {
|
||||
probeExplicitlyDisabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !identityUnchanged || probeExplicitlyDisabled || len(currentSnapshot) == 0 || string(currentSnapshot) == "null" {
|
||||
return extra, nil
|
||||
if identityUnchanged && !probeExplicitlyDisabled {
|
||||
if snapshot, ok, err := decodeAccountExtraJSON(currentSnapshot); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
extra[service.UpstreamBillingProbeExtraKey] = snapshot
|
||||
}
|
||||
}
|
||||
var snapshot any
|
||||
if err := json.Unmarshal(currentSnapshot, &snapshot); err != nil {
|
||||
return nil, err
|
||||
|
||||
if service.IsOllamaCloudUsageAccount(account) && ollamaGroupIdentityUnchanged {
|
||||
for key, raw := range map[string][]byte{
|
||||
service.OllamaCloudUsageSessionExtraKey: currentOllamaSession,
|
||||
service.OllamaCloudUsageAutoRefreshExtraKey: currentOllamaAutoRefresh,
|
||||
} {
|
||||
if value, ok, err := decodeAccountExtraJSON(raw); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
extra[key] = value
|
||||
}
|
||||
}
|
||||
if ollamaProxyIdentityUnchanged {
|
||||
if snapshot, ok, err := decodeAccountExtraJSON(currentOllamaSnapshot); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
extra[service.OllamaCloudUsageSnapshotExtraKey] = snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
extra[service.UpstreamBillingProbeExtraKey] = snapshot
|
||||
return extra, nil
|
||||
}
|
||||
|
||||
func decodeAccountExtraJSON(raw []byte) (any, bool, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, false, nil
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
func (r *accountRepository) UpdateCredentials(ctx context.Context, id int64, credentials map[string]any) error {
|
||||
payload, err := json.Marshal(normalizeJSONMap(credentials))
|
||||
if err != nil {
|
||||
@@ -642,6 +708,22 @@ func (r *accountRepository) UpdateCredentials(ctx context.Context, id int64, cre
|
||||
SET
|
||||
credentials = $1::jsonb,
|
||||
extra = CASE
|
||||
WHEN platform IN ('openai', 'anthropic')
|
||||
AND type = 'apikey'
|
||||
AND (
|
||||
credentials -> 'api_key' IS DISTINCT FROM $1::jsonb -> 'api_key'
|
||||
OR NOT (
|
||||
`+ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'")+`
|
||||
AND `+ollamaCloudBaseURLMatchesSQL("$1::jsonb ->> 'base_url'")+`
|
||||
)
|
||||
)
|
||||
THEN (CASE
|
||||
WHEN platform = 'openai' THEN COALESCE(extra, '{}'::jsonb) - 'upstream_billing_probe'
|
||||
ELSE COALESCE(extra, '{}'::jsonb)
|
||||
END)
|
||||
- 'ollama_cloud_usage_session'
|
||||
- 'ollama_cloud_usage_auto_refresh'
|
||||
- 'ollama_cloud_usage_snapshot'
|
||||
WHEN platform = 'openai'
|
||||
AND type = 'apikey'
|
||||
AND credentials IS DISTINCT FROM $1::jsonb
|
||||
@@ -2605,6 +2687,11 @@ func upstreamBillingProbeSnapshotClearRequested(extra map[string]any) bool {
|
||||
return ok && value == nil
|
||||
}
|
||||
|
||||
func ollamaCloudUsageSnapshotClearRequested(extra map[string]any) bool {
|
||||
value, ok := extra[service.OllamaCloudUsageSnapshotExtraKey]
|
||||
return ok && value == nil
|
||||
}
|
||||
|
||||
func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates service.AccountBulkUpdate) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
@@ -2614,6 +2701,7 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
|
||||
args := make([]any, 0, 8)
|
||||
|
||||
idx := 1
|
||||
ollamaProxyIdentityChanged := ""
|
||||
if updates.Name != nil {
|
||||
setClauses = append(setClauses, "name = $"+itoa(idx))
|
||||
args = append(args, *updates.Name)
|
||||
@@ -2623,8 +2711,11 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
|
||||
// 0 表示清除代理(前端发送 0 而不是 null 来表达清除意图)
|
||||
if *updates.ProxyID == 0 {
|
||||
setClauses = append(setClauses, "proxy_id = NULL")
|
||||
ollamaProxyIdentityChanged = "proxy_id IS NOT NULL"
|
||||
} else {
|
||||
setClauses = append(setClauses, "proxy_id = $"+itoa(idx))
|
||||
proxyPlaceholder := "$" + itoa(idx)
|
||||
setClauses = append(setClauses, "proxy_id = "+proxyPlaceholder)
|
||||
ollamaProxyIdentityChanged = "proxy_id IS DISTINCT FROM " + proxyPlaceholder
|
||||
args = append(args, *updates.ProxyID)
|
||||
idx++
|
||||
}
|
||||
@@ -2670,27 +2761,68 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
|
||||
updates.Extra[service.UpstreamBillingProbeEnabledExtraKey] = *updates.ProbeEnabled
|
||||
}
|
||||
// JSONB 需要合并而非覆盖,使用 raw SQL 保持旧行为。
|
||||
credentialPlaceholder := ""
|
||||
if len(updates.Credentials) > 0 {
|
||||
payload, err := json.Marshal(updates.Credentials)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
setClauses = append(setClauses, "credentials = COALESCE(credentials, '{}'::jsonb) || $"+itoa(idx)+"::jsonb")
|
||||
credentialPlaceholder = "$" + itoa(idx)
|
||||
setClauses = append(setClauses, "credentials = COALESCE(credentials, '{}'::jsonb) || "+credentialPlaceholder+"::jsonb")
|
||||
args = append(args, payload)
|
||||
idx++
|
||||
}
|
||||
if len(updates.Extra) > 0 {
|
||||
payload, err := json.Marshal(updates.Extra)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
||||
ollamaGroupIdentityChanges := make([]string, 0, 2)
|
||||
if _, ok := updates.Credentials["api_key"]; ok {
|
||||
ollamaGroupIdentityChanges = append(ollamaGroupIdentityChanges, "credentials -> 'api_key' IS DISTINCT FROM "+credentialPlaceholder+"::jsonb -> 'api_key'")
|
||||
}
|
||||
if _, ok := updates.Credentials["base_url"]; ok {
|
||||
ollamaGroupIdentityChanges = append(ollamaGroupIdentityChanges,
|
||||
"NOT ("+ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'")+
|
||||
" AND "+ollamaCloudBaseURLMatchesSQL(credentialPlaceholder+"::jsonb ->> 'base_url'")+")")
|
||||
}
|
||||
|
||||
if len(updates.Extra) > 0 || len(ollamaGroupIdentityChanges) > 0 || ollamaProxyIdentityChanged != "" {
|
||||
extraExpression := "COALESCE(extra, '{}'::jsonb)"
|
||||
if len(updates.Extra) > 0 {
|
||||
payload, err := json.Marshal(updates.Extra)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
extraExpression += " || $" + itoa(idx) + "::jsonb"
|
||||
args = append(args, payload)
|
||||
idx++
|
||||
if upstreamBillingProbeExplicitlyDisabled(updates.Extra) || upstreamBillingProbeSnapshotClearRequested(updates.Extra) {
|
||||
extraExpression = "(" + extraExpression + ") - 'upstream_billing_probe'"
|
||||
}
|
||||
if ollamaCloudUsageSnapshotClearRequested(updates.Extra) {
|
||||
extraExpression = "(" + extraExpression + ") - 'ollama_cloud_usage_snapshot'"
|
||||
}
|
||||
}
|
||||
extraExpression := "COALESCE(extra, '{}'::jsonb) || $" + itoa(idx) + "::jsonb"
|
||||
if upstreamBillingProbeExplicitlyDisabled(updates.Extra) || upstreamBillingProbeSnapshotClearRequested(updates.Extra) {
|
||||
extraExpression = "(" + extraExpression + ") - 'upstream_billing_probe'"
|
||||
eligibleAccount := "platform IN ('openai', 'anthropic') AND type = 'apikey'"
|
||||
groupIdentityChanged := ""
|
||||
if len(ollamaGroupIdentityChanges) > 0 {
|
||||
groupIdentityChanged = "(" + eligibleAccount + " AND (" + joinClauses(ollamaGroupIdentityChanges, " OR ") + "))"
|
||||
}
|
||||
snapshotIdentityChanged := groupIdentityChanged
|
||||
if ollamaProxyIdentityChanged != "" {
|
||||
proxyChanged := "(" + eligibleAccount + " AND " + ollamaProxyIdentityChanged + ")"
|
||||
if snapshotIdentityChanged == "" {
|
||||
snapshotIdentityChanged = proxyChanged
|
||||
} else {
|
||||
snapshotIdentityChanged = "(" + snapshotIdentityChanged + " OR " + proxyChanged + ")"
|
||||
}
|
||||
}
|
||||
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"
|
||||
}
|
||||
setClauses = append(setClauses, "extra = "+extraExpression)
|
||||
args = append(args, payload)
|
||||
idx++
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
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 (
|
||||
ollamaCloudBaseURLRegexSQL = `^[hH][tT][tT][pP][sS]://([wW][wW][wW]\.)?[oO][lL][lL][aA][mM][aA]\.[cC][oO][mM](:443)?(/v1)?$`
|
||||
ollamaCloudBaseURLMatchSQLPrefix = "btrim("
|
||||
ollamaCloudBaseURLMatchSQLSuffix = ") ~ '" + ollamaCloudBaseURLRegexSQL + "'"
|
||||
ollamaCloudUsageEligibleSQL = `
|
||||
platform IN ('openai', 'anthropic')
|
||||
AND type = 'apikey'
|
||||
AND ` + ollamaCloudBaseURLMatchSQLPrefix + `credentials ->> 'base_url'` + ollamaCloudBaseURLMatchSQLSuffix + `
|
||||
AND jsonb_typeof(credentials -> 'api_key') = 'string'
|
||||
`
|
||||
)
|
||||
|
||||
func ollamaCloudBaseURLMatchesSQL(expression string) string {
|
||||
return ollamaCloudBaseURLMatchSQLPrefix + expression + ollamaCloudBaseURLMatchSQLSuffix
|
||||
}
|
||||
|
||||
// ListOllamaCloudUsageGroupAccounts 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) ListOllamaCloudUsageGroupAccounts(ctx context.Context, accounts []*service.Account) ([]service.Account, error) {
|
||||
if r == nil || r.sql == nil {
|
||||
return nil, service.ErrOllamaCloudUsageUnavailable
|
||||
}
|
||||
keys := make([]string, 0, len(accounts))
|
||||
seen := make(map[string]struct{}, len(accounts))
|
||||
for _, account := range accounts {
|
||||
if !service.IsOllamaCloudUsageAccount(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 `+ollamaCloudUsageEligibleSQL+`
|
||||
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
|
||||
}
|
||||
|
||||
func (r *accountRepository) SaveOllamaCloudUsageSession(ctx context.Context, account *service.Account, ciphertext string, autoRefresh bool) error {
|
||||
return r.updateOllamaCloudUsageGroup(ctx, account, map[string]any{
|
||||
service.OllamaCloudUsageSessionExtraKey: ciphertext,
|
||||
service.OllamaCloudUsageAutoRefreshExtraKey: autoRefresh,
|
||||
}, false)
|
||||
}
|
||||
|
||||
func (r *accountRepository) DeleteOllamaCloudUsageSession(ctx context.Context, account *service.Account) error {
|
||||
return r.updateOllamaCloudUsageGroup(ctx, account, map[string]any{}, false)
|
||||
}
|
||||
|
||||
func (r *accountRepository) SetOllamaCloudUsageAutoRefresh(ctx context.Context, account *service.Account, enabled bool) error {
|
||||
if !ollamaCloudUsageAccountHasSession(account) {
|
||||
return service.ErrOllamaCloudUsageSessionRequired
|
||||
}
|
||||
payload := ollamaCloudUsageManagedPayload(account)
|
||||
payload[service.OllamaCloudUsageAutoRefreshExtraKey] = enabled
|
||||
return r.updateOllamaCloudUsageGroup(ctx, account, payload, true)
|
||||
}
|
||||
|
||||
func (r *accountRepository) UpdateOllamaCloudUsageSnapshot(ctx context.Context, account *service.Account, snapshot *service.OllamaCloudUsageSnapshot) error {
|
||||
if account == nil || snapshot == nil {
|
||||
return service.ErrAccountNilInput
|
||||
}
|
||||
if !ollamaCloudUsageAccountHasSession(account) {
|
||||
return service.ErrOllamaCloudUsageSessionRequired
|
||||
}
|
||||
payload := ollamaCloudUsageManagedPayload(account)
|
||||
payload[service.OllamaCloudUsageSnapshotExtraKey] = snapshot
|
||||
return r.updateOllamaCloudUsageGroup(ctx, account, payload, true)
|
||||
}
|
||||
|
||||
// DisableOllamaCloudUsageAutoRefresh is group-scoped and retains the loaded
|
||||
// identity CAS. It cannot disable a new group after the account changes key.
|
||||
func (r *accountRepository) DisableOllamaCloudUsageAutoRefresh(ctx context.Context, account *service.Account) error {
|
||||
if !ollamaCloudUsageAccountHasSession(account) {
|
||||
return service.ErrOllamaCloudUsageSessionRequired
|
||||
}
|
||||
payload := ollamaCloudUsageManagedPayload(account)
|
||||
payload[service.OllamaCloudUsageAutoRefreshExtraKey] = false
|
||||
delete(payload, service.OllamaCloudUsageSnapshotExtraKey)
|
||||
return r.updateOllamaCloudUsageGroup(ctx, account, payload, true)
|
||||
}
|
||||
|
||||
func ollamaCloudUsageManagedPayload(account *service.Account) map[string]any {
|
||||
payload := make(map[string]any, 3)
|
||||
if account == nil || account.Extra == nil {
|
||||
return payload
|
||||
}
|
||||
for _, key := range []string{
|
||||
service.OllamaCloudUsageSessionExtraKey,
|
||||
service.OllamaCloudUsageAutoRefreshExtraKey,
|
||||
service.OllamaCloudUsageSnapshotExtraKey,
|
||||
} {
|
||||
if value, ok := account.Extra[key]; ok {
|
||||
payload[key] = value
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func ollamaCloudUsageAccountHasSession(account *service.Account) bool {
|
||||
if account == nil || account.Extra == nil {
|
||||
return false
|
||||
}
|
||||
value, ok := account.Extra[service.OllamaCloudUsageSessionExtraKey].(string)
|
||||
return ok && value != ""
|
||||
}
|
||||
|
||||
type lockedOllamaCloudUsageMember struct {
|
||||
id int64
|
||||
anchorMatches bool
|
||||
sessionJSON string
|
||||
autoJSON string
|
||||
snapshotJSON string
|
||||
}
|
||||
|
||||
func (r *accountRepository) updateOllamaCloudUsageGroup(
|
||||
ctx context.Context,
|
||||
account *service.Account,
|
||||
payload map[string]any,
|
||||
requireExpectedState bool,
|
||||
) error {
|
||||
if account == nil {
|
||||
return service.ErrAccountNilInput
|
||||
}
|
||||
if r == nil || r.client == nil || !service.IsOllamaCloudUsageAccount(account) {
|
||||
return service.ErrOllamaCloudUsageUnavailable
|
||||
}
|
||||
apiKey, ok := account.Credentials["api_key"].(string)
|
||||
if !ok || apiKey == "" {
|
||||
return service.ErrOllamaCloudUsageAccountInvalid
|
||||
}
|
||||
apply := func(txCtx context.Context, client *dbent.Client) error {
|
||||
matchesProxy, err := lockAndMatchProbeProxyIdentity(txCtx, client, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !matchesProxy {
|
||||
return service.ErrOllamaCloudUsageIdentityChanged
|
||||
}
|
||||
members, err := lockOllamaCloudUsageGroup(txCtx, client, account, apiKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
anchorMatches := false
|
||||
for _, member := range members {
|
||||
anchorMatches = anchorMatches || member.anchorMatches
|
||||
}
|
||||
if !anchorMatches {
|
||||
return service.ErrOllamaCloudUsageIdentityChanged
|
||||
}
|
||||
if requireExpectedState {
|
||||
expectedSession, err := canonicalAccountExtraJSON(account, service.OllamaCloudUsageSessionExtraKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expectedAuto, err := canonicalAccountExtraJSON(account, service.OllamaCloudUsageAutoRefreshExtraKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expectedSnapshot, err := canonicalAccountExtraJSON(account, service.OllamaCloudUsageSnapshotExtraKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stateMatches := false
|
||||
for _, member := range members {
|
||||
if canonicalJSON(member.sessionJSON) == expectedSession &&
|
||||
canonicalJSON(member.autoJSON) == expectedAuto &&
|
||||
canonicalJSON(member.snapshotJSON) == expectedSnapshot {
|
||||
stateMatches = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !stateMatches {
|
||||
return service.ErrOllamaCloudUsageIdentityChanged
|
||||
}
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
memberIDs := make([]int64, len(members))
|
||||
for index := range members {
|
||||
memberIDs[index] = members[index].id
|
||||
}
|
||||
result, err := client.ExecContext(txCtx, `
|
||||
UPDATE accounts
|
||||
SET extra = (COALESCE(extra, '{}'::jsonb)
|
||||
- 'ollama_cloud_usage_session'
|
||||
- 'ollama_cloud_usage_auto_refresh'
|
||||
- 'ollama_cloud_usage_snapshot') || $1::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE deleted_at IS NULL
|
||||
AND `+ollamaCloudUsageEligibleSQL+`
|
||||
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.ErrOllamaCloudUsageIdentityChanged
|
||||
}
|
||||
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 lockOllamaCloudUsageGroup(
|
||||
ctx context.Context,
|
||||
client *dbent.Client,
|
||||
account *service.Account,
|
||||
apiKey string,
|
||||
) ([]lockedOllamaCloudUsageMember, 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 -> 'ollama_cloud_usage_session')::text, 'null'),
|
||||
COALESCE((extra -> 'ollama_cloud_usage_auto_refresh')::text, 'null'),
|
||||
COALESCE((extra -> 'ollama_cloud_usage_snapshot')::text, 'null')
|
||||
FROM accounts
|
||||
WHERE deleted_at IS NULL
|
||||
AND `+ollamaCloudUsageEligibleSQL+`
|
||||
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([]lockedOllamaCloudUsageMember, 0, 1)
|
||||
for rows.Next() {
|
||||
var member lockedOllamaCloudUsageMember
|
||||
if err := rows.Scan(&member.id, &member.anchorMatches, &member.sessionJSON, &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.ErrOllamaCloudUsageIdentityChanged
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func canonicalAccountExtraJSON(account *service.Account, key string) (string, error) {
|
||||
var value any
|
||||
if account != nil && account.Extra != nil {
|
||||
value = account.Extra[key]
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return canonicalJSON(string(raw)), nil
|
||||
}
|
||||
|
||||
func canonicalJSON(raw string) string {
|
||||
var value any
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
return ""
|
||||
}
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
// ListDueOllamaCloudUsageAccounts returns at most one due representative per
|
||||
// exact API key before hydration, preventing one shared group from consuming a
|
||||
// whole runner cycle.
|
||||
func (r *accountRepository) ListDueOllamaCloudUsageAccounts(ctx context.Context, now time.Time, 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")
|
||||
}
|
||||
rows, err := r.sql.QueryContext(ctx, `
|
||||
WITH candidates AS (
|
||||
SELECT id, credentials ->> 'api_key' AS api_key,
|
||||
extra #>> '{ollama_cloud_usage_snapshot,next_refresh_at}' AS next_refresh_at
|
||||
FROM accounts
|
||||
WHERE deleted_at IS NULL
|
||||
AND status = 'active'
|
||||
AND `+ollamaCloudUsageEligibleSQL+`
|
||||
AND jsonb_typeof(extra -> 'ollama_cloud_usage_session') = 'string'
|
||||
AND extra @> '{"ollama_cloud_usage_auto_refresh": true}'::jsonb
|
||||
), parsed AS MATERIALIZED (
|
||||
SELECT id, api_key, next_refresh_at,
|
||||
next_refresh_at ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$' AS rfc3339_shape,
|
||||
jsonb_path_query_first_tz(
|
||||
to_jsonb(regexp_replace(
|
||||
next_refresh_at,
|
||||
'(\.[0-9]{6})[0-9]+(Z|[+-][0-9]{2}:[0-9]{2})$',
|
||||
'\1\2'
|
||||
)),
|
||||
'$.datetime()', '{}'::jsonb, true
|
||||
) #>> '{}' AS parsed_next_refresh_at
|
||||
FROM candidates
|
||||
), due AS (
|
||||
SELECT *,
|
||||
CASE WHEN next_refresh_at IS NULL OR NOT rfc3339_shape OR parsed_next_refresh_at IS NULL THEN 0 ELSE 1 END AS due_class
|
||||
FROM parsed
|
||||
WHERE next_refresh_at IS NULL
|
||||
OR NOT rfc3339_shape
|
||||
OR parsed_next_refresh_at IS NULL
|
||||
OR parsed_next_refresh_at::timestamptz <= $1
|
||||
), ranked AS (
|
||||
SELECT *, row_number() OVER (
|
||||
PARTITION BY api_key
|
||||
ORDER BY due_class,
|
||||
CASE WHEN rfc3339_shape AND parsed_next_refresh_at IS NOT NULL THEN parsed_next_refresh_at::timestamptz END NULLS FIRST,
|
||||
id
|
||||
) AS group_rank
|
||||
FROM due
|
||||
)
|
||||
SELECT id
|
||||
FROM ranked
|
||||
WHERE group_rank = 1
|
||||
ORDER BY due_class,
|
||||
CASE WHEN rfc3339_shape AND parsed_next_refresh_at IS NOT NULL THEN parsed_next_refresh_at::timestamptz END NULLS FIRST,
|
||||
id
|
||||
LIMIT $2
|
||||
`, now.UTC(), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
ids := make([]int64, 0, limit)
|
||||
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
|
||||
}
|
||||
accounts, err := r.GetByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]service.Account, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
if account != nil {
|
||||
result = append(result, *account)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestListDueOllamaCloudUsageAccountsOrderingLimitAndProxyHydration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
|
||||
now := time.Date(2026, time.July, 22, 12, 0, 0, 0, time.UTC)
|
||||
proxy := mustCreateProxy(t, tx.Client(), &service.Proxy{
|
||||
Name: "ollama-due-proxy", Protocol: "http", Host: "127.0.0.1", Port: 3128,
|
||||
Username: "user", Password: "pass", Status: service.StatusActive,
|
||||
})
|
||||
|
||||
createAccount := func(name, baseURL string, proxyID *int64, nextRefreshAt *time.Time) *service.Account {
|
||||
t.Helper()
|
||||
extra := map[string]any{
|
||||
service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture",
|
||||
service.OllamaCloudUsageAutoRefreshExtraKey: true,
|
||||
}
|
||||
if nextRefreshAt != nil {
|
||||
extra[service.OllamaCloudUsageSnapshotExtraKey] = map[string]any{
|
||||
"status": service.OllamaCloudUsageStatusOK, "next_refresh_at": nextRefreshAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
return mustCreateAccount(t, tx.Client(), &service.Account{
|
||||
Name: name, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": name, "base_url": baseURL},
|
||||
Extra: extra, ProxyID: proxyID,
|
||||
})
|
||||
}
|
||||
|
||||
uppercasePath := createAccount("ollama-uppercase-path", "https://ollama.com/V1", nil, nil)
|
||||
missingSnapshot := createAccount("ollama-due-missing", "HTTPS://WWW.OLLAMA.COM:443/v1", &proxy.ID, nil)
|
||||
oldest := now.Add(-2 * time.Hour)
|
||||
due := createAccount("ollama-due-oldest", "https://ollama.com", nil, &oldest)
|
||||
future := now.Add(time.Minute)
|
||||
_ = createAccount("ollama-not-due", "https://ollama.com", nil, &future)
|
||||
_ = createAccount("ollama-ineligible", "https://ollama.com.evil.test", nil, nil)
|
||||
|
||||
accounts, err := repo.ListDueOllamaCloudUsageAccounts(ctx, now, 2)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, accounts, 2)
|
||||
require.Equal(t, missingSnapshot.ID, accounts[0].ID)
|
||||
require.Equal(t, due.ID, accounts[1].ID)
|
||||
require.NotContains(t, accountIDs(accounts), uppercasePath.ID)
|
||||
require.NotNil(t, accounts[0].Proxy)
|
||||
require.Equal(t, proxy.ID, accounts[0].Proxy.ID)
|
||||
require.Equal(t, proxy.URL(), accounts[0].Proxy.URL())
|
||||
}
|
||||
|
||||
func TestListDueOllamaCloudUsageAccountsParsesRFC3339NanoAndFailsOpen(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
|
||||
now := time.Date(2026, time.July, 22, 14, 0, 0, 0, time.UTC)
|
||||
|
||||
create := func(name, nextRefreshAt string) *service.Account {
|
||||
t.Helper()
|
||||
return mustCreateAccount(t, tx.Client(), &service.Account{
|
||||
Name: name, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": name, "base_url": "https://ollama.com"},
|
||||
Extra: map[string]any{
|
||||
service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture",
|
||||
service.OllamaCloudUsageAutoRefreshExtraKey: true,
|
||||
service.OllamaCloudUsageSnapshotExtraKey: map[string]any{
|
||||
"status": service.OllamaCloudUsageStatusOK, "next_refresh_at": nextRefreshAt,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
sevenDigitsOffset := create("ollama-nano-seven", "2026-07-22T11:00:00.1234567-02:00")
|
||||
eightDigitsOffset := create("ollama-nano-eight", "2026-07-22T11:00:00.12345678+01:00")
|
||||
nineDigitsZ := create("ollama-nano-nine", "2026-07-22T09:00:00.123456789Z")
|
||||
invalidCalendar := create("ollama-nano-invalid", "2026-02-30T09:00:00.123456789Z")
|
||||
future := create("ollama-nano-future", "2026-07-22T15:00:00.123456789Z")
|
||||
|
||||
accounts, err := repo.ListDueOllamaCloudUsageAccounts(ctx, now, 10)
|
||||
|
||||
require.NoError(t, err, "invalid stored values must not abort the query")
|
||||
require.Equal(t, []int64{
|
||||
invalidCalendar.ID,
|
||||
nineDigitsZ.ID,
|
||||
eightDigitsOffset.ID,
|
||||
sevenDigitsOffset.ID,
|
||||
}, accountIDs(accounts))
|
||||
require.NotContains(t, accountIDs(accounts), future.ID)
|
||||
}
|
||||
|
||||
func TestLockAndMergeAccountProbeExtraCoalescesNullableOllamaGroupIdentity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
account := mustCreateAccount(t, tx.Client(), &service.Account{
|
||||
Name: "ordinary-openai-without-base-url", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "sk-no-base-url"},
|
||||
Extra: map[string]any{service.UpstreamBillingProbeEnabledExtraKey: true},
|
||||
})
|
||||
loaded, err := newAccountRepositoryWithSQL(tx.Client(), tx, nil).GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
merged, err := lockAndMergeAccountProbeExtra(ctx, tx.Client(), loaded, nil)
|
||||
|
||||
require.NoError(t, err, "a NULL Ollama eligibility expression must scan as false")
|
||||
require.NotContains(t, merged, service.OllamaCloudUsageSessionExtraKey)
|
||||
require.Equal(t, true, merged[service.UpstreamBillingProbeEnabledExtraKey])
|
||||
}
|
||||
|
||||
func TestOllamaCloudUsageGroupWritesAreAtomicAcrossPlatformsAndURLVariants(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
|
||||
create := func(name, platform, apiKey, baseURL string) *service.Account {
|
||||
t.Helper()
|
||||
return mustCreateAccount(t, tx.Client(), &service.Account{
|
||||
Name: name, Platform: platform, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": apiKey, "base_url": baseURL},
|
||||
Extra: map[string]any{},
|
||||
})
|
||||
}
|
||||
first := create("ollama-group-openai", service.PlatformOpenAI, "shared-key", "https://ollama.com")
|
||||
second := create("ollama-group-anthropic", service.PlatformAnthropic, "shared-key", "HTTPS://WWW.OLLAMA.COM:443/v1")
|
||||
different := create("ollama-group-different", service.PlatformOpenAI, "different-key", "https://ollama.com")
|
||||
|
||||
require.NoError(t, repo.SaveOllamaCloudUsageSession(ctx, first, "cipher:shared", false))
|
||||
for _, id := range []int64{first.ID, second.ID} {
|
||||
account, err := repo.GetByID(ctx, id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "cipher:shared", account.Extra[service.OllamaCloudUsageSessionExtraKey])
|
||||
require.Equal(t, false, account.Extra[service.OllamaCloudUsageAutoRefreshExtraKey])
|
||||
}
|
||||
differentLoaded, err := repo.GetByID(ctx, different.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, differentLoaded.Extra, service.OllamaCloudUsageSessionExtraKey)
|
||||
|
||||
secondLoaded, err := repo.GetByID(ctx, second.ID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, repo.SetOllamaCloudUsageAutoRefresh(ctx, secondLoaded, true))
|
||||
firstLoaded, err := repo.GetByID(ctx, first.ID)
|
||||
require.NoError(t, err)
|
||||
secondLoaded, err = repo.GetByID(ctx, second.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, firstLoaded.Extra[service.OllamaCloudUsageAutoRefreshExtraKey])
|
||||
require.Equal(t, true, secondLoaded.Extra[service.OllamaCloudUsageAutoRefreshExtraKey])
|
||||
|
||||
now := time.Now().UTC()
|
||||
snapshot := &service.OllamaCloudUsageSnapshot{
|
||||
Status: service.OllamaCloudUsageStatusOK, LastAttemptAt: now, NextRefreshAt: now.Add(time.Hour),
|
||||
}
|
||||
require.NoError(t, repo.UpdateOllamaCloudUsageSnapshot(ctx, firstLoaded, snapshot))
|
||||
secondLoaded, err = repo.GetByID(ctx, second.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, service.OllamaCloudUsageStatusOK,
|
||||
secondLoaded.Extra[service.OllamaCloudUsageSnapshotExtraKey].(map[string]any)["status"])
|
||||
|
||||
staleSecond := secondLoaded
|
||||
require.NoError(t, repo.UpdateCredentials(ctx, second.ID, map[string]any{
|
||||
"api_key": "rotated-key", "base_url": "https://ollama.com",
|
||||
}))
|
||||
require.ErrorIs(t, repo.DisableOllamaCloudUsageAutoRefresh(ctx, staleSecond), service.ErrOllamaCloudUsageIdentityChanged)
|
||||
firstLoaded, err = repo.GetByID(ctx, first.ID)
|
||||
require.NoError(t, err)
|
||||
secondLoaded, err = repo.GetByID(ctx, second.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "cipher:shared", firstLoaded.Extra[service.OllamaCloudUsageSessionExtraKey])
|
||||
require.Equal(t, true, firstLoaded.Extra[service.OllamaCloudUsageAutoRefreshExtraKey])
|
||||
require.NotContains(t, secondLoaded.Extra, service.OllamaCloudUsageSessionExtraKey)
|
||||
require.NotContains(t, secondLoaded.Extra, service.OllamaCloudUsageAutoRefreshExtraKey)
|
||||
|
||||
require.NoError(t, repo.DeleteOllamaCloudUsageSession(ctx, firstLoaded))
|
||||
firstLoaded, err = repo.GetByID(ctx, first.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, firstLoaded.Extra, service.OllamaCloudUsageSessionExtraKey)
|
||||
}
|
||||
|
||||
func TestConcurrentOllamaCloudUsageSaveAndDeleteSerializeGroupState(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
client := testEntClient(t)
|
||||
repo := newAccountRepositoryWithSQL(client, integrationDB, nil)
|
||||
suffix := time.Now().UnixNano()
|
||||
apiKey := fmt.Sprintf("ollama-concurrent-%d", suffix)
|
||||
create := func(platform string) *service.Account {
|
||||
t.Helper()
|
||||
return mustCreateAccount(t, client, &service.Account{
|
||||
Name: fmt.Sprintf("%s-%s", apiKey, platform), Platform: platform, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": apiKey, "base_url": "https://ollama.com"},
|
||||
Extra: map[string]any{
|
||||
service.OllamaCloudUsageSessionExtraKey: "cipher:initial",
|
||||
service.OllamaCloudUsageAutoRefreshExtraKey: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
first := create(service.PlatformOpenAI)
|
||||
second := create(service.PlatformAnthropic)
|
||||
t.Cleanup(func() {
|
||||
_, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM accounts WHERE id IN ($1, $2)", first.ID, second.ID)
|
||||
})
|
||||
anchor, err := repo.GetByID(ctx, first.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, 2)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
errs <- repo.SaveOllamaCloudUsageSession(ctx, anchor, "cipher:replacement", true)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
errs <- repo.DeleteOllamaCloudUsageSession(ctx, anchor)
|
||||
}()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for writeErr := range errs {
|
||||
require.NoError(t, writeErr)
|
||||
}
|
||||
|
||||
firstLoaded, err := repo.GetByID(ctx, first.ID)
|
||||
require.NoError(t, err)
|
||||
secondLoaded, err := repo.GetByID(ctx, second.ID)
|
||||
require.NoError(t, err)
|
||||
managedState := func(account *service.Account) map[string]any {
|
||||
state := make(map[string]any)
|
||||
for _, key := range []string{
|
||||
service.OllamaCloudUsageSessionExtraKey,
|
||||
service.OllamaCloudUsageAutoRefreshExtraKey,
|
||||
service.OllamaCloudUsageSnapshotExtraKey,
|
||||
} {
|
||||
if value, ok := account.Extra[key]; ok {
|
||||
state[key] = value
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
firstState := managedState(firstLoaded)
|
||||
require.Equal(t, firstState, managedState(secondLoaded), "a serialized last commit must own the whole group")
|
||||
if len(firstState) > 0 {
|
||||
require.Equal(t, "cipher:replacement", firstState[service.OllamaCloudUsageSessionExtraKey])
|
||||
require.Equal(t, true, firstState[service.OllamaCloudUsageAutoRefreshExtraKey])
|
||||
require.NotContains(t, firstState, service.OllamaCloudUsageSnapshotExtraKey)
|
||||
}
|
||||
}
|
||||
|
||||
func accountIDs(accounts []service.Account) []int64 {
|
||||
ids := make([]int64, len(accounts))
|
||||
for index := range accounts {
|
||||
ids[index] = accounts[index].ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestOllamaCloudUsageCredentialAndBulkUpdatesPreserveManagedStateOnlyWhenSafe(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
|
||||
now := time.Now().UTC()
|
||||
newAccount := func(name string) *service.Account {
|
||||
return mustCreateAccount(t, tx.Client(), &service.Account{
|
||||
Name: name, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "old-key", "base_url": "https://ollama.com"},
|
||||
Extra: 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),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
rawAccount := newAccount("ollama-raw-credentials")
|
||||
require.NoError(t, repo.UpdateCredentials(ctx, rawAccount.ID, map[string]any{
|
||||
"api_key": "old-key", "base_url": "https://ollama.com/V1",
|
||||
}))
|
||||
rawUpdated, err := repo.GetByID(ctx, rawAccount.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, rawUpdated.Extra, service.OllamaCloudUsageSessionExtraKey)
|
||||
require.NotContains(t, rawUpdated.Extra, service.OllamaCloudUsageAutoRefreshExtraKey)
|
||||
require.NotContains(t, rawUpdated.Extra, service.OllamaCloudUsageSnapshotExtraKey)
|
||||
|
||||
bulkAccount := newAccount("ollama-bulk-credentials")
|
||||
rows, err := repo.BulkUpdate(ctx, []int64{bulkAccount.ID}, service.AccountBulkUpdate{
|
||||
Credentials: map[string]any{"base_url": "HTTPS://WWW.OLLAMA.COM:443/v1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), rows)
|
||||
bulkUnchanged, err := repo.GetByID(ctx, bulkAccount.ID)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, bulkUnchanged.Extra, service.OllamaCloudUsageSnapshotExtraKey)
|
||||
|
||||
rows, err = repo.BulkUpdate(ctx, []int64{bulkAccount.ID}, service.AccountBulkUpdate{
|
||||
Credentials: map[string]any{"base_url": "https://ollama.com/V1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), rows)
|
||||
bulkIneligible, err := repo.GetByID(ctx, bulkAccount.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, bulkIneligible.Extra, service.OllamaCloudUsageSessionExtraKey)
|
||||
require.NotContains(t, bulkIneligible.Extra, service.OllamaCloudUsageAutoRefreshExtraKey)
|
||||
require.NotContains(t, bulkIneligible.Extra, service.OllamaCloudUsageSnapshotExtraKey)
|
||||
}
|
||||
|
||||
func TestProxyIdentityUpdateInvalidatesOllamaSnapshotAndRejectsInFlightCAS(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
accountRepo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
|
||||
proxyRepo := newProxyRepositoryWithSQL(tx.Client(), tx)
|
||||
proxy := mustCreateProxy(t, tx.Client(), &service.Proxy{
|
||||
Name: "ollama-identity-proxy", Protocol: "http", Host: "old.example", Port: 8080,
|
||||
Username: "old-user", Password: "old-pass", Status: service.StatusActive,
|
||||
})
|
||||
now := time.Now().UTC()
|
||||
account := mustCreateAccount(t, tx.Client(), &service.Account{
|
||||
Name: "ollama-proxy-account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://ollama.com"},
|
||||
ProxyID: &proxy.ID,
|
||||
Extra: 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),
|
||||
},
|
||||
},
|
||||
})
|
||||
inFlight, err := accountRepo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, inFlight.Proxy)
|
||||
require.Equal(t, "old.example", inFlight.Proxy.Host)
|
||||
|
||||
proxyToUpdate, err := proxyRepo.GetByID(ctx, proxy.ID)
|
||||
require.NoError(t, err)
|
||||
proxyToUpdate.Host = "new.example"
|
||||
require.NoError(t, proxyRepo.Update(ctx, proxyToUpdate))
|
||||
|
||||
got, err := accountRepo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, got.Extra, service.OllamaCloudUsageSnapshotExtraKey)
|
||||
require.Equal(t, "cipher:wos-session=fixture", got.Extra[service.OllamaCloudUsageSessionExtraKey])
|
||||
require.Equal(t, true, got.Extra[service.OllamaCloudUsageAutoRefreshExtraKey])
|
||||
|
||||
err = accountRepo.UpdateOllamaCloudUsageSnapshot(ctx, inFlight, &service.OllamaCloudUsageSnapshot{
|
||||
Status: service.OllamaCloudUsageStatusOK, LastAttemptAt: now, NextRefreshAt: now.Add(time.Hour),
|
||||
})
|
||||
require.ErrorIs(t, err, service.ErrOllamaCloudUsageIdentityChanged)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"entgo.io/ent/dialect"
|
||||
entsql "entgo.io/ent/dialect/sql"
|
||||
)
|
||||
|
||||
func newOllamaCloudUsageRepositoryTestClient(t *testing.T) (*dbent.Client, sqlmock.Sqlmock) {
|
||||
t.Helper()
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db)))
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
return client, mock
|
||||
}
|
||||
|
||||
func ollamaCloudUsageRepositoryAccount() *service.Account {
|
||||
return &service.Account{
|
||||
ID: 17, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://ollama.com"},
|
||||
Extra: map[string]any{
|
||||
service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=secret",
|
||||
service.OllamaCloudUsageAutoRefreshExtraKey: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateOllamaCloudUsageSnapshotRowsAffectedZeroIsIdentityConflict(t *testing.T) {
|
||||
client, mock := newOllamaCloudUsageRepositoryTestClient(t)
|
||||
mock.ExpectBegin()
|
||||
expectOllamaCloudUsageGroupLock(mock, ollamaCloudUsageRepositoryAccount(), true,
|
||||
`"cipher:wos-session=secret"`, `true`, `null`)
|
||||
mock.ExpectExec(`(?s)`+regexp.QuoteMeta("UPDATE accounts")).
|
||||
WithArgs(sqlmock.AnyArg(), "key", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectRollback()
|
||||
repo := newAccountRepositoryWithSQL(client, nil, nil)
|
||||
|
||||
err := repo.UpdateOllamaCloudUsageSnapshot(context.Background(), ollamaCloudUsageRepositoryAccount(), &service.OllamaCloudUsageSnapshot{
|
||||
Status: service.OllamaCloudUsageStatusOK,
|
||||
LastAttemptAt: time.Now(),
|
||||
NextRefreshAt: time.Now().Add(time.Hour),
|
||||
})
|
||||
|
||||
require.ErrorIs(t, err, service.ErrOllamaCloudUsageIdentityChanged)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func expectOllamaCloudUsageGroupLock(
|
||||
mock sqlmock.Sqlmock,
|
||||
account *service.Account,
|
||||
anchorMatches bool,
|
||||
sessionJSON, autoJSON, snapshotJSON string,
|
||||
) {
|
||||
apiKey, _ := account.Credentials["api_key"].(string)
|
||||
credentials, _ := json.Marshal(normalizeJSONMap(account.Credentials))
|
||||
var proxyID any
|
||||
if account.ProxyID != nil {
|
||||
proxyID = *account.ProxyID
|
||||
}
|
||||
mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")).
|
||||
WithArgs(apiKey, account.ID, account.Platform, account.Type, string(credentials), proxyID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "anchor_matches", "session", "auto_refresh", "snapshot"}).
|
||||
AddRow(account.ID, anchorMatches, sessionJSON, autoJSON, snapshotJSON))
|
||||
}
|
||||
|
||||
func TestOllamaCloudUsageManagedWriteRejectsChangedProxyIdentity(t *testing.T) {
|
||||
client, mock := newOllamaCloudUsageRepositoryTestClient(t)
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(`(?s)` + regexp.QuoteMeta("SELECT protocol, host, port") + `.*` + regexp.QuoteMeta("FOR SHARE")).
|
||||
WithArgs(int64(9)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"protocol", "host", "port", "username", "password", "status"}).
|
||||
AddRow("http", "new.example", 3128, "user", "pass", service.StatusActive))
|
||||
mock.ExpectRollback()
|
||||
|
||||
account := ollamaCloudUsageRepositoryAccount()
|
||||
proxyID := int64(9)
|
||||
account.ProxyID = &proxyID
|
||||
account.Proxy = &service.Proxy{
|
||||
ID: proxyID, Protocol: "http", Host: "old.example", Port: 3128,
|
||||
Username: "user", Password: "pass", Status: service.StatusActive,
|
||||
}
|
||||
repo := newAccountRepositoryWithSQL(client, nil, nil)
|
||||
|
||||
err := repo.SaveOllamaCloudUsageSession(context.Background(), account, "cipher:wos-session=replacement", true)
|
||||
|
||||
require.ErrorIs(t, err, service.ErrOllamaCloudUsageIdentityChanged)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestSaveAndDeleteOllamaCloudUsageSessionKeepCiphertextOutOfSQL(t *testing.T) {
|
||||
var capturedSQL []string
|
||||
matcher := sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
|
||||
capturedSQL = append(capturedSQL, actualSQL)
|
||||
return sqlmock.QueryMatcherRegexp.Match(expectedSQL, actualSQL)
|
||||
})
|
||||
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(matcher))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db)))
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
repo := newAccountRepositoryWithSQL(client, db, nil)
|
||||
account := ollamaCloudUsageRepositoryAccount()
|
||||
const replacement = "cipher:wos-session=browser-cookie-secret"
|
||||
|
||||
mock.ExpectBegin()
|
||||
expectOllamaCloudUsageGroupLock(mock, account, true, `"cipher:wos-session=secret"`, `true`, `null`)
|
||||
mock.ExpectExec(`(?s)UPDATE accounts.*ollama_cloud_usage_session.*ollama_cloud_usage_auto_refresh.*ollama_cloud_usage_snapshot`).
|
||||
WithArgs(`{"ollama_cloud_usage_auto_refresh":true,"ollama_cloud_usage_session":"cipher:wos-session=browser-cookie-secret"}`, "key", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
require.NoError(t, repo.SaveOllamaCloudUsageSession(context.Background(), account, replacement, true))
|
||||
|
||||
account.Extra[service.OllamaCloudUsageSessionExtraKey] = replacement
|
||||
mock.ExpectBegin()
|
||||
expectOllamaCloudUsageGroupLock(mock, account, true, `"cipher:wos-session=browser-cookie-secret"`, `true`, `null`)
|
||||
mock.ExpectExec(`(?s)UPDATE accounts.*ollama_cloud_usage_session.*ollama_cloud_usage_auto_refresh.*ollama_cloud_usage_snapshot`).
|
||||
WithArgs(`{}`, "key", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
require.NoError(t, repo.DeleteOllamaCloudUsageSession(context.Background(), account))
|
||||
|
||||
require.NotEmpty(t, capturedSQL)
|
||||
for _, query := range capturedSQL {
|
||||
require.NotContains(t, query, "browser-cookie-secret")
|
||||
}
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestOllamaCloudBaseURLSQLRegexMatchesServiceSemantics(t *testing.T) {
|
||||
for _, baseURL := range []string{
|
||||
"https://ollama.com",
|
||||
"HTTPS://WWW.OLLAMA.COM:443/v1",
|
||||
"https://ollama.com/V1",
|
||||
"https://ollama.com/v1/",
|
||||
"https://ollama.com.evil.test/v1",
|
||||
} {
|
||||
t.Run(baseURL, func(t *testing.T) {
|
||||
matched, err := regexp.MatchString(ollamaCloudBaseURLRegexSQL, baseURL)
|
||||
require.NoError(t, err)
|
||||
account := ollamaCloudUsageRepositoryAccount()
|
||||
account.Credentials["base_url"] = baseURL
|
||||
require.Equal(t, service.IsOllamaCloudUsageAccount(account), matched)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOllamaCloudUsageGroupAccountsUsesOneStrictBatchQuery(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
var capturedSQL string
|
||||
mock.ExpectQuery("SELECT id").
|
||||
WithArgs(sqlmock.AnyArg()).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||
repo := newAccountRepositoryWithSQL(nil, captureQuerySQL{db: db, captured: &capturedSQL}, nil)
|
||||
first := ollamaCloudUsageRepositoryAccount()
|
||||
second := ollamaCloudUsageRepositoryAccount()
|
||||
second.ID = 18
|
||||
second.Platform = service.PlatformAnthropic
|
||||
second.Credentials = map[string]any{"api_key": "key", "base_url": "https://www.ollama.com:443/v1"}
|
||||
|
||||
accounts, err := repo.ListOllamaCloudUsageGroupAccounts(context.Background(), []*service.Account{first, second})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, accounts)
|
||||
query := normalizeSQLWhitespace(capturedSQL)
|
||||
require.Contains(t, query, "credentials ->> 'api_key' = ANY($1)")
|
||||
require.Contains(t, query, "platform IN ('openai', 'anthropic')")
|
||||
require.Contains(t, query, "jsonb_typeof(credentials -> 'api_key') = 'string'")
|
||||
require.Contains(t, query, ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'"))
|
||||
require.NotContains(t, query, "~*")
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestListDueOllamaCloudUsageAccountsFiltersOrdersAndLimits(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
now := time.Date(2026, time.July, 22, 12, 0, 0, 0, time.UTC)
|
||||
var capturedSQL string
|
||||
mock.ExpectQuery("WITH candidates AS").
|
||||
WithArgs(now, 20).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||
repo := newAccountRepositoryWithSQL(nil, captureQuerySQL{db: db, captured: &capturedSQL}, nil)
|
||||
|
||||
accounts, err := repo.ListDueOllamaCloudUsageAccounts(context.Background(), now, 20)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, accounts)
|
||||
normalized := normalizeSQLWhitespace(capturedSQL)
|
||||
for _, clause := range []string{
|
||||
"deleted_at IS NULL",
|
||||
"status = 'active'",
|
||||
"platform IN ('openai', 'anthropic')",
|
||||
"type = 'apikey'",
|
||||
ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'"),
|
||||
"jsonb_typeof(extra -> 'ollama_cloud_usage_session') = 'string'",
|
||||
`extra @> '{"ollama_cloud_usage_auto_refresh": true}'::jsonb`,
|
||||
"parsed_next_refresh_at::timestamptz <= $1",
|
||||
"PARTITION BY api_key",
|
||||
"WHERE group_rank = 1",
|
||||
"LIMIT $2",
|
||||
} {
|
||||
require.Contains(t, normalized, clause)
|
||||
}
|
||||
require.NotContains(t, normalized, "~*")
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestBulkUpdateOllamaIdentityCleanupIsValueConditional(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": "https://www.ollama.com:443/v1"},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, exec.execQueries)
|
||||
query := normalizeSQLWhitespace(exec.execQueries[0])
|
||||
require.Contains(t, query, "NOT ("+ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'"))
|
||||
require.Contains(t, query, ollamaCloudBaseURLMatchesSQL("$1::jsonb ->> 'base_url'"))
|
||||
require.NotContains(t, query, "~*")
|
||||
require.Contains(t, query, "platform IN ('openai', 'anthropic') AND type = 'apikey'")
|
||||
require.Contains(t, query, "- 'ollama_cloud_usage_session' - 'ollama_cloud_usage_auto_refresh' - 'ollama_cloud_usage_snapshot'")
|
||||
payload, ok := exec.execArgs[0][0].([]byte)
|
||||
require.True(t, ok)
|
||||
require.NotContains(t, string(payload), service.OllamaCloudUsageSnapshotExtraKey)
|
||||
}
|
||||
|
||||
func TestUpdateCredentialsIdentityChangeClearsAllOllamaManagedExtra(t *testing.T) {
|
||||
client, mock := newOllamaCloudUsageRepositoryTestClient(t)
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(`(?s)UPDATE accounts.*credentials -> 'api_key' IS DISTINCT FROM.*ollama_cloud_usage_session.*ollama_cloud_usage_auto_refresh.*ollama_cloud_usage_snapshot`).
|
||||
WithArgs(`{"api_key":"new-key","base_url":"https://ollama.com"}`, 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://ollama.com",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestDisableOllamaCloudUsageAutoRefreshUsesGroupIdentityCAS(t *testing.T) {
|
||||
client, mock := newOllamaCloudUsageRepositoryTestClient(t)
|
||||
account := ollamaCloudUsageRepositoryAccount()
|
||||
mock.ExpectBegin()
|
||||
expectOllamaCloudUsageGroupLock(mock, account, true, `"cipher:wos-session=secret"`, `true`, `null`)
|
||||
mock.ExpectExec(`(?s)UPDATE accounts.*ollama_cloud_usage_auto_refresh`).
|
||||
WithArgs(`{"ollama_cloud_usage_auto_refresh":false,"ollama_cloud_usage_session":"cipher:wos-session=secret"}`, "key", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
repo := newAccountRepositoryWithSQL(client, nil, nil)
|
||||
|
||||
err := repo.DisableOllamaCloudUsageAutoRefresh(context.Background(), account)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
@@ -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", "enabled", "snapshot"}).
|
||||
AddRow(tt.identityUnchanged, tt.databaseEnabled, tt.databaseSnapshot))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}).
|
||||
AddRow(tt.identityUnchanged, false, true, tt.databaseEnabled, tt.databaseSnapshot, nil, nil, nil))
|
||||
|
||||
account := &service.Account{
|
||||
ID: 27,
|
||||
@@ -104,6 +104,45 @@ func TestLockAndMergeAccountProbeExtraUsesCurrentDatabaseSnapshot(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockAndMergeAccountProbeExtraProtectsOllamaManagedFields(t *testing.T) {
|
||||
for _, identityUnchanged := range []bool{true, false} {
|
||||
t.Run(map[bool]string{true: "same identity keeps snapshot", false: "changed identity clears snapshot"}[identityUnchanged], func(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db)))
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
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", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}).
|
||||
AddRow(identityUnchanged, identityUnchanged, true, nil, nil, []byte(`"local-ciphertext"`), []byte(`true`), []byte(`{"status":"ok"}`)))
|
||||
|
||||
account := &service.Account{
|
||||
ID: 29, Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://ollama.com"},
|
||||
Extra: map[string]any{
|
||||
service.OllamaCloudUsageSessionExtraKey: "forged-ciphertext",
|
||||
service.OllamaCloudUsageAutoRefreshExtraKey: false,
|
||||
service.OllamaCloudUsageSnapshotExtraKey: map[string]any{"status": "forged"},
|
||||
},
|
||||
}
|
||||
got, err := lockAndMergeAccountProbeExtra(context.Background(), client, account, nil)
|
||||
require.NoError(t, err)
|
||||
if identityUnchanged {
|
||||
require.Equal(t, "local-ciphertext", got[service.OllamaCloudUsageSessionExtraKey])
|
||||
require.Equal(t, true, got[service.OllamaCloudUsageAutoRefreshExtraKey])
|
||||
require.Equal(t, map[string]any{"status": "ok"}, got[service.OllamaCloudUsageSnapshotExtraKey])
|
||||
} else {
|
||||
require.NotContains(t, got, service.OllamaCloudUsageSessionExtraKey)
|
||||
require.NotContains(t, got, service.OllamaCloudUsageAutoRefreshExtraKey)
|
||||
require.NotContains(t, got, service.OllamaCloudUsageSnapshotExtraKey)
|
||||
}
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateExtraExplicitProbeDisableRemovesSnapshot(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
@@ -236,8 +275,8 @@ func TestUpdateWithUpstreamBillingProbeEnabledRollsBackWhenOutboxFails(t *testin
|
||||
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", "enabled", "snapshot"}).
|
||||
AddRow(true, []byte(`true`), []byte(`{"status":"ok"}`)))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}).
|
||||
AddRow(true, false, true, []byte(`true`), []byte(`{"status":"ok"}`), nil, nil, nil))
|
||||
mock.ExpectExec(`(?s)UPDATE .*accounts.*SET.*WHERE .*id.*`).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery(`(?s)SELECT .* FROM "accounts" WHERE "id" = \$1`).
|
||||
|
||||
@@ -224,12 +224,20 @@ func lockProxyProbeIdentity(ctx context.Context, client *dbent.Client, proxyID i
|
||||
func invalidateProxyProbeSnapshots(ctx context.Context, exec sqlExecutor, proxyID int64) ([]int64, error) {
|
||||
rows, err := exec.QueryContext(ctx, `
|
||||
UPDATE accounts
|
||||
SET extra = COALESCE(extra, '{}'::jsonb) - 'upstream_billing_probe', updated_at = NOW()
|
||||
SET extra = COALESCE(extra, '{}'::jsonb)
|
||||
- 'upstream_billing_probe'
|
||||
- 'ollama_cloud_usage_snapshot',
|
||||
updated_at = NOW()
|
||||
WHERE proxy_id = $1
|
||||
AND platform = 'openai'
|
||||
AND type = 'apikey'
|
||||
AND extra ? 'upstream_billing_probe'
|
||||
AND extra -> 'upstream_billing_probe' <> 'null'::jsonb
|
||||
AND (
|
||||
(platform = 'openai'
|
||||
AND extra ? 'upstream_billing_probe'
|
||||
AND extra -> 'upstream_billing_probe' <> 'null'::jsonb)
|
||||
OR (platform IN ('openai', 'anthropic')
|
||||
AND extra ? 'ollama_cloud_usage_snapshot'
|
||||
AND extra -> 'ollama_cloud_usage_snapshot' <> 'null'::jsonb)
|
||||
)
|
||||
AND deleted_at IS NULL
|
||||
RETURNING id
|
||||
`, proxyID)
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestProxyUpdateInvalidatesBoundProbeSnapshotsAndEnqueuesOutboxAtomically(t
|
||||
WithArgs(int64(9)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
expectProxyUpdateReload(mock, 9, "new.example", "user", "pass")
|
||||
mock.ExpectQuery(`(?s)UPDATE accounts.*platform = 'openai'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*extra -> 'upstream_billing_probe' <> 'null'::jsonb.*RETURNING id`).
|
||||
mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*platform = 'openai'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`).
|
||||
WithArgs(int64(9)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(17)).AddRow(int64(18)))
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload)")).
|
||||
@@ -76,7 +76,7 @@ func TestProxyUpdateRollsBackWhenProbeInvalidationOutboxFails(t *testing.T) {
|
||||
WithArgs(int64(9)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
expectProxyUpdateReload(mock, 9, "new.example", "", "")
|
||||
mock.ExpectQuery(`(?s)UPDATE accounts.*platform = 'openai'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*extra -> 'upstream_billing_probe' <> 'null'::jsonb.*RETURNING id`).
|
||||
mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*platform = 'openai'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`).
|
||||
WithArgs(int64(9)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(17)))
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload)")).
|
||||
|
||||
@@ -346,6 +346,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu
|
||||
accounts.GET("/upstream-billing-probe/settings", h.Admin.Account.GetUpstreamBillingProbeSettings)
|
||||
accounts.PUT("/upstream-billing-probe/settings", h.Admin.Account.UpdateUpstreamBillingProbeSettings)
|
||||
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("/:id", h.Admin.Account.GetByID)
|
||||
accounts.POST("", h.Admin.Account.Create)
|
||||
accounts.POST("/:id/duplicate", h.Admin.Account.Duplicate)
|
||||
@@ -356,6 +358,11 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu
|
||||
accounts.PUT("/:id", h.Admin.Account.Update)
|
||||
accounts.PUT("/:id/upstream-billing-probe", h.Admin.Account.SetUpstreamBillingProbeEnabled)
|
||||
accounts.POST("/:id/upstream-billing-probe", h.Admin.Account.ProbeUpstreamBilling)
|
||||
accounts.GET("/:id/ollama-cloud-usage", h.Admin.Account.GetOllamaCloudUsage)
|
||||
accounts.PUT("/:id/ollama-cloud-usage/session", h.Admin.Account.SaveOllamaCloudUsageSession)
|
||||
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.DELETE("/:id", h.Admin.Account.Delete)
|
||||
accounts.POST("/:id/test", h.Admin.Account.Test)
|
||||
accounts.POST("/:id/recover-state", h.Admin.Account.RecoverState)
|
||||
|
||||
@@ -315,7 +315,14 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount
|
||||
}
|
||||
|
||||
if req.Extra != nil {
|
||||
account.Extra = *req.Extra
|
||||
extra := make(map[string]any, len(*req.Extra))
|
||||
for key, value := range *req.Extra {
|
||||
extra[key] = value
|
||||
}
|
||||
delete(extra, OllamaCloudUsageSessionExtraKey)
|
||||
delete(extra, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
delete(extra, OllamaCloudUsageSnapshotExtraKey)
|
||||
account.Extra = extra
|
||||
}
|
||||
|
||||
if req.ProxyID != nil {
|
||||
|
||||
@@ -453,9 +453,12 @@ func normalizeGrokMediaEligibilityUpdateExtra(account *Account, input *UpdateAcc
|
||||
}
|
||||
|
||||
func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]any) (*Account, error) {
|
||||
// Probe state is system-managed. New accounts always start with auto probe disabled.
|
||||
// Probe/session state is system-managed. New accounts always start with automatic refresh disabled.
|
||||
delete(accountExtra, UpstreamBillingProbeEnabledExtraKey)
|
||||
delete(accountExtra, UpstreamBillingProbeExtraKey)
|
||||
delete(accountExtra, OllamaCloudUsageSessionExtraKey)
|
||||
delete(accountExtra, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
delete(accountExtra, OllamaCloudUsageSnapshotExtraKey)
|
||||
account := &Account{
|
||||
Name: input.Name,
|
||||
Notes: normalizeAccountNotes(input.Notes),
|
||||
@@ -612,6 +615,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
}
|
||||
}
|
||||
previousProbeIdentity := upstreamBillingProbeIdentity(account)
|
||||
previousOllamaUsageIdentity := ollamaCloudUsageIdentity(account)
|
||||
// 安全/身份不变量(影子账号):通用更新路径被 edit/re-auth/refresh/batch 共用,
|
||||
// 必须在此守住,否则仅在创建时的保证可被这些路径绕过。
|
||||
if account.IsCredentialShadow() {
|
||||
@@ -675,7 +679,10 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
}
|
||||
delete(normalizedExtra, UpstreamBillingProbeEnabledExtraKey)
|
||||
delete(normalizedExtra, UpstreamBillingProbeExtraKey)
|
||||
// 保留配额用量字段,防止编辑账号时意外重置
|
||||
delete(normalizedExtra, OllamaCloudUsageSessionExtraKey)
|
||||
delete(normalizedExtra, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
delete(normalizedExtra, OllamaCloudUsageSnapshotExtraKey)
|
||||
// 保留配额用量和专用服务受管字段,防止普通账号编辑意外覆盖。
|
||||
for _, key := range []string{
|
||||
"quota_used",
|
||||
"quota_daily_used",
|
||||
@@ -685,6 +692,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
grokBillingExtraKey,
|
||||
UpstreamBillingProbeEnabledExtraKey,
|
||||
UpstreamBillingProbeExtraKey,
|
||||
OllamaCloudUsageSessionExtraKey,
|
||||
OllamaCloudUsageAutoRefreshExtraKey,
|
||||
OllamaCloudUsageSnapshotExtraKey,
|
||||
} {
|
||||
if v, ok := account.Extra[key]; ok {
|
||||
normalizedExtra[key] = v
|
||||
@@ -733,6 +743,17 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
delete(account.Extra, UpstreamBillingProbeEnabledExtraKey)
|
||||
}
|
||||
}
|
||||
if account.Extra != nil {
|
||||
if !IsOllamaCloudUsageAccount(account) {
|
||||
delete(account.Extra, OllamaCloudUsageSessionExtraKey)
|
||||
delete(account.Extra, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
delete(account.Extra, OllamaCloudUsageSnapshotExtraKey)
|
||||
} else if !reflect.DeepEqual(previousOllamaUsageIdentity, ollamaCloudUsageIdentity(account)) {
|
||||
delete(account.Extra, OllamaCloudUsageSessionExtraKey)
|
||||
delete(account.Extra, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
delete(account.Extra, OllamaCloudUsageSnapshotExtraKey)
|
||||
}
|
||||
}
|
||||
// 只在指针非 nil 时更新 Concurrency(支持设置为 0)
|
||||
if input.Concurrency != nil {
|
||||
account.Concurrency = normalizeAccountConcurrency(account.Platform, account.Type, *input.Concurrency)
|
||||
@@ -833,6 +854,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
// UpdateAccountExtra 仅对 Extra JSONB 做 key 级合并,避免覆盖其它运行态键
|
||||
// (如 model_rate_limits / passive_usage_* 等)。
|
||||
func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error {
|
||||
delete(updates, OllamaCloudUsageSessionExtraKey)
|
||||
delete(updates, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
delete(updates, OllamaCloudUsageSnapshotExtraKey)
|
||||
if _, exists := updates[openAILongContextBillingEnabledKey]; exists {
|
||||
account, err := s.accountRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
@@ -851,9 +875,12 @@ func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, upd
|
||||
// BulkUpdateAccounts updates multiple accounts in one request.
|
||||
// It merges credentials/extra keys instead of overwriting the whole object.
|
||||
func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) {
|
||||
// Managed probe state may only enter through the dedicated typed field below.
|
||||
// Managed probe/session state may only enter through dedicated typed endpoints.
|
||||
delete(input.Extra, UpstreamBillingProbeEnabledExtraKey)
|
||||
delete(input.Extra, UpstreamBillingProbeExtraKey)
|
||||
delete(input.Extra, OllamaCloudUsageSessionExtraKey)
|
||||
delete(input.Extra, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
delete(input.Extra, OllamaCloudUsageSnapshotExtraKey)
|
||||
|
||||
if len(input.AccountIDs) == 0 && input.Filters != nil {
|
||||
accountIDs, err := s.resolveBulkUpdateTargetIDs(ctx, input.Filters)
|
||||
|
||||
@@ -1160,21 +1160,39 @@ func reconcileCRSUpstreamBillingProbeExtra(
|
||||
targetCredentials map[string]any,
|
||||
extra map[string]any,
|
||||
) {
|
||||
delete(extra, UpstreamBillingProbeEnabledExtraKey)
|
||||
delete(extra, UpstreamBillingProbeExtraKey)
|
||||
for _, key := range []string{
|
||||
UpstreamBillingProbeEnabledExtraKey,
|
||||
UpstreamBillingProbeExtraKey,
|
||||
OllamaCloudUsageSessionExtraKey,
|
||||
OllamaCloudUsageAutoRefreshExtraKey,
|
||||
OllamaCloudUsageSnapshotExtraKey,
|
||||
} {
|
||||
delete(extra, key)
|
||||
}
|
||||
if existing == nil {
|
||||
return
|
||||
}
|
||||
if targetPlatform != PlatformOpenAI || targetType != AccountTypeAPIKey {
|
||||
return
|
||||
}
|
||||
if enabled, ok := existing.Extra[UpstreamBillingProbeEnabledExtraKey]; ok {
|
||||
extra[UpstreamBillingProbeEnabledExtraKey] = enabled
|
||||
}
|
||||
target := &Account{Platform: targetPlatform, Type: targetType, Credentials: targetCredentials}
|
||||
if reflect.DeepEqual(upstreamBillingProbeIdentity(existing), upstreamBillingProbeIdentity(target)) {
|
||||
if snapshot, ok := existing.Extra[UpstreamBillingProbeExtraKey]; ok {
|
||||
extra[UpstreamBillingProbeExtraKey] = snapshot
|
||||
if targetPlatform == PlatformOpenAI && targetType == AccountTypeAPIKey {
|
||||
if enabled, ok := existing.Extra[UpstreamBillingProbeEnabledExtraKey]; ok {
|
||||
extra[UpstreamBillingProbeEnabledExtraKey] = enabled
|
||||
}
|
||||
if reflect.DeepEqual(upstreamBillingProbeIdentity(existing), upstreamBillingProbeIdentity(target)) {
|
||||
if snapshot, ok := existing.Extra[UpstreamBillingProbeExtraKey]; ok {
|
||||
extra[UpstreamBillingProbeExtraKey] = snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
if IsOllamaCloudUsageAccount(existing) && IsOllamaCloudUsageAccount(target) &&
|
||||
reflect.DeepEqual(ollamaCloudUsageIdentity(existing), ollamaCloudUsageIdentity(target)) {
|
||||
if session, ok := existing.Extra[OllamaCloudUsageSessionExtraKey]; ok {
|
||||
extra[OllamaCloudUsageSessionExtraKey] = session
|
||||
}
|
||||
if enabled, ok := existing.Extra[OllamaCloudUsageAutoRefreshExtraKey]; ok {
|
||||
extra[OllamaCloudUsageAutoRefreshExtraKey] = enabled
|
||||
}
|
||||
if snapshot, ok := existing.Extra[OllamaCloudUsageSnapshotExtraKey]; ok {
|
||||
extra[OllamaCloudUsageSnapshotExtraKey] = snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,6 +385,9 @@ const (
|
||||
// for probing remote Sub2API API-key billing metadata.
|
||||
SettingKeyUpstreamBillingProbeSettings = "upstream_billing_probe_settings"
|
||||
|
||||
// SettingKeyOllamaCloudUsageSettings stores the opt-in global runner switch and interval.
|
||||
SettingKeyOllamaCloudUsageSettings = "ollama_cloud_usage_settings"
|
||||
|
||||
// =========================
|
||||
// Overload Cooldown (529)
|
||||
// =========================
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,456 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
var (
|
||||
ollamaUsagePercentPattern = regexp.MustCompile(`(?i)([0-9]+(?:\.[0-9]+)?)\s*%`)
|
||||
ollamaUsageWidthPattern = regexp.MustCompile(`(?i)(?:^|;)\s*width\s*:\s*([0-9]+(?:\.[0-9]+)?)%`)
|
||||
ollamaBalancePattern = regexp.MustCompile(`(?i)(?:balance|credits?)(?:\s+[[:alpha:]]+){0,4}\s*[:\n]?\s*((?:USD\s*)?\$?\s*-?[0-9][0-9,]*(?:\.[0-9]{1,4})?)`)
|
||||
ollamaResetPattern = regexp.MustCompile(`(?i)\breset(?:s|ting)?\s*(?:at|in|on)?\s*[:\-]?\s*([^\n|]+)`)
|
||||
ollamaModelFallbackPattern = regexp.MustCompile(`(?i)^(.+?)\s+([0-9][0-9,]*)\s+requests?$`)
|
||||
|
||||
ollamaFiveHourUsageAliases = []string{"session usage", "5 hour usage", "5-hour usage", "5h usage", "5 hour limit", "5-hour limit"}
|
||||
ollamaSevenDayUsageAliases = []string{"weekly usage", "7 day usage", "7-day usage", "7d usage", "weekly limit", "7 day limit"}
|
||||
)
|
||||
|
||||
func parseOllamaCloudUsageHTML(body []byte) (*OllamaCloudUsageData, error) {
|
||||
doc, err := html.Parse(strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse settings HTML: %w", err)
|
||||
}
|
||||
pageText := normalizedNodeText(doc)
|
||||
lowerPage := strings.ToLower(pageText)
|
||||
if containsAny(lowerPage, "sign in to ollama", "log in to ollama", "continue to sign in") {
|
||||
return nil, errOllamaCloudUsageUnauthorizedHTML
|
||||
}
|
||||
|
||||
data := &OllamaCloudUsageData{}
|
||||
data.Plan = valueBesideLabel(doc, []string{"cloud usage"}, 80)
|
||||
if data.Plan == "" {
|
||||
data.Plan = valueBesideLabel(doc, []string{"plan", "subscription"}, 80)
|
||||
}
|
||||
data.FiveHour = parseOllamaUsageWindow(doc, ollamaFiveHourUsageAliases)
|
||||
data.SevenDay = parseOllamaUsageWindow(doc, ollamaSevenDayUsageAliases)
|
||||
data.Balance = valueBesideLabel(doc, []string{"balance remaining"}, 80)
|
||||
if data.Balance == "" {
|
||||
if match := ollamaBalancePattern.FindStringSubmatch(pageText); len(match) == 2 {
|
||||
data.Balance = strings.Join(strings.Fields(match[1]), "")
|
||||
}
|
||||
}
|
||||
data.Models = parseOllamaModels(doc)
|
||||
|
||||
if data.Plan == "" && data.FiveHour == nil && data.SevenDay == nil && data.Balance == "" && len(data.Models) == 0 {
|
||||
return nil, fmt.Errorf("settings HTML does not contain recognizable usage fields")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func parseOllamaUsageWindow(root *html.Node, aliases []string) *OllamaCloudUsageWindow {
|
||||
label := findLabelElement(root, aliases)
|
||||
if label == nil {
|
||||
return nil
|
||||
}
|
||||
var candidate *OllamaCloudUsageWindow
|
||||
block := label
|
||||
for depth := 0; block != nil && depth < 6; depth, block = depth+1, block.Parent {
|
||||
text := normalizedNodeText(block)
|
||||
if len(text) > 600 {
|
||||
break
|
||||
}
|
||||
percent, ok := ollamaUsagePercentFromText(text)
|
||||
if !ok {
|
||||
percent, ok = ollamaUsagePercentFromTrack(block)
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(strings.ToLower(text), "remaining") && !strings.Contains(strings.ToLower(text), "used") {
|
||||
percent = 100 - percent
|
||||
}
|
||||
window := &OllamaCloudUsageWindow{UsedPercent: percent}
|
||||
window.ResetAt = timeElementValue(block)
|
||||
if reset := ollamaResetPattern.FindStringSubmatch(text); len(reset) == 2 {
|
||||
window.ResetText = strings.TrimSpace(reset[1])
|
||||
if window.ResetAt == nil {
|
||||
window.ResetAt = parseOllamaResetTime(window.ResetText)
|
||||
}
|
||||
}
|
||||
if candidate == nil {
|
||||
candidate = window
|
||||
}
|
||||
if window.ResetAt != nil || window.ResetText != "" {
|
||||
return window
|
||||
}
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
func valueBesideLabel(root *html.Node, aliases []string, maxLen int) string {
|
||||
label := findLabelElement(root, aliases)
|
||||
if label == nil {
|
||||
return ""
|
||||
}
|
||||
if sibling := nextElementSibling(label); sibling != nil {
|
||||
value := strings.Trim(normalizedNodeText(sibling), ":-| ")
|
||||
if value != "" && len(value) <= maxLen && !strings.EqualFold(value, "manage") {
|
||||
return value
|
||||
}
|
||||
}
|
||||
for depth, block := 0, label.Parent; block != nil && depth < 4; depth, block = depth+1, block.Parent {
|
||||
text := normalizedNodeText(block)
|
||||
if text == "" || len(text) > maxLen {
|
||||
continue
|
||||
}
|
||||
value := text
|
||||
for _, alias := range aliases {
|
||||
if index := strings.Index(strings.ToLower(value), alias); index >= 0 {
|
||||
value = strings.TrimSpace(value[:index] + " " + value[index+len(alias):])
|
||||
break
|
||||
}
|
||||
}
|
||||
value = strings.Trim(value, ":-| ")
|
||||
if value != "" && !strings.EqualFold(value, "manage") {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findLabelElement(root *html.Node, aliases []string) *html.Node {
|
||||
var best *html.Node
|
||||
bestLen := int(^uint(0) >> 1)
|
||||
walkHTML(root, func(node *html.Node) {
|
||||
if node.Type != html.ElementNode || !isOllamaParserContainer(node.Data) {
|
||||
return
|
||||
}
|
||||
text := strings.ToLower(normalizedNodeText(node))
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
for _, alias := range aliases {
|
||||
if (text == alias || strings.HasPrefix(text, alias+" ") || strings.HasPrefix(text, alias+":")) && len(text) < bestLen {
|
||||
best, bestLen = node, len(text)
|
||||
}
|
||||
}
|
||||
})
|
||||
return best
|
||||
}
|
||||
|
||||
func parseOllamaModels(root *html.Node) []OllamaCloudUsageModel {
|
||||
models := make([]OllamaCloudUsageModel, 0)
|
||||
seen := make(map[string]struct{})
|
||||
appendModel := func(node *html.Node, modelValue, requestsValue string) {
|
||||
model := strings.TrimSpace(modelValue)
|
||||
requests, ok := parseOllamaRequestCount(requestsValue)
|
||||
window := ollamaModelWindow(node)
|
||||
if !ok || model == "" || len(model) > 128 || window == "" {
|
||||
return
|
||||
}
|
||||
key := model + "\x00" + string(window)
|
||||
if _, duplicate := seen[key]; duplicate {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
models = append(models, OllamaCloudUsageModel{Model: model, Window: window, Requests: requests})
|
||||
}
|
||||
|
||||
walkHTML(root, func(node *html.Node) {
|
||||
if node.Type != html.ElementNode {
|
||||
return
|
||||
}
|
||||
if _, segment := htmlAttribute(node, "data-usage-segment"); !segment {
|
||||
return
|
||||
}
|
||||
model, modelOK := htmlAttributeInSubtree(node, "data-model")
|
||||
requests, requestsOK := htmlAttributeInSubtree(node, "data-requests")
|
||||
if modelOK && requestsOK {
|
||||
appendModel(node, model, requests)
|
||||
}
|
||||
})
|
||||
|
||||
// Older settings variants may expose the same narrow attributes without the
|
||||
// segment marker. Do not infer request counts from percentages or limits.
|
||||
if len(models) == 0 {
|
||||
walkHTML(root, func(node *html.Node) {
|
||||
if node.Type != html.ElementNode {
|
||||
return
|
||||
}
|
||||
model, modelOK := htmlAttribute(node, "data-model")
|
||||
requests, requestsOK := htmlAttribute(node, "data-requests")
|
||||
if modelOK && requestsOK {
|
||||
appendModel(node, model, requests)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if len(models) == 0 {
|
||||
heading := findLabelElement(root, []string{"models", "available models"})
|
||||
for depth, block := 0, parentNode(heading); block != nil && depth < 4; depth, block = depth+1, block.Parent {
|
||||
walkHTML(block, func(node *html.Node) {
|
||||
if node.Type != html.ElementNode || (node.Data != "li" && node.Data != "code") {
|
||||
return
|
||||
}
|
||||
match := ollamaModelFallbackPattern.FindStringSubmatch(strings.TrimSpace(normalizedNodeText(node)))
|
||||
if len(match) == 3 {
|
||||
appendModel(node, match[1], match[2])
|
||||
}
|
||||
})
|
||||
if len(models) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(models, func(i, j int) bool {
|
||||
if models[i].Window != models[j].Window {
|
||||
return models[i].Window < models[j].Window
|
||||
}
|
||||
return models[i].Model < models[j].Model
|
||||
})
|
||||
if len(models) > 100 {
|
||||
models = models[:100]
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
func ollamaModelWindow(node *html.Node) OllamaCloudUsageModelWindow {
|
||||
for block := parentNode(node); block != nil; block = block.Parent {
|
||||
if value, ok := htmlAttribute(block, "data-usage-window"); ok {
|
||||
normalized := strings.ToLower(strings.NewReplacer("-", " ", "_", " ").Replace(value))
|
||||
if containsAny(normalized, "five hour", "5 hour", "5h", "session") {
|
||||
return OllamaCloudUsageModelWindowFiveHour
|
||||
}
|
||||
if containsAny(normalized, "seven day", "7 day", "7d", "weekly") {
|
||||
return OllamaCloudUsageModelWindowSevenDay
|
||||
}
|
||||
}
|
||||
text := strings.ToLower(normalizedNodeText(block))
|
||||
fiveHour := containsAny(text, ollamaFiveHourUsageAliases...)
|
||||
sevenDay := containsAny(text, ollamaSevenDayUsageAliases...)
|
||||
if fiveHour && !sevenDay {
|
||||
return OllamaCloudUsageModelWindowFiveHour
|
||||
}
|
||||
if sevenDay && !fiveHour {
|
||||
return OllamaCloudUsageModelWindowSevenDay
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseOllamaRequestCount(value string) (int64, bool) {
|
||||
value = strings.ReplaceAll(strings.TrimSpace(value), ",", "")
|
||||
requests, err := strconv.ParseInt(value, 10, 64)
|
||||
return requests, err == nil && requests >= 0
|
||||
}
|
||||
|
||||
func parentNode(node *html.Node) *html.Node {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
return node.Parent
|
||||
}
|
||||
|
||||
func nextElementSibling(node *html.Node) *html.Node {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
for sibling := node.NextSibling; sibling != nil; sibling = sibling.NextSibling {
|
||||
if sibling.Type == html.ElementNode {
|
||||
return sibling
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func timeElementValue(root *html.Node) *time.Time {
|
||||
var parsed *time.Time
|
||||
walkHTML(root, func(node *html.Node) {
|
||||
if parsed != nil || node.Type != html.ElementNode {
|
||||
return
|
||||
}
|
||||
if node.Data == "time" {
|
||||
if value, ok := htmlAttribute(node, "datetime"); ok {
|
||||
parsed = parseOllamaResetTime(value)
|
||||
}
|
||||
if parsed != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if node.Data == "local-time" || htmlClassToken(node, "local-time") {
|
||||
if value, ok := htmlAttribute(node, "data-time"); ok {
|
||||
parsed = parseOllamaResetTime(value)
|
||||
}
|
||||
}
|
||||
})
|
||||
return parsed
|
||||
}
|
||||
|
||||
func ollamaUsagePercentFromText(value string) (float64, bool) {
|
||||
match := ollamaUsagePercentPattern.FindStringSubmatch(value)
|
||||
if len(match) != 2 {
|
||||
return 0, false
|
||||
}
|
||||
percent, err := strconv.ParseFloat(match[1], 64)
|
||||
return percent, err == nil && percent >= 0 && percent <= 100
|
||||
}
|
||||
|
||||
func ollamaUsagePercentFromTrack(root *html.Node) (float64, bool) {
|
||||
var percent float64
|
||||
var found bool
|
||||
walkHTML(root, func(node *html.Node) {
|
||||
if found || node.Type != html.ElementNode {
|
||||
return
|
||||
}
|
||||
if _, ok := htmlAttribute(node, "data-usage-track"); !ok {
|
||||
return
|
||||
}
|
||||
var segmentTotal float64
|
||||
var segmentFound bool
|
||||
walkHTML(node, func(segment *html.Node) {
|
||||
if segment.Type != html.ElementNode {
|
||||
return
|
||||
}
|
||||
if _, ok := htmlAttribute(segment, "data-usage-segment"); !ok {
|
||||
return
|
||||
}
|
||||
if value, ok := cssWidthPercent(segment); ok {
|
||||
segmentTotal += value
|
||||
segmentFound = true
|
||||
}
|
||||
})
|
||||
if segmentFound && segmentTotal >= 0 && segmentTotal <= 100 {
|
||||
percent, found = segmentTotal, true
|
||||
return
|
||||
}
|
||||
walkHTML(node, func(child *html.Node) {
|
||||
if found || child.Type != html.ElementNode {
|
||||
return
|
||||
}
|
||||
if value, ok := cssWidthPercent(child); ok {
|
||||
percent, found = value, true
|
||||
}
|
||||
})
|
||||
})
|
||||
return percent, found
|
||||
}
|
||||
|
||||
func cssWidthPercent(node *html.Node) (float64, bool) {
|
||||
style, ok := htmlAttribute(node, "style")
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
match := ollamaUsageWidthPattern.FindStringSubmatch(style)
|
||||
if len(match) != 2 {
|
||||
return 0, false
|
||||
}
|
||||
percent, err := strconv.ParseFloat(match[1], 64)
|
||||
return percent, err == nil && percent >= 0 && percent <= 100
|
||||
}
|
||||
|
||||
func htmlAttribute(node *html.Node, key string) (string, bool) {
|
||||
if node == nil {
|
||||
return "", false
|
||||
}
|
||||
for _, attr := range node.Attr {
|
||||
if strings.EqualFold(attr.Key, key) {
|
||||
return attr.Val, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func htmlAttributeInSubtree(root *html.Node, key string) (string, bool) {
|
||||
var value string
|
||||
var found bool
|
||||
walkHTML(root, func(node *html.Node) {
|
||||
if found || node.Type != html.ElementNode {
|
||||
return
|
||||
}
|
||||
value, found = htmlAttribute(node, key)
|
||||
})
|
||||
return value, found
|
||||
}
|
||||
|
||||
func htmlClassToken(node *html.Node, token string) bool {
|
||||
value, ok := htmlAttribute(node, "class")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, className := range strings.Fields(value) {
|
||||
if className == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseOllamaResetTime(value string) *time.Time {
|
||||
value = strings.TrimSpace(value)
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "Jan 2, 2006 3:04 PM MST", "January 2, 2006 3:04 PM MST"} {
|
||||
if parsed, err := time.Parse(layout, value); err == nil && !parsed.IsZero() {
|
||||
parsed = parsed.UTC()
|
||||
return &parsed
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizedNodeText(node *html.Node) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
var collect func(*html.Node)
|
||||
collect = func(current *html.Node) {
|
||||
if current.Type == html.ElementNode && (current.Data == "script" || current.Data == "style" || current.Data == "noscript") {
|
||||
return
|
||||
}
|
||||
if current.Type == html.TextNode {
|
||||
if value := strings.TrimSpace(current.Data); value != "" {
|
||||
parts = append(parts, value)
|
||||
}
|
||||
}
|
||||
for child := current.FirstChild; child != nil; child = child.NextSibling {
|
||||
collect(child)
|
||||
}
|
||||
}
|
||||
collect(node)
|
||||
return strings.Join(strings.Fields(strings.Join(parts, "\n")), " ")
|
||||
}
|
||||
|
||||
func walkHTML(node *html.Node, visit func(*html.Node)) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
visit(node)
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
walkHTML(child, visit)
|
||||
}
|
||||
}
|
||||
|
||||
func isOllamaParserContainer(tag string) bool {
|
||||
switch tag {
|
||||
case "div", "section", "article", "li", "p", "span", "dt", "dd", "h1", "h2", "h3", "h4":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(value string, candidates ...string) bool {
|
||||
for _, candidate := range candidates {
|
||||
if strings.Contains(value, candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,955 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type ollamaUsageTestEncryptor struct{}
|
||||
|
||||
func (ollamaUsageTestEncryptor) Encrypt(value string) (string, error) { return "cipher:" + value, nil }
|
||||
func (ollamaUsageTestEncryptor) Decrypt(value string) (string, error) {
|
||||
if !strings.HasPrefix(value, "cipher:") {
|
||||
return "", errors.New("authentication failed")
|
||||
}
|
||||
return strings.TrimPrefix(value, "cipher:"), nil
|
||||
}
|
||||
|
||||
type ollamaUsageTestRepo struct {
|
||||
*upstreamBillingProbeAccountRepo
|
||||
due []Account
|
||||
beforeSnapshot func()
|
||||
disableAutoAttempts atomic.Int64
|
||||
disableAutoCalls atomic.Int64
|
||||
groupResolveCalls atomic.Int64
|
||||
}
|
||||
|
||||
func (r *ollamaUsageTestRepo) ListOllamaCloudUsageGroupAccounts(_ 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 := ollamaCloudUsageGroupFingerprint(anchor); ok {
|
||||
wanted[fingerprint] = struct{}{}
|
||||
}
|
||||
}
|
||||
result := make([]Account, 0, len(r.accounts))
|
||||
for _, account := range r.accounts {
|
||||
fingerprint, ok := ollamaCloudUsageGroupFingerprint(account)
|
||||
if _, match := wanted[fingerprint]; !ok || !match {
|
||||
continue
|
||||
}
|
||||
clone := *account
|
||||
clone.Extra = make(map[string]any, len(account.Extra))
|
||||
for key, value := range account.Extra {
|
||||
clone.Extra[key] = value
|
||||
}
|
||||
result = append(result, clone)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *ollamaUsageTestRepo) SaveOllamaCloudUsageSession(_ context.Context, expected *Account, ciphertext string, autoRefresh bool) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
members, err := r.ollamaGroupMembersLocked(expected)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, account := range members {
|
||||
account.Extra[OllamaCloudUsageSessionExtraKey] = ciphertext
|
||||
account.Extra[OllamaCloudUsageAutoRefreshExtraKey] = autoRefresh
|
||||
delete(account.Extra, OllamaCloudUsageSnapshotExtraKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ollamaUsageTestRepo) DeleteOllamaCloudUsageSession(_ context.Context, expected *Account) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
members, err := r.ollamaGroupMembersLocked(expected)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, account := range members {
|
||||
delete(account.Extra, OllamaCloudUsageSessionExtraKey)
|
||||
delete(account.Extra, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
delete(account.Extra, OllamaCloudUsageSnapshotExtraKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ollamaUsageTestRepo) SetOllamaCloudUsageAutoRefresh(_ context.Context, expected *Account, enabled bool) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
members, err := r.ollamaGroupMembersLocked(expected)
|
||||
if err != nil || !r.ollamaExpectedSessionExistsLocked(members, expected) {
|
||||
return ErrOllamaCloudUsageIdentityChanged
|
||||
}
|
||||
for _, account := range members {
|
||||
applyOllamaUsageTestManagedExtra(account, expected)
|
||||
account.Extra[OllamaCloudUsageAutoRefreshExtraKey] = enabled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ollamaUsageTestRepo) UpdateOllamaCloudUsageSnapshot(_ context.Context, expected *Account, snapshot *OllamaCloudUsageSnapshot) error {
|
||||
if r.beforeSnapshot != nil {
|
||||
r.beforeSnapshot()
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
members, err := r.ollamaGroupMembersLocked(expected)
|
||||
if err != nil || !r.ollamaExpectedSessionExistsLocked(members, expected) {
|
||||
return ErrOllamaCloudUsageIdentityChanged
|
||||
}
|
||||
for _, account := range members {
|
||||
applyOllamaUsageTestManagedExtra(account, expected)
|
||||
account.Extra[OllamaCloudUsageSnapshotExtraKey] = snapshot
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ollamaUsageTestRepo) DisableOllamaCloudUsageAutoRefresh(_ context.Context, expected *Account) error {
|
||||
r.disableAutoAttempts.Add(1)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
members, err := r.ollamaGroupMembersLocked(expected)
|
||||
if err != nil || !r.ollamaExpectedSessionExistsLocked(members, expected) {
|
||||
return ErrOllamaCloudUsageIdentityChanged
|
||||
}
|
||||
for _, account := range members {
|
||||
applyOllamaUsageTestManagedExtra(account, expected)
|
||||
account.Extra[OllamaCloudUsageAutoRefreshExtraKey] = false
|
||||
delete(account.Extra, OllamaCloudUsageSnapshotExtraKey)
|
||||
}
|
||||
r.disableAutoCalls.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ollamaUsageTestRepo) ollamaGroupMembersLocked(expected *Account) ([]*Account, error) {
|
||||
anchor := r.accounts[expected.ID]
|
||||
if !sameOllamaUsageTestIdentity(anchor, expected) {
|
||||
return nil, ErrOllamaCloudUsageIdentityChanged
|
||||
}
|
||||
fingerprint, ok := ollamaCloudUsageGroupFingerprint(expected)
|
||||
if !ok {
|
||||
return nil, ErrOllamaCloudUsageAccountInvalid
|
||||
}
|
||||
members := make([]*Account, 0, len(r.accounts))
|
||||
for _, account := range r.accounts {
|
||||
candidate, valid := ollamaCloudUsageGroupFingerprint(account)
|
||||
if valid && candidate == fingerprint {
|
||||
if account.Extra == nil {
|
||||
account.Extra = make(map[string]any)
|
||||
}
|
||||
members = append(members, account)
|
||||
}
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func (r *ollamaUsageTestRepo) ollamaExpectedSessionExistsLocked(members []*Account, expected *Account) bool {
|
||||
for _, member := range members {
|
||||
if member.Extra[OllamaCloudUsageSessionExtraKey] == expected.Extra[OllamaCloudUsageSessionExtraKey] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func applyOllamaUsageTestManagedExtra(account, source *Account) {
|
||||
for _, key := range []string{OllamaCloudUsageSessionExtraKey, OllamaCloudUsageAutoRefreshExtraKey, OllamaCloudUsageSnapshotExtraKey} {
|
||||
delete(account.Extra, key)
|
||||
if value, ok := source.Extra[key]; ok {
|
||||
account.Extra[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ollamaUsageTestRepo) ListDueOllamaCloudUsageAccounts(_ context.Context, _ time.Time, limit int) ([]Account, error) {
|
||||
if len(r.due) > 0 {
|
||||
return append([]Account(nil), r.due[:min(limit, len(r.due))]...), nil
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]Account, 0, len(r.accounts))
|
||||
for _, account := range r.accounts {
|
||||
out = append(out, *account)
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type ollamaRefreshPreflightIdentityChangeRepo struct {
|
||||
*ollamaUsageTestRepo
|
||||
getCalls atomic.Int64
|
||||
}
|
||||
|
||||
func (r *ollamaRefreshPreflightIdentityChangeRepo) 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.upstreamBillingProbeAccountRepo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
type ollamaManagedExtraUpdateRepo struct {
|
||||
AccountRepository
|
||||
account *Account
|
||||
updated *Account
|
||||
}
|
||||
|
||||
func (r *ollamaManagedExtraUpdateRepo) GetByID(_ context.Context, _ int64) (*Account, error) {
|
||||
return r.account, nil
|
||||
}
|
||||
|
||||
func (r *ollamaManagedExtraUpdateRepo) Update(_ context.Context, account *Account) error {
|
||||
r.updated = account
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameOllamaUsageTestIdentity(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)
|
||||
}
|
||||
|
||||
type ollamaUsageHTTPStub struct {
|
||||
status int
|
||||
body []byte
|
||||
header http.Header
|
||||
calls atomic.Int64
|
||||
active atomic.Int64
|
||||
maxActive atomic.Int64
|
||||
beforeResponse func(*http.Request)
|
||||
lastRequest *http.Request
|
||||
lastProxyURL string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *ollamaUsageHTTPStub) Do(req *http.Request, proxyURL string, _ int64, _ int) (*http.Response, error) {
|
||||
s.calls.Add(1)
|
||||
active := s.active.Add(1)
|
||||
defer s.active.Add(-1)
|
||||
for {
|
||||
peak := s.maxActive.Load()
|
||||
if active <= peak || s.maxActive.CompareAndSwap(peak, active) {
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.lastRequest = req
|
||||
s.lastProxyURL = 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{"text/html; charset=utf-8"}}
|
||||
}
|
||||
return &http.Response{StatusCode: status, Header: header, Body: io.NopCloser(strings.NewReader(string(s.body))), Request: req}, nil
|
||||
}
|
||||
|
||||
func (s *ollamaUsageHTTPStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return s.Do(req, proxyURL, accountID, concurrency)
|
||||
}
|
||||
|
||||
func ollamaUsageAccount(id int64) *Account {
|
||||
return &Account{
|
||||
ID: id, Name: fmt.Sprintf("ollama-%d", id), Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"base_url": "https://ollama.com", "api_key": fmt.Sprintf("key-%d", id)},
|
||||
Extra: map[string]any{}, Status: StatusActive, Schedulable: true, Concurrency: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func newOllamaUsageTestService(t *testing.T, repo *ollamaUsageTestRepo, upstream HTTPUpstream, settingsRepo SettingRepository, fixedKey bool) *OllamaCloudUsageService {
|
||||
t.Helper()
|
||||
svc := NewOllamaCloudUsageService(repo, upstream, NewSettingService(settingsRepo, nil), ollamaUsageTestEncryptor{}, fixedKey)
|
||||
t.Cleanup(svc.Stop)
|
||||
return svc
|
||||
}
|
||||
|
||||
func ollamaUsageFixture(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile("testdata/ollama_settings_usage.html")
|
||||
require.NoError(t, err)
|
||||
return body
|
||||
}
|
||||
|
||||
func TestOllamaCloudUsageSettingsDefaultOffAndValidation(t *testing.T) {
|
||||
repo := &upstreamBillingProbeSettingRepo{}
|
||||
settingsService := NewSettingService(repo, nil)
|
||||
settings, err := settingsService.GetOllamaCloudUsageSettings(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.False(t, settings.Enabled)
|
||||
require.Equal(t, 60, settings.IntervalMinutes)
|
||||
|
||||
err = settingsService.SetOllamaCloudUsageSettings(context.Background(), &OllamaCloudUsageSettings{Enabled: true, IntervalMinutes: 14})
|
||||
require.Error(t, err)
|
||||
err = settingsService.SetOllamaCloudUsageSettings(context.Background(), &OllamaCloudUsageSettings{Enabled: true, IntervalMinutes: 90})
|
||||
require.NoError(t, err)
|
||||
settings, err = settingsService.GetOllamaCloudUsageSettings(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.True(t, settings.Enabled)
|
||||
require.Equal(t, 90, settings.IntervalMinutes)
|
||||
}
|
||||
|
||||
func TestIsOllamaCloudUsageAccountStrictOfficialHost(t *testing.T) {
|
||||
tests := []struct {
|
||||
baseURL string
|
||||
platform string
|
||||
want bool
|
||||
}{
|
||||
{"https://ollama.com", PlatformOpenAI, true},
|
||||
{"HTTPS://OLLAMA.COM", PlatformAnthropic, true},
|
||||
{"https://www.OLLAMA.com:443/v1", PlatformOpenAI, true},
|
||||
{"https://ollama.com:443", PlatformOpenAI, true},
|
||||
{"https://ollama.com/", PlatformAnthropic, false},
|
||||
{"https://ollama.com/v1/", PlatformOpenAI, false},
|
||||
{"http://ollama.com", PlatformOpenAI, false},
|
||||
{"https://ollama.com.evil.test", PlatformOpenAI, false},
|
||||
{"https://ollama.com:444", PlatformOpenAI, false},
|
||||
{"https://user@ollama.com", PlatformOpenAI, false},
|
||||
{"https://ollama.com/v2", PlatformOpenAI, false},
|
||||
{"https://ollama.com?next=https://evil.test", PlatformOpenAI, false},
|
||||
{"https://ollama.com#usage", PlatformOpenAI, false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.baseURL+test.platform, func(t *testing.T) {
|
||||
account := ollamaUsageAccount(1)
|
||||
account.Platform = test.platform
|
||||
account.Credentials["base_url"] = test.baseURL
|
||||
require.Equal(t, test.want, IsOllamaCloudUsageAccount(account))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOllamaCloudUsageCookieAllowlist(t *testing.T) {
|
||||
normalized, err := normalizeOllamaCloudUsageCookie(" tracking=discard ; wos-session=secret ; __Secure-authjs.session-token.0=part-a ; device=discard ")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "wos-session=secret; __Secure-authjs.session-token.0=part-a", normalized)
|
||||
|
||||
normalized, err = normalizeOllamaCloudUsageCookie(" \t\r\nwos-session=secret; tracking=discard\r\n\t ")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "wos-session=secret", normalized)
|
||||
|
||||
_, err = normalizeOllamaCloudUsageCookie("wos-session=secret\r\nHost: evil.test")
|
||||
require.ErrorContains(t, err, "invalid header")
|
||||
|
||||
for _, allowed := range []string{
|
||||
"wos-session", "__Secure-session", "session", "ollama_session", "__Host-ollama_session",
|
||||
"next-auth.session-token", "next-auth.session-token.0", "__Secure-next-auth.session-token.12",
|
||||
"authjs.session-token", "__Secure-authjs.session-token.1",
|
||||
} {
|
||||
normalized, err := normalizeOllamaCloudUsageCookie(allowed + "=value")
|
||||
require.NoError(t, err, allowed)
|
||||
require.Equal(t, allowed+"=value", normalized)
|
||||
}
|
||||
|
||||
for _, invalid := range []string{
|
||||
"", "Domain=ollama.com; wos-session=x", "wos-session=x; Path=/",
|
||||
"wos-session=x; wos-session=y", "Secure", "tracking=only", "__session=arbitrary",
|
||||
"authjs.session-token.bad=not-a-shard", "Authjs.session-token=wrong-case",
|
||||
} {
|
||||
_, err := normalizeOllamaCloudUsageCookie(invalid)
|
||||
require.Error(t, err, invalid)
|
||||
}
|
||||
_, err = normalizeOllamaCloudUsageCookie("wos-session=" + strings.Repeat("x", ollamaCloudUsageMaxSessionBytes))
|
||||
require.ErrorContains(t, err, "too large")
|
||||
}
|
||||
|
||||
func TestParseOllamaCloudUsageHTMLFixture(t *testing.T) {
|
||||
data, err := parseOllamaCloudUsageHTML(ollamaUsageFixture(t))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "max", data.Plan)
|
||||
require.NotNil(t, data.FiveHour)
|
||||
require.Equal(t, 5.6, data.FiveHour.UsedPercent)
|
||||
require.NotNil(t, data.FiveHour.ResetAt)
|
||||
require.Equal(t, time.Date(2026, time.July, 23, 3, 0, 0, 0, time.UTC), *data.FiveHour.ResetAt)
|
||||
require.NotNil(t, data.SevenDay)
|
||||
require.Equal(t, 14.2, data.SevenDay.UsedPercent)
|
||||
require.NotNil(t, data.SevenDay.ResetAt)
|
||||
require.Equal(t, time.Date(2026, time.July, 29, 0, 0, 0, 0, time.UTC), *data.SevenDay.ResetAt)
|
||||
require.Equal(t, "$0", data.Balance)
|
||||
require.Equal(t, []OllamaCloudUsageModel{
|
||||
{Model: "gpt-oss:120b-cloud", Window: OllamaCloudUsageModelWindowFiveHour, Requests: 2},
|
||||
{Model: "qwen3-coder:480b-cloud", Window: OllamaCloudUsageModelWindowFiveHour, Requests: 3},
|
||||
{Model: "gpt-oss:120b-cloud", Window: OllamaCloudUsageModelWindowSevenDay, Requests: 12},
|
||||
{Model: "qwen3-coder:480b-cloud", Window: OllamaCloudUsageModelWindowSevenDay, Requests: 13},
|
||||
}, data.Models)
|
||||
|
||||
_, err = parseOllamaCloudUsageHTML([]byte(`<html><body><main>Sign in to Ollama</main></body></html>`))
|
||||
require.ErrorIs(t, err, errOllamaCloudUsageUnauthorizedHTML)
|
||||
_, err = parseOllamaCloudUsageHTML([]byte(`<html><body><p>5 hour usage 42% used</p><form>Sign in to Ollama</form></body></html>`))
|
||||
require.ErrorIs(t, err, errOllamaCloudUsageUnauthorizedHTML)
|
||||
_, err = parseOllamaCloudUsageHTML([]byte(`<html><body><main>unrelated settings</main></body></html>`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestParseOllamaCloudUsageHTMLMissingOptionalFieldsAndCSSWidthFallback(t *testing.T) {
|
||||
data, err := parseOllamaCloudUsageHTML([]byte(`
|
||||
<section>
|
||||
<p>5 hour usage</p>
|
||||
<div data-usage-track>
|
||||
<div data-usage-segment style="width: 23.5%"><span data-model="model-a" data-requests="1,234"></span></div>
|
||||
<div data-usage-segment data-model="model-a" data-requests="9,999" style="width: 0%"></div>
|
||||
</div>
|
||||
</section>`))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 23.5, data.FiveHour.UsedPercent)
|
||||
require.Nil(t, data.FiveHour.ResetAt)
|
||||
require.Empty(t, data.Plan)
|
||||
require.Nil(t, data.SevenDay)
|
||||
require.Empty(t, data.Balance)
|
||||
require.Equal(t, []OllamaCloudUsageModel{{
|
||||
Model: "model-a", Window: OllamaCloudUsageModelWindowFiveHour, Requests: 1234,
|
||||
}}, data.Models)
|
||||
}
|
||||
|
||||
func TestParseOllamaCloudUsageHTMLResetElementVariants(t *testing.T) {
|
||||
const want = "2026-07-23T03:00:00Z"
|
||||
for name, element := range map[string]string{
|
||||
"time datetime": `<time datetime="` + want + `">2 hours.</time>`,
|
||||
"custom element": `<local-time data-time="` + want + `">2 hours.</local-time>`,
|
||||
"class token": `<span class="text-xs local-time tabular-nums" data-time="` + want + `">2 hours.</span>`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
data, err := parseOllamaCloudUsageHTML([]byte(
|
||||
`<div><div><span>Session usage</span><span>1% used</span></div><div>Resets in ` + element + `</div></div>`,
|
||||
))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, data.FiveHour)
|
||||
require.NotNil(t, data.FiveHour.ResetAt)
|
||||
require.Equal(t, want, data.FiveHour.ResetAt.Format(time.RFC3339))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOllamaCloudUsageHTMLPlanAndBalanceFallbacks(t *testing.T) {
|
||||
data, err := parseOllamaCloudUsageHTML([]byte(`
|
||||
<section>
|
||||
<h2><span>Cloud usage</span><span>max</span></h2>
|
||||
<div><span>Plan</span><span>Pro</span></div>
|
||||
<p>Credits currently available: USD $9.50</p>
|
||||
</section>`))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "max", data.Plan)
|
||||
require.Equal(t, "USD$9.50", data.Balance)
|
||||
|
||||
data, err = parseOllamaCloudUsageHTML([]byte(`<div><span>Subscription</span><span>Pro</span></div>`))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Pro", data.Plan)
|
||||
}
|
||||
|
||||
func TestOllamaCloudUsageManagedExtraCannotBeImported(t *testing.T) {
|
||||
remoteExtra := map[string]any{
|
||||
OllamaCloudUsageSessionExtraKey: "remote-ciphertext",
|
||||
OllamaCloudUsageAutoRefreshExtraKey: true,
|
||||
OllamaCloudUsageSnapshotExtraKey: map[string]any{"status": "forged"},
|
||||
}
|
||||
created, err := buildAccountForCreate(&CreateAccountInput{
|
||||
Name: "ollama", Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"base_url": "https://ollama.com", "api_key": "key"},
|
||||
Concurrency: 1,
|
||||
}, mergeMap(nil, remoteExtra))
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, created.Extra, OllamaCloudUsageSessionExtraKey)
|
||||
require.NotContains(t, created.Extra, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
require.NotContains(t, created.Extra, OllamaCloudUsageSnapshotExtraKey)
|
||||
|
||||
existing := ollamaUsageAccount(6)
|
||||
existing.Extra = map[string]any{
|
||||
OllamaCloudUsageSessionExtraKey: "local-ciphertext",
|
||||
OllamaCloudUsageAutoRefreshExtraKey: false,
|
||||
OllamaCloudUsageSnapshotExtraKey: map[string]any{"status": OllamaCloudUsageStatusOK},
|
||||
}
|
||||
targetExtra := mergeMap(existing.Extra, remoteExtra)
|
||||
reconcileCRSUpstreamBillingProbeExtra(existing, existing.Platform, existing.Type, mergeMap(existing.Credentials, nil), targetExtra)
|
||||
require.Equal(t, "local-ciphertext", targetExtra[OllamaCloudUsageSessionExtraKey])
|
||||
require.Equal(t, false, targetExtra[OllamaCloudUsageAutoRefreshExtraKey])
|
||||
require.Equal(t, map[string]any{"status": OllamaCloudUsageStatusOK}, targetExtra[OllamaCloudUsageSnapshotExtraKey])
|
||||
|
||||
changedCredentials := mergeMap(existing.Credentials, map[string]any{"api_key": "rotated"})
|
||||
targetExtra = mergeMap(existing.Extra, remoteExtra)
|
||||
reconcileCRSUpstreamBillingProbeExtra(existing, existing.Platform, existing.Type, changedCredentials, targetExtra)
|
||||
require.NotContains(t, targetExtra, OllamaCloudUsageSessionExtraKey)
|
||||
require.NotContains(t, targetExtra, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
require.NotContains(t, targetExtra, OllamaCloudUsageSnapshotExtraKey)
|
||||
}
|
||||
|
||||
func TestAccountServiceUpdateStripsOllamaManagedExtra(t *testing.T) {
|
||||
account := ollamaUsageAccount(61)
|
||||
account.Extra = map[string]any{
|
||||
OllamaCloudUsageSessionExtraKey: "local-ciphertext",
|
||||
OllamaCloudUsageAutoRefreshExtraKey: true,
|
||||
OllamaCloudUsageSnapshotExtraKey: map[string]any{"status": OllamaCloudUsageStatusOK},
|
||||
}
|
||||
repo := &ollamaManagedExtraUpdateRepo{account: account}
|
||||
svc := NewAccountService(repo, nil)
|
||||
requestedExtra := map[string]any{
|
||||
"note": "preserved",
|
||||
OllamaCloudUsageSessionExtraKey: "forged-ciphertext",
|
||||
OllamaCloudUsageAutoRefreshExtraKey: nil,
|
||||
OllamaCloudUsageSnapshotExtraKey: nil,
|
||||
}
|
||||
|
||||
_, err := svc.Update(context.Background(), account.ID, UpdateAccountRequest{Extra: &requestedExtra})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "preserved", repo.updated.Extra["note"])
|
||||
require.NotContains(t, repo.updated.Extra, OllamaCloudUsageSessionExtraKey)
|
||||
require.NotContains(t, repo.updated.Extra, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
require.NotContains(t, repo.updated.Extra, OllamaCloudUsageSnapshotExtraKey)
|
||||
// The request map is not mutated while managed fields are stripped.
|
||||
require.Contains(t, requestedExtra, OllamaCloudUsageSessionExtraKey)
|
||||
}
|
||||
|
||||
func TestOllamaCloudUsageSessionEncryptionFailClosedAndWriteOnlyState(t *testing.T) {
|
||||
account := ollamaUsageAccount(7)
|
||||
repo := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{7: account}}}
|
||||
settings := &upstreamBillingProbeSettingRepo{}
|
||||
|
||||
ephemeral := newOllamaUsageTestService(t, repo, &ollamaUsageHTTPStub{}, settings, false)
|
||||
_, err := ephemeral.SaveSession(context.Background(), 7, "wos-session=plaintext-secret")
|
||||
require.ErrorIs(t, err, ErrOllamaCloudUsageEncryptionKey)
|
||||
require.NotContains(t, account.Extra, OllamaCloudUsageSessionExtraKey)
|
||||
|
||||
svc := newOllamaUsageTestService(t, repo, &ollamaUsageHTTPStub{}, settings, true)
|
||||
_, err = svc.SaveSession(context.Background(), 7, "tracking=arbitrary-only")
|
||||
require.Error(t, err)
|
||||
require.NotContains(t, account.Extra, OllamaCloudUsageSessionExtraKey)
|
||||
|
||||
state, err := svc.SaveSession(context.Background(), 7, "tracking=must-not-persist; wos-session=plaintext-secret")
|
||||
require.NoError(t, err)
|
||||
require.True(t, state.Configured)
|
||||
stored, ok := account.Extra[OllamaCloudUsageSessionExtraKey].(string)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "cipher:wos-session=plaintext-secret", stored)
|
||||
require.NotContains(t, stored, "tracking")
|
||||
raw, err := json.Marshal(state)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(raw), "plaintext-secret")
|
||||
require.NotContains(t, string(raw), "cipher:")
|
||||
|
||||
account.Extra[OllamaCloudUsageSessionExtraKey] = "plaintext-secret"
|
||||
_, err = svc.Refresh(context.Background(), 7)
|
||||
require.ErrorContains(t, err, "cannot be decrypted")
|
||||
}
|
||||
|
||||
func TestOllamaCloudUsageGroupSharesAcrossPlatformsURLVariantsAndDynamicSiblings(t *testing.T) {
|
||||
source := ollamaUsageAccount(71)
|
||||
source.Credentials["api_key"] = "shared-key"
|
||||
source.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=shared"
|
||||
source.Extra[OllamaCloudUsageAutoRefreshExtraKey] = true
|
||||
source.Extra[OllamaCloudUsageSnapshotExtraKey] = &OllamaCloudUsageSnapshot{
|
||||
Status: OllamaCloudUsageStatusOK,
|
||||
Data: &OllamaCloudUsageData{Plan: "pro"},
|
||||
}
|
||||
source.UpdatedAt = time.Now().Add(-time.Minute)
|
||||
sibling := ollamaUsageAccount(72)
|
||||
sibling.Platform = PlatformAnthropic
|
||||
sibling.Credentials = map[string]any{"base_url": "HTTPS://WWW.OLLAMA.COM:443/v1", "api_key": "shared-key"}
|
||||
sibling.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=shared"
|
||||
sibling.Extra[OllamaCloudUsageAutoRefreshExtraKey] = true
|
||||
sibling.UpdatedAt = time.Now()
|
||||
different := ollamaUsageAccount(73)
|
||||
different.Credentials["api_key"] = "different-key"
|
||||
repo := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
|
||||
source.ID: source, sibling.ID: sibling, different.ID: different,
|
||||
}}}
|
||||
svc := newOllamaUsageTestService(t, repo, &ollamaUsageHTTPStub{}, &upstreamBillingProbeSettingRepo{}, true)
|
||||
|
||||
state, err := svc.GetState(context.Background(), sibling.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, state.Configured)
|
||||
require.True(t, state.AutoRefreshEnabled)
|
||||
require.Equal(t, "pro", state.Snapshot.Data.Plan)
|
||||
|
||||
differentState, err := svc.GetState(context.Background(), different.ID)
|
||||
require.NoError(t, err)
|
||||
require.False(t, differentState.Configured)
|
||||
|
||||
newSibling := ollamaUsageAccount(74)
|
||||
newSibling.Platform = PlatformAnthropic
|
||||
newSibling.Credentials = map[string]any{"base_url": "https://ollama.com:443", "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.Configured)
|
||||
require.Equal(t, state.Snapshot, newState.Snapshot)
|
||||
|
||||
before := repo.groupResolveCalls.Load()
|
||||
require.NoError(t, svc.ResolveAccounts(context.Background(), []*Account{source, sibling, different, newSibling}))
|
||||
require.Equal(t, before+1, repo.groupResolveCalls.Load(), "one list batch must issue one group lookup")
|
||||
}
|
||||
|
||||
func TestOllamaCloudUsageSaveAutoRefreshAndDeleteAreGroupScoped(t *testing.T) {
|
||||
first := ollamaUsageAccount(81)
|
||||
first.Credentials["api_key"] = "shared-key"
|
||||
second := ollamaUsageAccount(82)
|
||||
second.Platform = PlatformAnthropic
|
||||
second.Credentials = map[string]any{"base_url": "https://www.ollama.com/v1", "api_key": "shared-key"}
|
||||
different := ollamaUsageAccount(83)
|
||||
different.Credentials["api_key"] = "different-key"
|
||||
repo := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
|
||||
first.ID: first, second.ID: second, different.ID: different,
|
||||
}}}
|
||||
svc := newOllamaUsageTestService(t, repo, &ollamaUsageHTTPStub{}, &upstreamBillingProbeSettingRepo{}, true)
|
||||
|
||||
state, err := svc.SaveSession(context.Background(), second.ID, "wos-session=shared-browser")
|
||||
require.NoError(t, err)
|
||||
require.True(t, state.Configured)
|
||||
require.Equal(t, "cipher:wos-session=shared-browser", first.Extra[OllamaCloudUsageSessionExtraKey])
|
||||
require.Equal(t, first.Extra[OllamaCloudUsageSessionExtraKey], second.Extra[OllamaCloudUsageSessionExtraKey])
|
||||
require.NotContains(t, different.Extra, OllamaCloudUsageSessionExtraKey)
|
||||
|
||||
state, err = svc.SetAutoRefresh(context.Background(), first.ID, true)
|
||||
require.NoError(t, err)
|
||||
require.True(t, state.AutoRefreshEnabled)
|
||||
require.Equal(t, true, first.Extra[OllamaCloudUsageAutoRefreshExtraKey])
|
||||
require.Equal(t, true, second.Extra[OllamaCloudUsageAutoRefreshExtraKey])
|
||||
|
||||
state, err = svc.DeleteSession(context.Background(), second.ID)
|
||||
require.NoError(t, err)
|
||||
require.False(t, state.Configured)
|
||||
for _, member := range []*Account{first, second} {
|
||||
require.NotContains(t, member.Extra, OllamaCloudUsageSessionExtraKey)
|
||||
require.NotContains(t, member.Extra, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
require.NotContains(t, member.Extra, OllamaCloudUsageSnapshotExtraKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOllamaCloudUsageRefreshSingleflightAndRunnerDeduplicateSharedGroup(t *testing.T) {
|
||||
first := ollamaUsageAccount(91)
|
||||
first.Credentials["api_key"] = "shared-key"
|
||||
first.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=shared"
|
||||
first.Extra[OllamaCloudUsageAutoRefreshExtraKey] = true
|
||||
second := ollamaUsageAccount(92)
|
||||
second.Platform = PlatformAnthropic
|
||||
second.Credentials = map[string]any{"base_url": "https://www.ollama.com:443/v1", "api_key": "shared-key"}
|
||||
second.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=shared"
|
||||
second.Extra[OllamaCloudUsageAutoRefreshExtraKey] = true
|
||||
repo := &ollamaUsageTestRepo{
|
||||
upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{first.ID: first, second.ID: second}},
|
||||
due: []Account{*first, *second},
|
||||
}
|
||||
settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{
|
||||
SettingKeyOllamaCloudUsageSettings: `{"enabled":true,"interval_minutes":60}`,
|
||||
}}
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var once sync.Once
|
||||
upstream := &ollamaUsageHTTPStub{body: ollamaUsageFixture(t), beforeResponse: func(*http.Request) {
|
||||
once.Do(func() { close(started) })
|
||||
<-release
|
||||
}}
|
||||
svc := newOllamaUsageTestService(t, repo, upstream, settingsRepo, true)
|
||||
|
||||
errs := make(chan error, 2)
|
||||
go func() { _, err := svc.Refresh(context.Background(), first.ID); errs <- err }()
|
||||
<-started
|
||||
go func() { _, err := svc.Refresh(context.Background(), second.ID); errs <- err }()
|
||||
close(release)
|
||||
require.NoError(t, <-errs)
|
||||
require.NoError(t, <-errs)
|
||||
require.Equal(t, int64(1), upstream.calls.Load())
|
||||
require.NotNil(t, decodeOllamaCloudUsageSnapshot(first.Extra))
|
||||
require.Equal(t, decodeOllamaCloudUsageSnapshot(first.Extra), decodeOllamaCloudUsageSnapshot(second.Extra))
|
||||
|
||||
delete(first.Extra, OllamaCloudUsageSnapshotExtraKey)
|
||||
delete(second.Extra, OllamaCloudUsageSnapshotExtraKey)
|
||||
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 TestOllamaCloudUsageRefreshRejectsGroupChangeBeforeUpstreamRequest(t *testing.T) {
|
||||
account := ollamaUsageAccount(94)
|
||||
account.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=secret"
|
||||
base := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}}
|
||||
repo := &ollamaRefreshPreflightIdentityChangeRepo{ollamaUsageTestRepo: base}
|
||||
upstream := &ollamaUsageHTTPStub{body: ollamaUsageFixture(t)}
|
||||
svc := NewOllamaCloudUsageService(repo, upstream, NewSettingService(&upstreamBillingProbeSettingRepo{}, nil), ollamaUsageTestEncryptor{}, true)
|
||||
t.Cleanup(svc.Stop)
|
||||
|
||||
_, err := svc.Refresh(context.Background(), account.ID)
|
||||
|
||||
require.ErrorIs(t, err, ErrOllamaCloudUsageIdentityChanged)
|
||||
require.Zero(t, upstream.calls.Load())
|
||||
require.NotContains(t, account.Extra, OllamaCloudUsageSnapshotExtraKey)
|
||||
}
|
||||
|
||||
func TestOllamaCloudUsageRefreshUsesFixedURLCookieAndNoRedirects(t *testing.T) {
|
||||
account := ollamaUsageAccount(8)
|
||||
account.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=browser-secret; tracking=must-not-send"
|
||||
repo := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{8: account}}}
|
||||
upstream := &ollamaUsageHTTPStub{body: ollamaUsageFixture(t)}
|
||||
svc := newOllamaUsageTestService(t, repo, upstream, &upstreamBillingProbeSettingRepo{}, true)
|
||||
fixedNow := time.Date(2026, time.July, 22, 15, 0, 0, 0, time.UTC)
|
||||
svc.now = func() time.Time { return fixedNow }
|
||||
|
||||
state, err := svc.Refresh(context.Background(), 8)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OllamaCloudUsageStatusOK, state.Snapshot.Status)
|
||||
require.Equal(t, "https://ollama.com/settings", upstream.lastRequest.URL.String())
|
||||
require.Equal(t, "ollama.com", upstream.lastRequest.Host)
|
||||
require.Equal(t, "wos-session=browser-secret", upstream.lastRequest.Header.Get("Cookie"))
|
||||
require.NotContains(t, upstream.lastRequest.Header.Get("Cookie"), "tracking")
|
||||
require.Empty(t, upstream.lastRequest.Header.Get("Authorization"))
|
||||
require.True(t, HTTPUpstreamRedirectsDisabled(upstream.lastRequest.Context()))
|
||||
}
|
||||
|
||||
func TestOllamaCloudUsageManualRefreshUsesShortIndependentInterval(t *testing.T) {
|
||||
account := ollamaUsageAccount(12)
|
||||
account.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=initial"
|
||||
repo := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{12: account}}}
|
||||
upstream := &ollamaUsageHTTPStub{body: ollamaUsageFixture(t)}
|
||||
svc := newOllamaUsageTestService(t, repo, upstream, &upstreamBillingProbeSettingRepo{}, true)
|
||||
fixedNow := time.Date(2026, time.July, 22, 15, 0, 0, 0, time.UTC)
|
||||
svc.now = func() time.Time { return fixedNow }
|
||||
|
||||
_, err := svc.Refresh(context.Background(), 12)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.Refresh(context.Background(), 12)
|
||||
require.ErrorIs(t, err, ErrOllamaCloudUsageRefreshRateLimited)
|
||||
require.Equal(t, int64(1), upstream.calls.Load())
|
||||
|
||||
// Saving a repaired session clears the prior snapshot, so the global 60-minute
|
||||
// next_refresh_at does not block immediate administrator verification.
|
||||
_, err = svc.SaveSession(context.Background(), 12, "wos-session=repaired")
|
||||
require.NoError(t, err)
|
||||
_, err = svc.Refresh(context.Background(), 12)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), upstream.calls.Load())
|
||||
}
|
||||
|
||||
func TestOllamaCloudUsageRefreshUsesHydratedProxyIdentity(t *testing.T) {
|
||||
account := ollamaUsageAccount(13)
|
||||
account.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=secret"
|
||||
proxyID := int64(4)
|
||||
account.ProxyID = &proxyID
|
||||
account.Proxy = &Proxy{
|
||||
ID: proxyID, Protocol: "http", Host: "127.0.0.1", Port: 3128,
|
||||
Username: "proxy-user", Password: "proxy-pass", Status: StatusActive,
|
||||
}
|
||||
repo := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{13: account}}}
|
||||
upstream := &ollamaUsageHTTPStub{body: ollamaUsageFixture(t)}
|
||||
svc := newOllamaUsageTestService(t, repo, upstream, &upstreamBillingProbeSettingRepo{}, true)
|
||||
|
||||
_, err := svc.Refresh(context.Background(), 13)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, account.Proxy.URL(), upstream.lastProxyURL)
|
||||
}
|
||||
|
||||
func TestOllamaCloudUsageRedirectAndBodyLimitArePersistedSafely(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
status int
|
||||
body []byte
|
||||
reason string
|
||||
}{
|
||||
{"redirect", http.StatusFound, nil, "redirect_blocked"},
|
||||
{"body limit", http.StatusOK, make([]byte, ollamaCloudUsageMaxBodyBytes+1), "response_too_large"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
account := ollamaUsageAccount(9)
|
||||
account.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=secret"
|
||||
repo := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{9: account}}}
|
||||
svc := newOllamaUsageTestService(t, repo, &ollamaUsageHTTPStub{status: test.status, body: test.body}, &upstreamBillingProbeSettingRepo{}, true)
|
||||
state, err := svc.Refresh(context.Background(), 9)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OllamaCloudUsageStatusFailed, state.Snapshot.Status)
|
||||
require.Equal(t, test.reason, state.Snapshot.LastError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOllamaCloudUsageRefreshRejectsIdentityChange(t *testing.T) {
|
||||
account := ollamaUsageAccount(10)
|
||||
account.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=secret"
|
||||
repo := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{10: account}}}
|
||||
repo.beforeSnapshot = func() { account.Credentials["api_key"] = "rotated" }
|
||||
svc := newOllamaUsageTestService(t, repo, &ollamaUsageHTTPStub{body: ollamaUsageFixture(t)}, &upstreamBillingProbeSettingRepo{}, true)
|
||||
_, err := svc.Refresh(context.Background(), 10)
|
||||
require.ErrorIs(t, err, ErrOllamaCloudUsageIdentityChanged)
|
||||
require.NotContains(t, account.Extra, OllamaCloudUsageSnapshotExtraKey)
|
||||
}
|
||||
|
||||
func TestOllamaCloudUsageRunnerHonorsLeaderLockAndBackoff(t *testing.T) {
|
||||
account := ollamaUsageAccount(11)
|
||||
account.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=secret"
|
||||
account.Extra[OllamaCloudUsageAutoRefreshExtraKey] = true
|
||||
repo := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{11: account}}}
|
||||
upstream := &ollamaUsageHTTPStub{body: ollamaUsageFixture(t)}
|
||||
settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{
|
||||
SettingKeyOllamaCloudUsageSettings: `{"enabled":true,"interval_minutes":60}`,
|
||||
}}
|
||||
cache := &fakeLeaderLockCache{}
|
||||
_, acquired := tryAcquireSingletonLeaderLock(context.Background(), cache, nil, ollamaCloudUsageLeaderLockKey, "peer", time.Minute)
|
||||
require.True(t, acquired)
|
||||
svc := newOllamaUsageTestService(t, repo, upstream, settingsRepo, true)
|
||||
svc.lockCache = cache
|
||||
require.NoError(t, svc.RunDue(context.Background()))
|
||||
require.Zero(t, upstream.calls.Load())
|
||||
require.NoError(t, cache.ReleaseLeaderLock(context.Background(), ollamaCloudUsageLeaderLockKey, "peer"))
|
||||
require.NoError(t, svc.RunDue(context.Background()))
|
||||
require.Equal(t, int64(1), upstream.calls.Load())
|
||||
|
||||
firstFailure := nextOllamaCloudUsageDelay(60, 1, 0)
|
||||
thirdFailure := nextOllamaCloudUsageDelay(60, 3, 0)
|
||||
require.Greater(t, thirdFailure, firstFailure)
|
||||
require.GreaterOrEqual(t, nextOllamaCloudUsageDelay(60, 1, 3*time.Hour), 3*time.Hour)
|
||||
require.LessOrEqual(t, nextOllamaCloudUsageDelay(60, 20, 0), ollamaCloudUsageMaxDelay+5*time.Minute)
|
||||
}
|
||||
|
||||
func TestOllamaCloudUsageRunnerDisablesAutoRefreshAfterUnpersistableIdentityError(t *testing.T) {
|
||||
account := ollamaUsageAccount(14)
|
||||
account.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=secret"
|
||||
account.Extra[OllamaCloudUsageAutoRefreshExtraKey] = true
|
||||
missingProxyID := int64(99)
|
||||
account.ProxyID = &missingProxyID
|
||||
account.Proxy = nil
|
||||
repo := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{14: account}}}
|
||||
settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{
|
||||
SettingKeyOllamaCloudUsageSettings: `{"enabled":true,"interval_minutes":60}`,
|
||||
}}
|
||||
upstream := &ollamaUsageHTTPStub{body: ollamaUsageFixture(t)}
|
||||
svc := newOllamaUsageTestService(t, repo, upstream, settingsRepo, true)
|
||||
|
||||
require.NoError(t, svc.RunDue(context.Background()))
|
||||
require.Equal(t, int64(1), repo.disableAutoCalls.Load())
|
||||
require.Equal(t, false, account.Extra[OllamaCloudUsageAutoRefreshExtraKey])
|
||||
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 TestOllamaCloudUsageRunnerIdentityChangePreservesOldGroupAndDoesNotLoop(t *testing.T) {
|
||||
anchor := ollamaUsageAccount(15)
|
||||
anchor.Credentials["api_key"] = "shared-before-rotation"
|
||||
anchor.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=secret"
|
||||
anchor.Extra[OllamaCloudUsageAutoRefreshExtraKey] = true
|
||||
sibling := ollamaUsageAccount(16)
|
||||
sibling.Platform = PlatformAnthropic
|
||||
sibling.Credentials = map[string]any{"api_key": "shared-before-rotation", "base_url": "https://www.ollama.com:443/v1"}
|
||||
sibling.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=secret"
|
||||
sibling.Extra[OllamaCloudUsageAutoRefreshExtraKey] = true
|
||||
dueAnchor := *anchor
|
||||
dueAnchor.Credentials = mergeMap(nil, anchor.Credentials)
|
||||
dueAnchor.Extra = mergeMap(nil, anchor.Extra)
|
||||
repo := &ollamaUsageTestRepo{
|
||||
upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
|
||||
anchor.ID: anchor, sibling.ID: sibling,
|
||||
}},
|
||||
due: []Account{dueAnchor},
|
||||
}
|
||||
var rotateOnce sync.Once
|
||||
repo.beforeSnapshot = func() {
|
||||
rotateOnce.Do(func() {
|
||||
repo.mu.Lock()
|
||||
defer repo.mu.Unlock()
|
||||
anchor.Credentials["api_key"] = "rotated-account-key"
|
||||
delete(anchor.Extra, OllamaCloudUsageSessionExtraKey)
|
||||
delete(anchor.Extra, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
delete(anchor.Extra, OllamaCloudUsageSnapshotExtraKey)
|
||||
})
|
||||
}
|
||||
settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{
|
||||
SettingKeyOllamaCloudUsageSettings: `{"enabled":true,"interval_minutes":60}`,
|
||||
}}
|
||||
upstream := &ollamaUsageHTTPStub{body: ollamaUsageFixture(t)}
|
||||
svc := newOllamaUsageTestService(t, repo, upstream, settingsRepo, true)
|
||||
|
||||
require.NoError(t, svc.RunDue(context.Background()))
|
||||
require.Equal(t, int64(1), repo.disableAutoAttempts.Load())
|
||||
require.Zero(t, repo.disableAutoCalls.Load(), "the stale anchor CAS must not disable the old sibling group")
|
||||
require.Equal(t, true, sibling.Extra[OllamaCloudUsageAutoRefreshExtraKey])
|
||||
require.NotContains(t, anchor.Extra, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
|
||||
repo.due = []Account{*anchor, *sibling}
|
||||
require.NoError(t, svc.RunDue(context.Background()))
|
||||
require.Equal(t, int64(1), repo.disableAutoAttempts.Load(), "the changed account must not be retried")
|
||||
require.Equal(t, true, sibling.Extra[OllamaCloudUsageAutoRefreshExtraKey])
|
||||
require.NotNil(t, decodeOllamaCloudUsageSnapshot(sibling.Extra), "the still-valid sibling must refresh normally")
|
||||
require.Equal(t, int64(2), upstream.calls.Load())
|
||||
}
|
||||
|
||||
func TestOllamaCloudUsageSingleflightConcurrencyAndRunnerSwitches(t *testing.T) {
|
||||
accounts := make(map[int64]*Account)
|
||||
for id := int64(1); id <= 7; id++ {
|
||||
account := ollamaUsageAccount(id)
|
||||
account.Extra[OllamaCloudUsageSessionExtraKey] = "cipher:wos-session=secret"
|
||||
account.Extra[OllamaCloudUsageAutoRefreshExtraKey] = true
|
||||
accounts[id] = account
|
||||
}
|
||||
repo := &ollamaUsageTestRepo{upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: accounts}}
|
||||
unblock := make(chan struct{})
|
||||
entered := make(chan struct{}, 10)
|
||||
upstream := &ollamaUsageHTTPStub{body: ollamaUsageFixture(t), beforeResponse: func(*http.Request) {
|
||||
entered <- struct{}{}
|
||||
<-unblock
|
||||
}}
|
||||
settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{}}
|
||||
svc := newOllamaUsageTestService(t, repo, upstream, settingsRepo, true)
|
||||
|
||||
// Global automatic refresh is fail-safe off by default.
|
||||
require.NoError(t, svc.RunDue(context.Background()))
|
||||
require.Zero(t, upstream.calls.Load())
|
||||
|
||||
settingsRepo.values[SettingKeyOllamaCloudUsageSettings] = `{"enabled":true,"interval_minutes":60}`
|
||||
var singleflight sync.WaitGroup
|
||||
singleflight.Add(2)
|
||||
for range 2 {
|
||||
go func() {
|
||||
defer singleflight.Done()
|
||||
_, _ = svc.Refresh(context.Background(), 1)
|
||||
}()
|
||||
}
|
||||
<-entered
|
||||
close(unblock)
|
||||
singleflight.Wait()
|
||||
require.Equal(t, int64(1), upstream.calls.Load())
|
||||
|
||||
// Clear snapshots so all accounts are due, then verify the shared four-slot bound.
|
||||
for _, account := range accounts {
|
||||
delete(account.Extra, OllamaCloudUsageSnapshotExtraKey)
|
||||
}
|
||||
unblock2 := make(chan struct{})
|
||||
upstream.beforeResponse = func(*http.Request) { <-unblock2 }
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = svc.RunDue(context.Background())
|
||||
close(done)
|
||||
}()
|
||||
require.Eventually(t, func() bool { return upstream.active.Load() == ollamaCloudUsageConcurrency }, time.Second, 10*time.Millisecond)
|
||||
close(unblock2)
|
||||
<-done
|
||||
require.LessOrEqual(t, upstream.maxActive.Load(), int64(ollamaCloudUsageConcurrency))
|
||||
require.Equal(t, int64(8), upstream.calls.Load())
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Sanitized and trimmed from the real Ollama /settings DOM structure.
|
||||
Usage tracks, segments, and local-time class anchors are retained for parser coverage.
|
||||
This fixture contains no real account identifiers, browser cookies, or session values.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head><title>Settings - Ollama</title></head>
|
||||
<body>
|
||||
<main>
|
||||
<section>
|
||||
<h2><span>Cloud usage</span><span>max</span></h2>
|
||||
<div><div>Balance remaining</div><div>$0</div></div>
|
||||
</section>
|
||||
|
||||
<section aria-label="Cloud usage windows">
|
||||
<div>
|
||||
<div><span>Session usage</span><span>5.6% used</span></div>
|
||||
<div data-usage-track>
|
||||
<div
|
||||
data-usage-segment
|
||||
data-model="qwen3-coder:480b-cloud"
|
||||
data-requests="3"
|
||||
style="width: 3.4%"
|
||||
></div>
|
||||
<div
|
||||
data-usage-segment
|
||||
data-model="gpt-oss:120b-cloud"
|
||||
data-requests="2"
|
||||
style="width: 2.2%"
|
||||
></div>
|
||||
</div>
|
||||
<div>Resets in <span class="tabular-nums local-time" data-time="2026-07-23T03:00:00Z">2 hours.</span></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div><span>Weekly usage</span><span>14.2% used</span></div>
|
||||
<div data-usage-track>
|
||||
<div
|
||||
data-usage-segment
|
||||
data-model="qwen3-coder:480b-cloud"
|
||||
data-requests="13"
|
||||
style="width: 10.1%"
|
||||
></div>
|
||||
<div
|
||||
data-usage-segment
|
||||
data-model="gpt-oss:120b-cloud"
|
||||
data-requests="12"
|
||||
style="width: 4.1%"
|
||||
></div>
|
||||
</div>
|
||||
<div>Resets in <span class="local-time text-xs" data-time="2026-07-29T00:00:00Z">6 days.</span></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -726,6 +726,7 @@ var ProviderSet = wire.NewSet(
|
||||
ProvideAccountUsageService,
|
||||
ProvideAccountTestService,
|
||||
ProvideUpstreamBillingProbeService,
|
||||
ProvideOllamaCloudUsageService,
|
||||
ProvideSettingService,
|
||||
NewDataManagementService,
|
||||
ProvideBackupService,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { get, post, put, del } = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
del: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: { get, post, put, delete: del }
|
||||
}))
|
||||
|
||||
import {
|
||||
deleteOllamaCloudUsageSession,
|
||||
getOllamaCloudUsage,
|
||||
getOllamaCloudUsageSettings,
|
||||
refreshOllamaCloudUsage,
|
||||
saveOllamaCloudUsageSession,
|
||||
setOllamaCloudUsageAutoRefresh,
|
||||
updateOllamaCloudUsageSettings
|
||||
} from '@/api/admin/accounts'
|
||||
|
||||
const state = {
|
||||
account_id: 7,
|
||||
eligible: true,
|
||||
configured: true,
|
||||
auto_refresh_enabled: false,
|
||||
encryption_key_configured: true
|
||||
}
|
||||
|
||||
describe('admin Ollama Cloud usage API', () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset()
|
||||
post.mockReset()
|
||||
put.mockReset()
|
||||
del.mockReset()
|
||||
})
|
||||
|
||||
it('uses dedicated global settings endpoints', async () => {
|
||||
const settings = { enabled: false, interval_minutes: 60 }
|
||||
get.mockResolvedValueOnce({ data: settings })
|
||||
put.mockResolvedValueOnce({ data: settings })
|
||||
|
||||
await expect(getOllamaCloudUsageSettings()).resolves.toEqual(settings)
|
||||
await expect(updateOllamaCloudUsageSettings(settings)).resolves.toEqual(settings)
|
||||
expect(get).toHaveBeenCalledWith('/admin/accounts/ollama-cloud-usage/settings')
|
||||
expect(put).toHaveBeenCalledWith('/admin/accounts/ollama-cloud-usage/settings', settings)
|
||||
})
|
||||
|
||||
it('keeps session configuration write-only and separate from account updates', async () => {
|
||||
get.mockResolvedValueOnce({ data: state })
|
||||
put.mockResolvedValueOnce({ data: state }).mockResolvedValueOnce({ data: state })
|
||||
del.mockResolvedValueOnce({ data: { ...state, configured: false } })
|
||||
post.mockResolvedValueOnce({ data: state })
|
||||
|
||||
await expect(getOllamaCloudUsage(7)).resolves.toEqual(state)
|
||||
await expect(saveOllamaCloudUsageSession(7, 'wos-session=secret')).resolves.toEqual(state)
|
||||
await expect(setOllamaCloudUsageAutoRefresh(7, true)).resolves.toEqual(state)
|
||||
await expect(refreshOllamaCloudUsage(7)).resolves.toEqual(state)
|
||||
await expect(deleteOllamaCloudUsageSession(7)).resolves.toMatchObject({ configured: false })
|
||||
|
||||
expect(put).toHaveBeenNthCalledWith(1, '/admin/accounts/7/ollama-cloud-usage/session', { session: 'wos-session=secret' })
|
||||
expect(put).toHaveBeenNthCalledWith(2, '/admin/accounts/7/ollama-cloud-usage/auto-refresh', { enabled: true })
|
||||
expect(post).toHaveBeenCalledWith('/admin/accounts/7/ollama-cloud-usage/refresh')
|
||||
expect(del).toHaveBeenCalledWith('/admin/accounts/7/ollama-cloud-usage/session')
|
||||
})
|
||||
})
|
||||
@@ -22,7 +22,9 @@ import type {
|
||||
CheckMixedChannelRequest,
|
||||
CheckMixedChannelResponse,
|
||||
UpstreamBillingProbeResult,
|
||||
UpstreamBillingProbeSettings
|
||||
UpstreamBillingProbeSettings,
|
||||
OllamaCloudUsageSettings,
|
||||
OllamaCloudUsageState
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
@@ -882,6 +884,50 @@ export async function probeUpstreamBillingBatch(accountIds: number[]): Promise<U
|
||||
return data.results
|
||||
}
|
||||
|
||||
export async function getOllamaCloudUsageSettings(): Promise<OllamaCloudUsageSettings> {
|
||||
const { data } = await apiClient.get<OllamaCloudUsageSettings>('/admin/accounts/ollama-cloud-usage/settings')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateOllamaCloudUsageSettings(
|
||||
settings: OllamaCloudUsageSettings
|
||||
): Promise<OllamaCloudUsageSettings> {
|
||||
const { data } = await apiClient.put<OllamaCloudUsageSettings>(
|
||||
'/admin/accounts/ollama-cloud-usage/settings',
|
||||
settings
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getOllamaCloudUsage(id: number): Promise<OllamaCloudUsageState> {
|
||||
const { data } = await apiClient.get<OllamaCloudUsageState>(`/admin/accounts/${id}/ollama-cloud-usage`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function saveOllamaCloudUsageSession(id: number, session: string): Promise<OllamaCloudUsageState> {
|
||||
const { data } = await apiClient.put<OllamaCloudUsageState>(`/admin/accounts/${id}/ollama-cloud-usage/session`, {
|
||||
session
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteOllamaCloudUsageSession(id: number): Promise<OllamaCloudUsageState> {
|
||||
const { data } = await apiClient.delete<OllamaCloudUsageState>(`/admin/accounts/${id}/ollama-cloud-usage/session`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function setOllamaCloudUsageAutoRefresh(id: number, enabled: boolean): Promise<OllamaCloudUsageState> {
|
||||
const { data } = await apiClient.put<OllamaCloudUsageState>(`/admin/accounts/${id}/ollama-cloud-usage/auto-refresh`, {
|
||||
enabled
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function refreshOllamaCloudUsage(id: number): Promise<OllamaCloudUsageState> {
|
||||
const { data } = await apiClient.post<OllamaCloudUsageState>(`/admin/accounts/${id}/ollama-cloud-usage/refresh`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const accountsAPI = {
|
||||
list,
|
||||
listWithEtag,
|
||||
@@ -933,7 +979,14 @@ export const accountsAPI = {
|
||||
updateUpstreamBillingProbeSettings,
|
||||
setUpstreamBillingProbeEnabled,
|
||||
probeUpstreamBilling,
|
||||
probeUpstreamBillingBatch
|
||||
probeUpstreamBillingBatch,
|
||||
getOllamaCloudUsageSettings,
|
||||
updateOllamaCloudUsageSettings,
|
||||
getOllamaCloudUsage,
|
||||
saveOllamaCloudUsageSession,
|
||||
deleteOllamaCloudUsageSession,
|
||||
setOllamaCloudUsageAutoRefresh,
|
||||
refreshOllamaCloudUsage
|
||||
}
|
||||
|
||||
export default accountsAPI
|
||||
|
||||
@@ -552,6 +552,10 @@
|
||||
<AccountQuotaInfo v-if="account.platform === 'gemini'" :account="account" />
|
||||
<!-- Key/Bedrock accounts: show today stats + optional quota bars -->
|
||||
<div v-else class="space-y-1">
|
||||
<OllamaCloudUsageCell
|
||||
v-if="account.ollama_cloud_usage?.eligible"
|
||||
:account="account"
|
||||
/>
|
||||
<!-- Today stats row (requests, tokens, cost, user_cost) -->
|
||||
<div
|
||||
v-if="todayStats"
|
||||
@@ -609,7 +613,10 @@
|
||||
/>
|
||||
|
||||
<!-- No data at all -->
|
||||
<div v-if="!todayStats && !todayStatsLoading && !hasApiKeyQuota" class="text-xs text-gray-400">-</div>
|
||||
<div
|
||||
v-if="!todayStats && !todayStatsLoading && !hasApiKeyQuota && !account.ollama_cloud_usage?.eligible"
|
||||
class="text-xs text-gray-400"
|
||||
>-</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -627,6 +634,7 @@ import UsageProgressBar from './UsageProgressBar.vue'
|
||||
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
||||
import OpenAIQuotaResetCell from './OpenAIQuotaResetCell.vue'
|
||||
import GrokQuotaProbeCell from './GrokQuotaProbeCell.vue'
|
||||
import OllamaCloudUsageCell from './OllamaCloudUsageCell.vue'
|
||||
|
||||
// Module-level cache shared across all AccountUsageCell instances
|
||||
const _usageCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
|
||||
|
||||
@@ -1635,6 +1635,12 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<OllamaCloudUsageSettings
|
||||
v-if="account?.ollama_cloud_usage?.eligible"
|
||||
:account="account"
|
||||
@updated="handleOllamaCloudUsageUpdated"
|
||||
/>
|
||||
|
||||
<!-- Anthropic API Key 自动透传开关 -->
|
||||
<div
|
||||
v-if="account?.platform === 'anthropic' && account?.type === 'apikey'"
|
||||
@@ -2598,7 +2604,8 @@ import type {
|
||||
CheckMixedChannelResponse,
|
||||
OpenAICompactMode,
|
||||
OpenAIResponsesMode,
|
||||
OpenAIEndpointCapability
|
||||
OpenAIEndpointCapability,
|
||||
OllamaCloudUsageState
|
||||
} from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
@@ -2612,6 +2619,7 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.
|
||||
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
|
||||
import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue'
|
||||
import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue'
|
||||
import OllamaCloudUsageSettings from '@/components/account/OllamaCloudUsageSettings.vue'
|
||||
import {
|
||||
applyAntigravityProjectID,
|
||||
applyHeaderOverride,
|
||||
@@ -2669,6 +2677,10 @@ const authStore = useAuthStore()
|
||||
// 故隐藏代理选择器。
|
||||
const isSparkShadow = computed(() => props.account?.parent_account_id != null)
|
||||
|
||||
const handleOllamaCloudUsageUpdated = (state: OllamaCloudUsageState) => {
|
||||
if (props.account) emit('updated', { ...props.account, ollama_cloud_usage: state })
|
||||
}
|
||||
|
||||
// Platform-specific hint for Base URL
|
||||
const baseUrlHint = computed(() => {
|
||||
if (!props.account) return t('admin.accounts.baseUrlHint')
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="state?.eligible"
|
||||
class="min-w-0 max-w-full space-y-1"
|
||||
data-testid="ollama-cloud-usage-cell"
|
||||
>
|
||||
<UsageProgressBar
|
||||
v-if="snapshot?.data?.five_hour"
|
||||
label="5h"
|
||||
:utilization="snapshot.data.five_hour.used_percent"
|
||||
:resets-at="snapshot.data.five_hour.reset_at"
|
||||
color="indigo"
|
||||
data-testid="ollama-cloud-five-hour"
|
||||
/>
|
||||
<UsageProgressBar
|
||||
v-if="snapshot?.data?.seven_day"
|
||||
label="7d"
|
||||
:utilization="snapshot.data.seven_day.used_percent"
|
||||
:resets-at="snapshot.data.seven_day.reset_at"
|
||||
color="emerald"
|
||||
data-testid="ollama-cloud-seven-day"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Account } from '@/types'
|
||||
import UsageProgressBar from './UsageProgressBar.vue'
|
||||
|
||||
const props = defineProps<{ account: Account }>()
|
||||
const state = computed(() => props.account.ollama_cloud_usage)
|
||||
const snapshot = computed(() => state.value?.snapshot)
|
||||
</script>
|
||||
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<section v-if="state?.eligible" class="space-y-4 border-t border-gray-200 pt-4 dark:border-dark-600" data-testid="ollama-cloud-usage-settings">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.accounts.ollamaCloud.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.ollamaCloud.sessionSecurityHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="whitespace-nowrap rounded px-2 py-1 text-xs font-medium"
|
||||
:class="state.configured
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300'"
|
||||
>
|
||||
{{ state.configured ? t('admin.accounts.ollamaCloud.configured') : t('admin.accounts.ollamaCloud.notConfigured') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex h-20 items-center justify-center text-gray-400">
|
||||
<Icon name="refresh" size="sm" class="animate-spin" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="!state.encryption_key_configured" class="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-800/50 dark:bg-amber-900/20 dark:text-amber-200">
|
||||
{{ t('admin.accounts.ollamaCloud.encryptionKeyRequired') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="snapshot"
|
||||
class="border-y border-gray-100 py-3 dark:border-dark-700"
|
||||
data-testid="ollama-cloud-usage-details"
|
||||
>
|
||||
<div class="grid grid-cols-[minmax(4rem,auto)_minmax(0,1fr)] gap-x-3 gap-y-1.5 text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.plan') }}</span>
|
||||
<span class="break-words text-gray-900 dark:text-white">{{ snapshot.data?.plan || '-' }}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.fiveHour') }}</span>
|
||||
<span class="break-words text-gray-900 dark:text-white">{{ windowSummary(snapshot.data?.five_hour) }}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.sevenDay') }}</span>
|
||||
<span class="break-words text-gray-900 dark:text-white">{{ windowSummary(snapshot.data?.seven_day) }}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.balance') }}</span>
|
||||
<span class="break-words text-gray-900 dark:text-white">{{ snapshot.data?.balance || '-' }}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.models') }}</span>
|
||||
<span class="break-words text-gray-900 dark:text-white">{{ modelSummary }}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.status') }}</span>
|
||||
<span class="break-words font-medium text-gray-900 dark:text-white">{{ statusLabel }}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.updatedAt') }}</span>
|
||||
<span class="break-words text-gray-900 dark:text-white">{{ formatDate(snapshot.fetched_at || snapshot.last_attempt_at) }}</span>
|
||||
</div>
|
||||
<p v-if="snapshot.last_error" class="mt-2 break-words border-t border-gray-100 pt-2 text-xs text-amber-700 dark:border-dark-700 dark:text-amber-300">
|
||||
{{ t(`admin.accounts.ollamaCloud.errors.${snapshot.last_error}`, snapshot.last_error) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label" for="ollama-cloud-session">{{ t('admin.accounts.ollamaCloud.sessionLabel') }}</label>
|
||||
<textarea
|
||||
id="ollama-cloud-session"
|
||||
v-model="session"
|
||||
rows="3"
|
||||
class="input font-mono text-xs"
|
||||
autocomplete="new-password"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
:placeholder="t('admin.accounts.ollamaCloud.sessionPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.ollamaCloud.writeOnlyHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="saving || !session.trim() || !state.encryption_key_configured"
|
||||
data-testid="ollama-cloud-session-save"
|
||||
@click="saveSession"
|
||||
>
|
||||
<Icon name="check" size="xs" class="mr-1.5" />
|
||||
{{ t('common.save') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="state.configured"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm text-red-600 dark:text-red-400"
|
||||
:disabled="saving"
|
||||
data-testid="ollama-cloud-session-delete"
|
||||
@click="showDeleteConfirm = true"
|
||||
>
|
||||
<Icon name="trash" size="xs" class="mr-1.5" />
|
||||
{{ t('admin.accounts.ollamaCloud.deleteSession') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="state.configured"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="refreshing"
|
||||
data-testid="ollama-cloud-refresh"
|
||||
@click="refreshUsage"
|
||||
>
|
||||
<Icon name="refresh" size="xs" class="mr-1.5" :class="{ 'animate-spin': refreshing }" />
|
||||
{{ t('admin.accounts.ollamaCloud.refreshNow') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="state.configured" class="flex items-center justify-between gap-4 border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.accounts.ollamaCloud.autoRefresh') }}
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.ollamaCloud.autoRefreshHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
:model-value="state.auto_refresh_enabled"
|
||||
:disabled="saving"
|
||||
data-testid="ollama-cloud-auto-refresh"
|
||||
@update:model-value="setAutoRefresh"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="showDeleteConfirm"
|
||||
:title="t('admin.accounts.ollamaCloud.deleteSession')"
|
||||
:message="t('admin.accounts.ollamaCloud.deleteConfirm')"
|
||||
:confirm-text="t('common.delete')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:danger="true"
|
||||
@confirm="deleteSession"
|
||||
@cancel="showDeleteConfirm = false"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { extractApiErrorMessage, extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import type { Account, OllamaCloudUsageState, OllamaCloudUsageWindow } from '@/types'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Toggle from '@/components/common/Toggle.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const props = defineProps<{ account: Account }>()
|
||||
const emit = defineEmits<{ updated: [state: OllamaCloudUsageState] }>()
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const state = ref<OllamaCloudUsageState | null>(props.account.ollama_cloud_usage ?? null)
|
||||
const session = ref('')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const showDeleteConfirm = ref(false)
|
||||
const snapshot = computed(() => state.value?.snapshot)
|
||||
const statusLabel = computed(() => {
|
||||
if (!snapshot.value) return t('admin.accounts.ollamaCloud.notRefreshed')
|
||||
if (snapshot.value.status === 'unauthorized') return t('admin.accounts.ollamaCloud.unauthorized')
|
||||
if (snapshot.value.status === 'failed') return t('admin.accounts.ollamaCloud.failed')
|
||||
return t('admin.accounts.ollamaCloud.ok')
|
||||
})
|
||||
const modelSummary = computed(() => snapshot.value?.data?.models?.map(model => {
|
||||
const window = model.window === 'five_hour'
|
||||
? t('admin.accounts.ollamaCloud.fiveHourShort')
|
||||
: t('admin.accounts.ollamaCloud.sevenDayShort')
|
||||
return `${window} ${model.model}: ${model.requests}`
|
||||
}).join(', ') || '-')
|
||||
|
||||
const formatPercent = (value?: number) => typeof value === 'number' && Number.isFinite(value)
|
||||
? `${value.toFixed(value % 1 ? 1 : 0)}%`
|
||||
: '-'
|
||||
const formatDate = (value?: string) => {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
|
||||
}
|
||||
const windowSummary = (window?: OllamaCloudUsageWindow) => {
|
||||
if (!window) return '-'
|
||||
const reset = window.reset_at ? formatDate(window.reset_at) : window.reset_text
|
||||
return reset
|
||||
? t('admin.accounts.ollamaCloud.windowWithReset', { percent: formatPercent(window.used_percent), reset })
|
||||
: formatPercent(window.used_percent)
|
||||
}
|
||||
|
||||
const applyState = (next: OllamaCloudUsageState) => {
|
||||
state.value = next
|
||||
emit('updated', next)
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
applyState(await adminAPI.accounts.getOllamaCloudUsage(props.account.id))
|
||||
} catch (error) {
|
||||
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.ollamaCloud.loadFailed')))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveSession = async () => {
|
||||
if (!session.value.trim()) return
|
||||
saving.value = true
|
||||
try {
|
||||
applyState(await adminAPI.accounts.saveOllamaCloudUsageSession(props.account.id, session.value))
|
||||
session.value = ''
|
||||
appStore.showSuccess(t('admin.accounts.ollamaCloud.sessionSaved'))
|
||||
} catch (error) {
|
||||
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.ollamaCloud.sessionSaveFailed')))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSession = async () => {
|
||||
saving.value = true
|
||||
showDeleteConfirm.value = false
|
||||
try {
|
||||
applyState(await adminAPI.accounts.deleteOllamaCloudUsageSession(props.account.id))
|
||||
session.value = ''
|
||||
appStore.showSuccess(t('admin.accounts.ollamaCloud.sessionDeleted'))
|
||||
} catch (error) {
|
||||
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.ollamaCloud.sessionDeleteFailed')))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setAutoRefresh = async (enabled: boolean) => {
|
||||
saving.value = true
|
||||
try {
|
||||
applyState(await adminAPI.accounts.setOllamaCloudUsageAutoRefresh(props.account.id, enabled))
|
||||
} catch (error) {
|
||||
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.ollamaCloud.autoRefreshFailed')))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshUsage = async () => {
|
||||
refreshing.value = true
|
||||
try {
|
||||
applyState(await adminAPI.accounts.refreshOllamaCloudUsage(props.account.id))
|
||||
appStore.showSuccess(t('admin.accounts.ollamaCloud.refreshSuccess'))
|
||||
} catch (error) {
|
||||
appStore.showError(extractI18nErrorMessage(
|
||||
error,
|
||||
t,
|
||||
'admin.accounts.ollamaCloud.errors',
|
||||
t('admin.accounts.ollamaCloud.refreshFailed')
|
||||
))
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.account.id, () => {
|
||||
state.value = props.account.ollama_cloud_usage ?? null
|
||||
session.value = ''
|
||||
if (!state.value) void load()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!state.value) void load()
|
||||
})
|
||||
</script>
|
||||
@@ -72,6 +72,47 @@ describe('AccountUsageCell', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders eligible Ollama Cloud state inside the unified usage cell', () => {
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 9001,
|
||||
platform: 'openai',
|
||||
type: 'apikey',
|
||||
ollama_cloud_usage: {
|
||||
account_id: 9001,
|
||||
eligible: true,
|
||||
configured: true,
|
||||
auto_refresh_enabled: true,
|
||||
encryption_key_configured: true,
|
||||
snapshot: {
|
||||
status: 'ok',
|
||||
last_attempt_at: '2026-07-23T00:00:00Z',
|
||||
next_refresh_at: '2026-07-23T01:00:00Z',
|
||||
data: {
|
||||
five_hour: { used_percent: 12 },
|
||||
seven_day: { used_percent: 34 }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
OllamaCloudUsageCell: {
|
||||
props: ['account'],
|
||||
template: '<div data-test="embedded-ollama">{{ account.ollama_cloud_usage.snapshot.data.five_hour.used_percent }}</div>'
|
||||
},
|
||||
UsageProgressBar: true,
|
||||
AccountQuotaInfo: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-test="embedded-ollama"]').text()).toBe('12')
|
||||
expect(getUsage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Antigravity 图片用量会聚合新旧 image 模型', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
antigravity_quota: {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import OllamaCloudUsageCell from '../OllamaCloudUsageCell.vue'
|
||||
import UsageProgressBar from '../UsageProgressBar.vue'
|
||||
import type { Account, OllamaCloudUsageState } from '@/types'
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({ t: (key: string) => key })
|
||||
}
|
||||
})
|
||||
|
||||
const usageState = (): OllamaCloudUsageState => ({
|
||||
account_id: 7,
|
||||
eligible: true,
|
||||
configured: true,
|
||||
auto_refresh_enabled: false,
|
||||
encryption_key_configured: true,
|
||||
snapshot: {
|
||||
status: 'ok',
|
||||
fetched_at: '2026-07-22T12:00:00Z',
|
||||
last_attempt_at: '2026-07-22T12:00:00Z',
|
||||
next_refresh_at: '2026-07-22T13:00:00Z',
|
||||
data: {
|
||||
plan: 'max',
|
||||
five_hour: { used_percent: 5.6, reset_at: '2026-07-23T03:00:00Z' },
|
||||
seven_day: { used_percent: 14.2, reset_at: '2026-07-29T00:00:00Z' },
|
||||
balance: '$0',
|
||||
models: [
|
||||
{ model: 'gpt-oss:120b-cloud', window: 'five_hour', requests: 2 },
|
||||
{ model: 'gpt-oss:120b-cloud', window: 'seven_day', requests: 12 }
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const account = (state = usageState()): Account => ({
|
||||
id: 7,
|
||||
name: 'ollama',
|
||||
platform: 'openai',
|
||||
type: 'apikey',
|
||||
ollama_cloud_usage: state,
|
||||
proxy_id: null,
|
||||
concurrency: 1,
|
||||
priority: 1,
|
||||
status: 'active',
|
||||
error_message: null,
|
||||
last_used_at: null,
|
||||
expires_at: null,
|
||||
auto_pause_on_expired: false,
|
||||
created_at: '2026-07-22T00:00:00Z',
|
||||
updated_at: '2026-07-22T00:00:00Z',
|
||||
schedulable: true,
|
||||
rate_limited_at: null,
|
||||
rate_limit_reset_at: null,
|
||||
overload_until: null,
|
||||
temp_unschedulable_until: null,
|
||||
temp_unschedulable_reason: null,
|
||||
session_window_start: null,
|
||||
session_window_end: null,
|
||||
session_window_status: null
|
||||
})
|
||||
|
||||
describe('OllamaCloudUsageCell', () => {
|
||||
it('renders only native 5h and 7d windows in a shrinkable mobile-safe cell', () => {
|
||||
const wrapper = mount(OllamaCloudUsageCell, { props: { account: account() } })
|
||||
const cell = wrapper.get('[data-testid="ollama-cloud-usage-cell"]')
|
||||
expect(cell.classes()).toEqual(expect.arrayContaining(['min-w-0', 'max-w-full']))
|
||||
expect(cell.classes()).not.toContain('min-w-[12rem]')
|
||||
|
||||
const bars = wrapper.findAllComponents(UsageProgressBar)
|
||||
expect(bars).toHaveLength(2)
|
||||
expect(bars[0].props()).toMatchObject({
|
||||
label: '5h',
|
||||
utilization: 5.6,
|
||||
resetsAt: '2026-07-23T03:00:00Z'
|
||||
})
|
||||
expect(bars[1].props()).toMatchObject({
|
||||
label: '7d',
|
||||
utilization: 14.2,
|
||||
resetsAt: '2026-07-29T00:00:00Z'
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="ollama-cloud-usage-details"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="ollama-cloud-usage-refresh"]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
expect(wrapper.text()).not.toContain('max')
|
||||
expect(wrapper.text()).not.toContain('$0')
|
||||
expect(wrapper.text()).not.toContain('gpt-oss:120b-cloud')
|
||||
})
|
||||
|
||||
it('reacts to an account snapshot update without a list-cell refresh action', async () => {
|
||||
const wrapper = mount(OllamaCloudUsageCell, { props: { account: account() } })
|
||||
const next = usageState()
|
||||
next.snapshot!.data!.five_hour!.used_percent = 43
|
||||
|
||||
await wrapper.setProps({ account: account(next) })
|
||||
|
||||
expect(wrapper.findAllComponents(UsageProgressBar)[0].props('utilization')).toBe(43)
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import OllamaCloudUsageSettings from '../OllamaCloudUsageSettings.vue'
|
||||
import type { Account, OllamaCloudUsageState } from '@/types'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getOllamaCloudUsage: vi.fn(),
|
||||
saveOllamaCloudUsageSession: vi.fn(),
|
||||
deleteOllamaCloudUsageSession: vi.fn(),
|
||||
setOllamaCloudUsageAutoRefresh: vi.fn(),
|
||||
refreshOllamaCloudUsage: vi.fn()
|
||||
}))
|
||||
const notifications = vi.hoisted(() => ({ showSuccess: vi.fn(), showError: vi.fn() }))
|
||||
|
||||
vi.mock('@/api/admin', () => ({ adminAPI: { accounts: api } }))
|
||||
vi.mock('@/stores/app', () => ({ useAppStore: () => notifications }))
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
if (key === 'admin.accounts.ollamaCloud.errors.OLLAMA_CLOUD_USAGE_REFRESH_RATE_LIMITED') {
|
||||
return `retry in ${params?.retry_after_seconds} seconds`
|
||||
}
|
||||
if (key === 'admin.accounts.ollamaCloud.fiveHourShort') return '5h'
|
||||
if (key === 'admin.accounts.ollamaCloud.sevenDayShort') return '7d'
|
||||
return key
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const state = (overrides: Partial<OllamaCloudUsageState> = {}): OllamaCloudUsageState => ({
|
||||
account_id: 7,
|
||||
eligible: true,
|
||||
configured: false,
|
||||
auto_refresh_enabled: false,
|
||||
encryption_key_configured: true,
|
||||
...overrides
|
||||
})
|
||||
|
||||
const detailedState = (plan = 'max'): OllamaCloudUsageState => state({
|
||||
configured: true,
|
||||
snapshot: {
|
||||
status: 'ok',
|
||||
fetched_at: '2026-07-22T12:00:00Z',
|
||||
last_attempt_at: '2026-07-22T12:00:00Z',
|
||||
next_refresh_at: '2026-07-22T13:00:00Z',
|
||||
data: {
|
||||
plan,
|
||||
five_hour: { used_percent: 5.6, reset_at: '2026-07-23T03:00:00Z' },
|
||||
seven_day: { used_percent: 14.2, reset_at: '2026-07-29T00:00:00Z' },
|
||||
balance: '$0',
|
||||
models: [
|
||||
{ model: 'gpt-oss:120b-cloud', window: 'five_hour', requests: 2 },
|
||||
{ model: 'gpt-oss:120b-cloud', window: 'seven_day', requests: 12 }
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const account = (usage: OllamaCloudUsageState = state()): Account => ({
|
||||
id: 7,
|
||||
name: 'ollama',
|
||||
platform: 'anthropic',
|
||||
type: 'apikey',
|
||||
ollama_cloud_usage: usage,
|
||||
proxy_id: null,
|
||||
concurrency: 1,
|
||||
priority: 1,
|
||||
status: 'active',
|
||||
error_message: null,
|
||||
last_used_at: null,
|
||||
expires_at: null,
|
||||
auto_pause_on_expired: false,
|
||||
created_at: '2026-07-22T00:00:00Z',
|
||||
updated_at: '2026-07-22T00:00:00Z',
|
||||
schedulable: true,
|
||||
rate_limited_at: null,
|
||||
rate_limit_reset_at: null,
|
||||
overload_until: null,
|
||||
temp_unschedulable_until: null,
|
||||
temp_unschedulable_reason: null,
|
||||
session_window_start: null,
|
||||
session_window_end: null,
|
||||
session_window_status: null
|
||||
})
|
||||
|
||||
describe('OllamaCloudUsageSettings', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(api).forEach(mock => mock.mockReset())
|
||||
notifications.showSuccess.mockReset()
|
||||
notifications.showError.mockReset()
|
||||
})
|
||||
|
||||
it('uses the existing account state without an immediate duplicate GET', async () => {
|
||||
api.saveOllamaCloudUsageSession.mockResolvedValueOnce(state({ configured: true }))
|
||||
const wrapper = mount(OllamaCloudUsageSettings, { props: { account: account() } })
|
||||
await flushPromises()
|
||||
|
||||
const input = wrapper.get('#ollama-cloud-session')
|
||||
await input.setValue('wos-session=browser-secret')
|
||||
await wrapper.get('[data-testid="ollama-cloud-session-save"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.getOllamaCloudUsage).not.toHaveBeenCalled()
|
||||
expect(api.saveOllamaCloudUsageSession).toHaveBeenCalledWith(7, 'wos-session=browser-secret')
|
||||
expect((input.element as HTMLTextAreaElement).value).toBe('')
|
||||
expect(wrapper.text()).not.toContain('browser-secret')
|
||||
expect(wrapper.emitted('updated')?.at(-1)?.[0]).toMatchObject({ configured: true })
|
||||
})
|
||||
|
||||
it('fails closed when the persistent encryption key is unavailable', async () => {
|
||||
const wrapper = mount(OllamaCloudUsageSettings, {
|
||||
props: { account: account(state({ encryption_key_configured: false })) }
|
||||
})
|
||||
await flushPromises()
|
||||
await wrapper.get('#ollama-cloud-session').setValue('wos-session=secret')
|
||||
|
||||
expect(wrapper.get('[data-testid="ollama-cloud-session-save"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).toContain('admin.accounts.ollamaCloud.encryptionKeyRequired')
|
||||
expect(api.saveOllamaCloudUsageSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates the account-level automatic refresh switch through its dedicated endpoint', async () => {
|
||||
api.setOllamaCloudUsageAutoRefresh.mockResolvedValueOnce(state({ configured: true, auto_refresh_enabled: true }))
|
||||
const wrapper = mount(OllamaCloudUsageSettings, {
|
||||
props: { account: account(state({ configured: true })) }
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-testid="ollama-cloud-auto-refresh"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.setOllamaCloudUsageAutoRefresh).toHaveBeenCalledWith(7, true)
|
||||
})
|
||||
|
||||
it('keeps plan, balance, model, status, and manual refresh details in the edit settings', async () => {
|
||||
api.refreshOllamaCloudUsage.mockResolvedValueOnce(detailedState('pro'))
|
||||
const wrapper = mount(OllamaCloudUsageSettings, {
|
||||
props: { account: account(detailedState()) }
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const details = wrapper.get('[data-testid="ollama-cloud-usage-details"]')
|
||||
expect(details.text()).toContain('max')
|
||||
expect(details.text()).toContain('$0')
|
||||
expect(details.text()).toContain('5h gpt-oss:120b-cloud: 2')
|
||||
expect(details.text()).toContain('7d gpt-oss:120b-cloud: 12')
|
||||
expect(details.text()).toContain('admin.accounts.ollamaCloud.ok')
|
||||
|
||||
await wrapper.get('[data-testid="ollama-cloud-refresh"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.refreshOllamaCloudUsage).toHaveBeenCalledWith(7)
|
||||
expect(wrapper.get('[data-testid="ollama-cloud-usage-details"]').text()).toContain('pro')
|
||||
expect(notifications.showSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the structured manual refresh limit from the edit settings', async () => {
|
||||
api.refreshOllamaCloudUsage.mockRejectedValueOnce({
|
||||
status: 429,
|
||||
reason: 'OLLAMA_CLOUD_USAGE_REFRESH_RATE_LIMITED',
|
||||
metadata: { retry_after_seconds: '18' }
|
||||
})
|
||||
const wrapper = mount(OllamaCloudUsageSettings, {
|
||||
props: { account: account(detailedState()) }
|
||||
})
|
||||
|
||||
await wrapper.get('[data-testid="ollama-cloud-refresh"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(notifications.showError).toHaveBeenCalledWith('retry in 18 seconds')
|
||||
})
|
||||
})
|
||||
@@ -70,12 +70,13 @@
|
||||
<div
|
||||
v-for="column in dataColumns"
|
||||
:key="column.key"
|
||||
class="flex items-start justify-between gap-4"
|
||||
:data-field="column.key"
|
||||
class="flex min-w-0 items-start justify-between gap-4"
|
||||
>
|
||||
<span class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400">
|
||||
{{ column.label }}
|
||||
</span>
|
||||
<div class="text-right text-sm text-gray-900 dark:text-gray-100">
|
||||
<div class="min-w-0 max-w-full text-right text-sm text-gray-900 dark:text-gray-100">
|
||||
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded">
|
||||
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
|
||||
</slot>
|
||||
|
||||
@@ -309,6 +309,35 @@ describe('DataTable', () => {
|
||||
expect(wrapper.emitted('selectionChange')?.at(-1)?.[0]).toEqual([99, 2])
|
||||
})
|
||||
|
||||
it('keeps the single usage field shrinkable in a 320px mobile card', () => {
|
||||
stubMobileMatchMedia()
|
||||
const viewport = document.createElement('div')
|
||||
viewport.style.width = '320px'
|
||||
document.body.appendChild(viewport)
|
||||
const wrapper = mount(DataTable, {
|
||||
attachTo: viewport,
|
||||
props: {
|
||||
columns: [{ key: 'usage', label: 'Usage' }],
|
||||
data: [{ id: 1, usage: 'snapshot' }],
|
||||
rowKey: 'id'
|
||||
},
|
||||
slots: {
|
||||
'cell-usage': '<div data-test="usage-cell">snapshot</div>'
|
||||
}
|
||||
})
|
||||
|
||||
expect(viewport.style.width).toBe('320px')
|
||||
expect(wrapper.findAll('[data-field="usage"]')).toHaveLength(1)
|
||||
expect(wrapper.find('[data-field="ollama_cloud_usage"]').exists()).toBe(false)
|
||||
const field = wrapper.get('[data-field="usage"]')
|
||||
expect(field.classes()).toContain('min-w-0')
|
||||
expect(field.get('div').classes()).toEqual(expect.arrayContaining(['min-w-0', 'max-w-full']))
|
||||
expect(wrapper.findAll('[data-test="usage-cell"]')).toHaveLength(1)
|
||||
|
||||
wrapper.unmount()
|
||||
viewport.remove()
|
||||
})
|
||||
|
||||
it('offers current-page select all in the mobile card layout', async () => {
|
||||
stubMobileMatchMedia()
|
||||
const wrapper = mount(DataTable, {
|
||||
|
||||
@@ -173,6 +173,55 @@ export default {
|
||||
hint: 'Displayed as "group / base score / sticky bonus". The base score is computed within the current filtered candidate set and includes priority, load, queue depth, error rate, first-token latency, reset window, quota headroom, billing rate, and related factors. The sticky bonus applies only when sticky weighting is enabled for previous_response_id or session_hash. Higher scores are preferred.'
|
||||
},
|
||||
usageWindowsHint: '"5h / 7d" are the upstream account\'s official rolling usage windows (e.g. OpenAI ChatGPT, Claude). They are imposed by the upstream provider on the account itself — not configured by sub2api, and unrelated to the models you map. Usage resets automatically once each window rolls over, and the limit cannot be lifted from within sub2api.',
|
||||
ollamaCloud: {
|
||||
title: 'Ollama Cloud usage',
|
||||
sessionSecurityHint: 'The browser session is encrypted at rest and sent only to the fixed official settings URL.',
|
||||
configured: 'Configured',
|
||||
notConfigured: 'Not configured',
|
||||
notRefreshed: 'Not refreshed',
|
||||
encryptionKeyRequired: 'Set a persistent TOTP_ENCRYPTION_KEY before storing a browser session.',
|
||||
sessionLabel: 'Ollama browser Cookie',
|
||||
sessionPlaceholder: 'wos-session=...; __Secure-authjs.session-token.0=...',
|
||||
writeOnlyHint: 'Write-only. The saved value cannot be viewed and an empty value never replaces it.',
|
||||
deleteSession: 'Delete session',
|
||||
deleteConfirm: 'Delete the stored Ollama browser session and its usage snapshot?',
|
||||
refreshNow: 'Refresh usage',
|
||||
autoRefresh: 'Automatic usage refresh',
|
||||
autoRefreshHint: 'Runs only when the account switch and the global switch are both enabled.',
|
||||
plan: 'Plan',
|
||||
fiveHour: '5 hour',
|
||||
fiveHourShort: '5h',
|
||||
sevenDay: '7 day',
|
||||
sevenDayShort: '7d',
|
||||
balance: 'Balance',
|
||||
models: 'Models',
|
||||
status: 'Status',
|
||||
updatedAt: 'Updated',
|
||||
ok: 'Current',
|
||||
unauthorized: 'Session expired',
|
||||
failed: 'Refresh failed',
|
||||
windowWithReset: '{percent} used, resets {reset}',
|
||||
loadFailed: 'Failed to load Ollama Cloud usage settings',
|
||||
sessionSaved: 'Ollama browser session saved',
|
||||
sessionSaveFailed: 'Failed to save Ollama browser session',
|
||||
sessionDeleted: 'Ollama browser session deleted',
|
||||
sessionDeleteFailed: 'Failed to delete Ollama browser session',
|
||||
autoRefreshFailed: 'Failed to update automatic usage refresh',
|
||||
refreshSuccess: 'Ollama Cloud usage refreshed',
|
||||
refreshFailed: 'Failed to refresh Ollama Cloud usage',
|
||||
errors: {
|
||||
request_failed: 'Request failed',
|
||||
empty_response: 'Empty response',
|
||||
response_host_mismatch: 'Unexpected response host',
|
||||
redirect_blocked: 'Official settings redirected the request',
|
||||
unauthorized: 'Browser session expired',
|
||||
http_error: 'Official settings returned an error',
|
||||
response_read_failed: 'Failed to read the response',
|
||||
response_too_large: 'Settings page exceeded the response limit',
|
||||
invalid_html: 'Settings page format was not recognized',
|
||||
OLLAMA_CLOUD_USAGE_REFRESH_RATE_LIMITED: 'Refresh is limited. Try again in {retry_after_seconds} seconds.'
|
||||
}
|
||||
},
|
||||
upstreamBilling: {
|
||||
trustWarning: 'This rate is declared by the upstream site for the current API key. Sub2API cannot verify that it matches actual charges. The upstream site or an intermediary may return forged, stale, or modified data. Verify it against bills, balance changes, and actual usage.',
|
||||
autoProbe: 'Automatically probe upstream declared rate',
|
||||
|
||||
@@ -333,6 +333,16 @@ export default {
|
||||
saved: 'Upstream rate auto detection settings saved',
|
||||
saveFailed: 'Failed to save upstream rate auto detection settings'
|
||||
},
|
||||
ollamaCloudUsage: {
|
||||
title: 'Ollama Cloud Usage Refresh',
|
||||
description: 'Periodically refresh official Ollama settings-page usage for individually opted-in accounts. Disabled by default.',
|
||||
enabled: 'Enable global automatic refresh',
|
||||
enabledHint: 'Only accounts with a stored browser session and their own automatic refresh switch enabled are refreshed. Manual refresh remains available.',
|
||||
intervalMinutes: 'Refresh interval (minutes)',
|
||||
intervalHint: 'Range: 15–1440 minutes. Failures use bounded exponential backoff.',
|
||||
saved: 'Ollama Cloud usage refresh settings saved',
|
||||
saveFailed: 'Failed to save Ollama Cloud usage refresh settings'
|
||||
},
|
||||
gatewayForwarding: {
|
||||
title: 'Request Forwarding',
|
||||
description: 'Control how requests are forwarded to upstream OAuth accounts',
|
||||
|
||||
@@ -126,6 +126,55 @@ export default {
|
||||
hint: '显示格式为“分组名 / 基础分 / 粘性加分”。基础分按当前筛选条件限定的候选账号计算,包含优先级、负载、排队、错误率、首包延迟、重置窗口、额度余量、计费倍率等因子;粘性加分只在开启粘性加权时用于 previous_response_id 或 session_hash。分数越大越优先。'
|
||||
},
|
||||
usageWindowsHint: '“5h / 7d”是上游账号(如 OpenAI ChatGPT、Claude)官方的滚动用量窗口限制,由上游对账号设定,并非 sub2api 配置,也与你映射的模型无关。窗口滚动到期后用量会自动重置,无法在 sub2api 端解除该限制。',
|
||||
ollamaCloud: {
|
||||
title: 'Ollama Cloud 用量',
|
||||
sessionSecurityHint: '浏览器会话会加密落库,且只发送到固定的 Ollama 官方设置页。',
|
||||
configured: '已配置',
|
||||
notConfigured: '未配置',
|
||||
notRefreshed: '尚未刷新',
|
||||
encryptionKeyRequired: '请先配置持久 TOTP_ENCRYPTION_KEY,再保存浏览器会话。',
|
||||
sessionLabel: 'Ollama 浏览器 Cookie',
|
||||
sessionPlaceholder: 'wos-session=...; __Secure-authjs.session-token.0=...',
|
||||
writeOnlyHint: '仅写入。已保存内容不可查看,留空不会覆盖。',
|
||||
deleteSession: '删除会话',
|
||||
deleteConfirm: '确定删除已保存的 Ollama 浏览器会话及其用量快照?',
|
||||
refreshNow: '刷新用量',
|
||||
autoRefresh: '自动刷新用量',
|
||||
autoRefreshHint: '只有账号开关和全局开关同时启用时才会定时刷新。',
|
||||
plan: '套餐',
|
||||
fiveHour: '5 小时',
|
||||
fiveHourShort: '5h',
|
||||
sevenDay: '7 天',
|
||||
sevenDayShort: '7d',
|
||||
balance: '余额',
|
||||
models: '模型',
|
||||
status: '状态',
|
||||
updatedAt: '更新时间',
|
||||
ok: '正常',
|
||||
unauthorized: '会话已过期',
|
||||
failed: '刷新失败',
|
||||
windowWithReset: '已用 {percent},{reset} 重置',
|
||||
loadFailed: '加载 Ollama Cloud 用量设置失败',
|
||||
sessionSaved: 'Ollama 浏览器会话已保存',
|
||||
sessionSaveFailed: '保存 Ollama 浏览器会话失败',
|
||||
sessionDeleted: 'Ollama 浏览器会话已删除',
|
||||
sessionDeleteFailed: '删除 Ollama 浏览器会话失败',
|
||||
autoRefreshFailed: '更新自动刷新设置失败',
|
||||
refreshSuccess: 'Ollama Cloud 用量已刷新',
|
||||
refreshFailed: '刷新 Ollama Cloud 用量失败',
|
||||
errors: {
|
||||
request_failed: '请求失败',
|
||||
empty_response: '响应为空',
|
||||
response_host_mismatch: '响应主机不符合安全边界',
|
||||
redirect_blocked: '官方设置页发生重定向',
|
||||
unauthorized: '浏览器会话已过期',
|
||||
http_error: '官方设置页返回错误',
|
||||
response_read_failed: '读取响应失败',
|
||||
response_too_large: '设置页超过响应大小限制',
|
||||
invalid_html: '无法识别设置页格式',
|
||||
OLLAMA_CLOUD_USAGE_REFRESH_RATE_LIMITED: '刷新过于频繁,请在 {retry_after_seconds} 秒后重试。'
|
||||
}
|
||||
},
|
||||
upstreamBilling: {
|
||||
trustWarning: '此倍率由上游站点针对当前 API Key 自行声明。Sub2API 无法验证该值是否与实际扣费一致;上游站点或中间代理可能返回伪造、过期或被篡改的数据。请结合账单、余额变化和实际用量自行核验。',
|
||||
autoProbe: '自动探测上游声明倍率',
|
||||
|
||||
@@ -326,6 +326,16 @@ export default {
|
||||
saved: '上游倍率自动探测设置已保存',
|
||||
saveFailed: '保存上游倍率自动探测设置失败'
|
||||
},
|
||||
ollamaCloudUsage: {
|
||||
title: 'Ollama Cloud 用量刷新',
|
||||
description: '定期刷新账号在 Ollama 官方设置页展示的用量;默认关闭。',
|
||||
enabled: '启用全局自动刷新',
|
||||
enabledHint: '仅刷新已保存浏览器会话且账号自身也开启自动刷新的账号;手动刷新不受影响。',
|
||||
intervalMinutes: '刷新周期(分钟)',
|
||||
intervalHint: '范围 15–1440 分钟。失败后按有上限的指数退避重试。',
|
||||
saved: 'Ollama Cloud 用量刷新设置已保存',
|
||||
saveFailed: '保存 Ollama Cloud 用量刷新设置失败'
|
||||
},
|
||||
gatewayForwarding: {
|
||||
title: '请求转发行为',
|
||||
description: '控制请求转发到上游 OAuth 账号时的行为',
|
||||
|
||||
@@ -975,6 +975,53 @@ export interface UpstreamBillingProbeResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type OllamaCloudUsageStatus = 'ok' | 'unauthorized' | 'failed'
|
||||
|
||||
export interface OllamaCloudUsageWindow {
|
||||
used_percent: number
|
||||
reset_at?: string
|
||||
reset_text?: string
|
||||
}
|
||||
|
||||
export interface OllamaCloudUsageModel {
|
||||
model: string
|
||||
window: 'five_hour' | 'seven_day'
|
||||
requests: number
|
||||
}
|
||||
|
||||
export interface OllamaCloudUsageData {
|
||||
plan?: string
|
||||
five_hour?: OllamaCloudUsageWindow
|
||||
seven_day?: OllamaCloudUsageWindow
|
||||
balance?: string
|
||||
models?: OllamaCloudUsageModel[]
|
||||
}
|
||||
|
||||
export interface OllamaCloudUsageSnapshot {
|
||||
status: OllamaCloudUsageStatus
|
||||
data?: OllamaCloudUsageData
|
||||
fetched_at?: string
|
||||
last_attempt_at: string
|
||||
next_refresh_at: string
|
||||
failure_count?: number
|
||||
http_status?: number
|
||||
last_error?: string
|
||||
}
|
||||
|
||||
export interface OllamaCloudUsageState {
|
||||
account_id: number
|
||||
eligible: boolean
|
||||
configured: boolean
|
||||
auto_refresh_enabled: boolean
|
||||
encryption_key_configured: boolean
|
||||
snapshot?: OllamaCloudUsageSnapshot
|
||||
}
|
||||
|
||||
export interface OllamaCloudUsageSettings {
|
||||
enabled: boolean
|
||||
interval_minutes: number
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: number
|
||||
name: string
|
||||
@@ -987,6 +1034,7 @@ export interface Account {
|
||||
// 改为通过 credentials_status.has_<key> 暴露存在性。
|
||||
credentials?: Record<string, unknown>
|
||||
credentials_status?: Record<string, boolean>
|
||||
ollama_cloud_usage?: OllamaCloudUsageState
|
||||
// Extra fields including Codex usage, OpenAI compact capability, and model-level rate limits.
|
||||
extra?: (CodexUsageSnapshot & OpenAICompactState & {
|
||||
model_rate_limits?: Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
|
||||
|
||||
@@ -1407,8 +1407,8 @@ const allColumns = computed(() => {
|
||||
if (!authStore.isSimpleMode) {
|
||||
c.push({ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false })
|
||||
}
|
||||
c.push({ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false })
|
||||
c.push(
|
||||
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
|
||||
{ key: 'proxy', label: t('admin.accounts.columns.proxy'), sortable: false },
|
||||
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
|
||||
{ key: 'scheduler_score', label: t('admin.accounts.columns.schedulerScore'), sortable: false },
|
||||
|
||||
@@ -4189,6 +4189,70 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ollama Cloud Usage Settings -->
|
||||
<div class="card" data-testid="ollama-cloud-usage-global-settings">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.ollamaCloudUsage.title") }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.ollamaCloudUsage.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-5 p-6">
|
||||
<div v-if="ollamaCloudUsageLoading" class="flex items-center gap-2 text-gray-500">
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600"></div>
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.ollamaCloudUsage.enabled") }}
|
||||
</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.ollamaCloudUsage.enabledHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="ollamaCloudUsageForm.enabled"
|
||||
:aria-label="t('admin.settings.ollamaCloudUsage.enabled')"
|
||||
data-testid="ollama-cloud-usage-global-enabled"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="ollamaCloudUsageForm.enabled" class="border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" for="ollama-cloud-usage-interval">
|
||||
{{ t("admin.settings.ollamaCloudUsage.intervalMinutes") }}
|
||||
</label>
|
||||
<input
|
||||
id="ollama-cloud-usage-interval"
|
||||
v-model.number="ollamaCloudUsageForm.interval_minutes"
|
||||
type="number"
|
||||
min="15"
|
||||
max="1440"
|
||||
class="input w-32"
|
||||
data-testid="ollama-cloud-usage-global-interval"
|
||||
@keydown.enter.prevent="saveOllamaCloudUsageSettings"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.ollamaCloudUsage.intervalHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="ollamaCloudUsageSaving"
|
||||
data-testid="ollama-cloud-usage-global-save"
|
||||
@click="saveOllamaCloudUsageSettings"
|
||||
>
|
||||
{{ ollamaCloudUsageSaving ? t("common.saving") : t("common.save") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gateway Scheduling Settings -->
|
||||
<div class="card">
|
||||
<div
|
||||
@@ -7865,6 +7929,13 @@ const upstreamBillingProbeForm = reactive({
|
||||
interval_minutes: 30,
|
||||
});
|
||||
|
||||
const ollamaCloudUsageLoading = ref(true);
|
||||
const ollamaCloudUsageSaving = ref(false);
|
||||
const ollamaCloudUsageForm = reactive({
|
||||
enabled: false,
|
||||
interval_minutes: 60,
|
||||
});
|
||||
|
||||
// Overload Cooldown (529) 状态
|
||||
const overloadCooldownLoading = ref(true);
|
||||
const overloadCooldownSaving = ref(false);
|
||||
@@ -10481,6 +10552,37 @@ async function saveUpstreamBillingProbeSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOllamaCloudUsageSettings() {
|
||||
ollamaCloudUsageLoading.value = true;
|
||||
try {
|
||||
Object.assign(
|
||||
ollamaCloudUsageForm,
|
||||
await adminAPI.accounts.getOllamaCloudUsageSettings(),
|
||||
);
|
||||
} catch (_error: unknown) {
|
||||
// Keep the fail-safe disabled defaults when this optional setting cannot be loaded.
|
||||
} finally {
|
||||
ollamaCloudUsageLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveOllamaCloudUsageSettings() {
|
||||
ollamaCloudUsageSaving.value = true;
|
||||
try {
|
||||
const updated = await adminAPI.accounts.updateOllamaCloudUsageSettings({
|
||||
...ollamaCloudUsageForm,
|
||||
});
|
||||
Object.assign(ollamaCloudUsageForm, updated);
|
||||
appStore.showSuccess(t("admin.settings.ollamaCloudUsage.saved"));
|
||||
} catch (error: unknown) {
|
||||
appStore.showError(
|
||||
extractApiErrorMessage(error, t("admin.settings.ollamaCloudUsage.saveFailed")),
|
||||
);
|
||||
} finally {
|
||||
ollamaCloudUsageSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Overload Cooldown 方法
|
||||
async function loadOverloadCooldownSettings() {
|
||||
overloadCooldownLoading.value = true;
|
||||
@@ -11178,6 +11280,7 @@ onMounted(() => {
|
||||
loadSubscriptionGroups();
|
||||
loadAdminApiKey();
|
||||
loadUpstreamBillingProbeSettings();
|
||||
loadOllamaCloudUsageSettings();
|
||||
loadOverloadCooldownSettings();
|
||||
loadRateLimit429CooldownSettings();
|
||||
loadStreamTimeoutSettings();
|
||||
|
||||
@@ -166,6 +166,16 @@ describe('admin AccountsView usage windows hint', () => {
|
||||
expect(hint.text()).toBe('admin.accounts.usageWindowsHint')
|
||||
})
|
||||
|
||||
it('keeps Ollama Cloud in the single usage column and ignores legacy column preferences', async () => {
|
||||
localStorage.setItem('account-hidden-columns', JSON.stringify(['ollama_cloud_usage']))
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
const columns = wrapper.getComponent(DataTableStub).props('columns') as Array<{ key: string }>
|
||||
expect(columns.filter(column => column.key === 'usage')).toHaveLength(1)
|
||||
expect(columns.some(column => column.key === 'ollama_cloud_usage')).toBe(false)
|
||||
})
|
||||
|
||||
it('renders the upstream billing trust warning next to the declared-rate column', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
@@ -18,6 +18,8 @@ const {
|
||||
getBetaPolicySettings,
|
||||
getUpstreamBillingProbeSettings,
|
||||
updateUpstreamBillingProbeSettings,
|
||||
getOllamaCloudUsageSettings,
|
||||
updateOllamaCloudUsageSettings,
|
||||
getGroups,
|
||||
listProxies,
|
||||
getProviders,
|
||||
@@ -45,6 +47,11 @@ const {
|
||||
interval_minutes: 30,
|
||||
}),
|
||||
updateUpstreamBillingProbeSettings: vi.fn().mockImplementation(async (payload) => payload),
|
||||
getOllamaCloudUsageSettings: vi.fn().mockResolvedValue({
|
||||
enabled: false,
|
||||
interval_minutes: 60,
|
||||
}),
|
||||
updateOllamaCloudUsageSettings: vi.fn().mockImplementation(async (payload) => payload),
|
||||
getGroups: vi.fn(),
|
||||
listProxies: vi.fn(),
|
||||
getProviders: vi.fn(),
|
||||
@@ -77,6 +84,8 @@ vi.mock("@/api", () => ({
|
||||
accounts: {
|
||||
getUpstreamBillingProbeSettings,
|
||||
updateUpstreamBillingProbeSettings,
|
||||
getOllamaCloudUsageSettings,
|
||||
updateOllamaCloudUsageSettings,
|
||||
},
|
||||
groups: {
|
||||
getAll: getGroups,
|
||||
@@ -573,6 +582,8 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
getBetaPolicySettings.mockReset();
|
||||
getUpstreamBillingProbeSettings.mockReset();
|
||||
updateUpstreamBillingProbeSettings.mockReset();
|
||||
getOllamaCloudUsageSettings.mockReset();
|
||||
updateOllamaCloudUsageSettings.mockReset();
|
||||
getGroups.mockReset();
|
||||
listProxies.mockReset();
|
||||
getProviders.mockReset();
|
||||
@@ -633,6 +644,11 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
interval_minutes: 30,
|
||||
});
|
||||
updateUpstreamBillingProbeSettings.mockImplementation(async (payload) => payload);
|
||||
getOllamaCloudUsageSettings.mockResolvedValue({
|
||||
enabled: false,
|
||||
interval_minutes: 60,
|
||||
});
|
||||
updateOllamaCloudUsageSettings.mockImplementation(async (payload) => payload);
|
||||
getGroups.mockResolvedValue([]);
|
||||
listProxies.mockResolvedValue({
|
||||
items: [],
|
||||
@@ -976,6 +992,31 @@ describe("admin SettingsView payment visible method controls", () => {
|
||||
expect(showSuccess).toHaveBeenCalledWith("上游倍率自动探测设置已保存");
|
||||
});
|
||||
|
||||
it("loads fail-safe-off Ollama Cloud usage refresh settings and saves an explicit opt-in", async () => {
|
||||
const wrapper = mountView();
|
||||
|
||||
await flushPromises();
|
||||
await openGatewayTab(wrapper);
|
||||
|
||||
const card = wrapper.get('[data-testid="ollama-cloud-usage-global-settings"]');
|
||||
expect(card.isVisible()).toBe(true);
|
||||
expect(
|
||||
(card.get('[data-testid="ollama-cloud-usage-global-enabled"]').element as HTMLInputElement)
|
||||
.checked,
|
||||
).toBe(false);
|
||||
expect(card.find('[data-testid="ollama-cloud-usage-global-interval"]').exists()).toBe(false);
|
||||
|
||||
await card.get('[data-testid="ollama-cloud-usage-global-enabled"]').setValue(true);
|
||||
await card.get('[data-testid="ollama-cloud-usage-global-interval"]').setValue(90);
|
||||
await card.get('[data-testid="ollama-cloud-usage-global-save"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(updateOllamaCloudUsageSettings).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
interval_minutes: 90,
|
||||
});
|
||||
});
|
||||
|
||||
it("places and explains rate controls for both scheduling modes", async () => {
|
||||
const wrapper = mountView();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user