diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 8bd9ac5b1685..64148fabbd5d 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -114,6 +114,7 @@ func provideCleanup( quotaFlusher *service.UserPlatformQuotaUsageFlusher, upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, + opencodeGoUsage *service.OpenCodeGoUsageService, auditLog *service.AuditLogService, promptAudit *securityaudit.PromptService, ) func() { @@ -350,6 +351,12 @@ func provideCleanup( } return nil }}, + {"OpenCodeGoUsageService", func() error { + if opencodeGoUsage != nil { + opencodeGoUsage.Stop() + } + return nil + }}, } infraSteps := []cleanupStep{ diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index ab9f4530b866..88fdc4a6af9b 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -277,7 +277,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService) upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db) 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) + openCodeGoUsageService := service.ProvideOpenCodeGoUsageService(accountRepository, httpUpstream, 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, openCodeGoUsageService) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) @@ -336,7 +337,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService) channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService) userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService) - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, promptService) application := &Application{ Server: httpServer, PromptAudit: promptService, @@ -406,6 +407,7 @@ func provideCleanup( quotaFlusher *service.UserPlatformQuotaUsageFlusher, upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, + opencodeGoUsage *service.OpenCodeGoUsageService, auditLog *service.AuditLogService, promptAudit *securityaudit.PromptService, ) func() { @@ -641,6 +643,12 @@ func provideCleanup( } return nil }}, + {"OpenCodeGoUsageService", func() error { + if opencodeGoUsage != nil { + opencodeGoUsage.Stop() + } + return nil + }}, } infraSteps := []cleanupStep{ diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index dff3a9129efb..737317c801a7 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -92,6 +92,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { nil, // quotaFlusher nil, // upstreamBillingProbe nil, // ollamaCloudUsage + nil, // opencodeGoUsage nil, // auditLog nil, // promptAudit ) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 30fc6b982882..0e6e1029c8c7 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -64,6 +64,7 @@ type AccountHandler struct { grokImportProber grokImportProber upstreamBillingProbe *service.UpstreamBillingProbeService ollamaCloudUsage *service.OllamaCloudUsageService + opencodeGoUsage *service.OpenCodeGoUsageService } // SetUpstreamBillingProbeService attaches the optional remote billing probe service. @@ -75,6 +76,10 @@ func (h *AccountHandler) SetOllamaCloudUsageService(usage *service.OllamaCloudUs h.ollamaCloudUsage = usage } +func (h *AccountHandler) SetOpenCodeGoUsageService(usage *service.OpenCodeGoUsageService) { + h.opencodeGoUsage = usage +} + // NewAccountHandler creates a new admin account handler func NewAccountHandler( adminService service.AdminService, diff --git a/backend/internal/handler/admin/account_opencode_go_usage.go b/backend/internal/handler/admin/account_opencode_go_usage.go new file mode 100644 index 000000000000..ee19b0129d93 --- /dev/null +++ b/backend/internal/handler/admin/account_opencode_go_usage.go @@ -0,0 +1,121 @@ +package admin + +import ( + "strconv" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +type openCodeGoUsageAutoRefreshRequest struct { + Enabled *bool `json:"enabled" binding:"required"` +} + +func (h *AccountHandler) GetOpenCodeGoUsageSettings(c *gin.Context) { + if h.opencodeGoUsage == nil { + response.ErrorFrom(c, service.ErrOpenCodeGoUsageUnavailable) + return + } + settings, err := h.opencodeGoUsage.GetSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, settings) +} + +func (h *AccountHandler) UpdateOpenCodeGoUsageSettings(c *gin.Context) { + if h.opencodeGoUsage == nil { + response.ErrorFrom(c, service.ErrOpenCodeGoUsageUnavailable) + return + } + var req service.OpenCodeGoUsageSettings + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if err := h.opencodeGoUsage.UpdateSettings(c.Request.Context(), &req); err != nil { + response.ErrorFrom(c, err) + return + } + settings, err := h.opencodeGoUsage.GetSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, settings) +} + +func (h *AccountHandler) GetOpenCodeGoUsage(c *gin.Context) { + if !h.requireOpenCodeGoUsage(c) { + return + } + accountID, ok := openCodeGoUsageAccountID(c) + if !ok { + return + } + state, err := h.opencodeGoUsage.GetState(c.Request.Context(), accountID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, state) +} + +func (h *AccountHandler) SetOpenCodeGoUsageAutoRefresh(c *gin.Context) { + if !h.requireOpenCodeGoUsage(c) { + return + } + accountID, ok := openCodeGoUsageAccountID(c) + if !ok { + return + } + var req openCodeGoUsageAutoRefreshRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + state, err := h.opencodeGoUsage.SetAutoRefresh(c.Request.Context(), accountID, *req.Enabled) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, state) +} + +func (h *AccountHandler) RefreshOpenCodeGoUsage(c *gin.Context) { + if !h.requireOpenCodeGoUsage(c) { + return + } + accountID, ok := openCodeGoUsageAccountID(c) + if !ok { + return + } + state, err := h.opencodeGoUsage.Refresh(c.Request.Context(), accountID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, state) +} + +func (h *AccountHandler) requireOpenCodeGoUsage(c *gin.Context) bool { + if h != nil && h.opencodeGoUsage != nil { + return true + } + response.ErrorFrom(c, service.ErrOpenCodeGoUsageUnavailable) + return false +} + +func openCodeGoUsageAccountID(c *gin.Context) (int64, bool) { + if c == nil { + return 0, false + } + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || accountID <= 0 { + response.BadRequest(c, "Invalid account ID") + return 0, false + } + return accountID, true +} diff --git a/backend/internal/handler/admin/account_opencode_go_usage_test.go b/backend/internal/handler/admin/account_opencode_go_usage_test.go new file mode 100644 index 000000000000..e1ad0015feef --- /dev/null +++ b/backend/internal/handler/admin/account_opencode_go_usage_test.go @@ -0,0 +1,170 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type openCodeGoUsageHandlerTestRepo struct { + service.AccountRepository + account *service.Account + accounts []*service.Account +} + +func (r *openCodeGoUsageHandlerTestRepo) GetByID(_ context.Context, id int64) (*service.Account, error) { + if r.account != nil && r.account.ID == id { + return r.account, nil + } + for _, account := range r.accounts { + if account.ID == id { + return account, nil + } + } + return nil, service.ErrAccountNotFound +} + +func (r *openCodeGoUsageHandlerTestRepo) SetOpenCodeGoUsageAutoRefresh(context.Context, *service.Account, bool) error { + return nil +} +func (r *openCodeGoUsageHandlerTestRepo) UpdateOpenCodeGoUsageSnapshot(context.Context, *service.Account, *service.OpenCodeGoUsageSnapshot) error { + return nil +} +func (r *openCodeGoUsageHandlerTestRepo) ListDueOpenCodeGoUsageAccounts(context.Context, time.Time, int) ([]service.Account, error) { + return nil, nil +} + +func newOpenCodeGoUsageHandlerTestService(t *testing.T) *service.OpenCodeGoUsageService { + t.Helper() + svc := service.NewOpenCodeGoUsageService(nil, nil, nil) + t.Cleanup(svc.Stop) + return svc +} + +func newOpenCodeGoUsageHandlerContext(method, target, body, id string) (*gin.Context, *httptest.ResponseRecorder) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(method, target, bytes.NewBufferString(body)) + request.Header.Set("Content-Type", "application/json") + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = request + if id != "" { + ctx.Params = gin.Params{{Key: "id", Value: id}} + } + return ctx, recorder +} + +func TestOpenCodeGoUsageHandlersValidateRequestsAndDependencies(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newOpenCodeGoUsageHandlerTestService(t) + + t.Run("invalid account id", func(t *testing.T) { + ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodGet, "/admin/accounts/not-an-id/opencode-go-usage", "", "not-an-id") + (&AccountHandler{opencodeGoUsage: svc}).GetOpenCodeGoUsage(ctx) + require.Equal(t, http.StatusBadRequest, recorder.Code) + }) + + t.Run("missing enabled", func(t *testing.T) { + ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodPut, "/admin/accounts/7/opencode-go-usage/auto-refresh", `{}`, "7") + (&AccountHandler{opencodeGoUsage: svc}).SetOpenCodeGoUsageAutoRefresh(ctx) + require.Equal(t, http.StatusBadRequest, recorder.Code) + }) + + t.Run("service unavailable", func(t *testing.T) { + ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodGet, "/admin/accounts/7/opencode-go-usage", "", "7") + (&AccountHandler{}).GetOpenCodeGoUsage(ctx) + require.Equal(t, http.StatusServiceUnavailable, recorder.Code) + require.Contains(t, recorder.Body.String(), "OPENCODE_GO_USAGE_UNAVAILABLE") + }) +} + +func TestGetOpenCodeGoUsageSettingsHandlerSuccess(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, recorder := newOpenCodeGoUsageHandlerContext(http.MethodGet, "/admin/accounts/opencode-go-usage/settings", "", "") + handler := &AccountHandler{opencodeGoUsage: newOpenCodeGoUsageHandlerTestService(t)} + + handler.GetOpenCodeGoUsageSettings(ctx) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Contains(t, recorder.Body.String(), `"enabled":false`) + require.Contains(t, recorder.Body.String(), `"interval_minutes":15`) +} + +func TestOpenCodeGoUsageStateEmbeddedInListAndDetail(t *testing.T) { + gin.SetMode(gin.TestMode) + now := time.Now().UTC() + account := &service.Account{ + ID: 7, Name: "opencode", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://opencode.ai/zen/go/v1", "api_key": "test-key"}, + Extra: map[string]any{ + service.OpenCodeGoUsageAutoRefreshExtraKey: true, + service.OpenCodeGoUsageSnapshotExtraKey: &service.OpenCodeGoUsageSnapshot{ + Status: service.OpenCodeGoUsageStatusOK, Data: &service.OpenCodeGoUsageData{ + Rolling: service.OpenCodeGoUsageWindow{Status: "ok", Percent: 6}, + }, + LastAttemptAt: now, NextRefreshAt: now.Add(time.Hour), + }, + }, + Status: service.StatusActive, + } + repo := &openCodeGoUsageHandlerTestRepo{account: account} + adminService := newStubAdminService() + adminService.accounts = []service.Account{*account} + adminService.getAccountResult = account + usageService := service.NewOpenCodeGoUsageService(repo, nil, nil) + t.Cleanup(usageService.Stop) + handler := NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + handler.SetOpenCodeGoUsageService(usageService) + router := gin.New() + router.GET("/accounts", handler.List) + router.GET("/accounts/:id", handler.GetByID) + router.GET("/accounts/:id/opencode-go-usage", handler.GetOpenCodeGoUsage) + + listRecorder := httptest.NewRecorder() + router.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, "/accounts?page=1&page_size=20", nil)) + require.Equal(t, http.StatusOK, listRecorder.Code) + var listPayload struct { + Data struct { + Items []struct { + OpenCodeGoUsage *service.OpenCodeGoUsageState `json:"opencode_go_usage"` + } `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(listRecorder.Body.Bytes(), &listPayload)) + require.Len(t, listPayload.Data.Items, 1) + require.NotNil(t, listPayload.Data.Items[0].OpenCodeGoUsage) + require.True(t, listPayload.Data.Items[0].OpenCodeGoUsage.AutoRefreshEnabled) + require.Equal(t, 6.0, listPayload.Data.Items[0].OpenCodeGoUsage.Snapshot.Data.Rolling.Percent) + + detailRecorder := httptest.NewRecorder() + router.ServeHTTP(detailRecorder, httptest.NewRequest(http.MethodGet, "/accounts/7", nil)) + require.Equal(t, http.StatusOK, detailRecorder.Code) + var detailPayload struct { + Data struct { + OpenCodeGoUsage *service.OpenCodeGoUsageState `json:"opencode_go_usage"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(detailRecorder.Body.Bytes(), &detailPayload)) + require.NotNil(t, detailPayload.Data.OpenCodeGoUsage) + require.Equal(t, 6.0, detailPayload.Data.OpenCodeGoUsage.Snapshot.Data.Rolling.Percent) + + stateRecorder := httptest.NewRecorder() + router.ServeHTTP(stateRecorder, httptest.NewRequest(http.MethodGet, "/accounts/7/opencode-go-usage", nil)) + require.Equal(t, http.StatusOK, stateRecorder.Code) + var statePayload struct { + Data service.OpenCodeGoUsageState `json:"data"` + } + require.NoError(t, json.Unmarshal(stateRecorder.Body.Bytes(), &statePayload)) + require.Equal(t, statePayload.Data.Snapshot, detailPayload.Data.OpenCodeGoUsage.Snapshot) + + for _, body := range []string{listRecorder.Body.String(), detailRecorder.Body.String(), stateRecorder.Body.String()} { + require.NotContains(t, body, "test-key") + } +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index d5a9403a23b2..910ba884f73b 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -235,6 +235,10 @@ func AccountFromServiceShallow(a *service.Account) *Account { if state := service.OllamaCloudUsageStateFromAccount(a); state.Eligible { ollamaCloudUsage = state } + var openCodeGoUsage *service.OpenCodeGoUsageState + if state := service.OpenCodeGoUsageStateFromAccount(a); state.Eligible { + openCodeGoUsage = state + } out := &Account{ ID: a.ID, Name: a.Name, @@ -245,6 +249,7 @@ func AccountFromServiceShallow(a *service.Account) *Account { CredentialsStatus: credsStatus, Extra: extra, OllamaCloudUsage: ollamaCloudUsage, + OpenCodeGoUsage: openCodeGoUsage, ProxyID: a.ProxyID, ProxyFallbackOriginID: a.ProxyFallbackOriginID, ProxyFallbackOriginName: a.ProxyFallbackOriginName, @@ -411,7 +416,9 @@ func redactAccountManagedExtra(extra map[string]any) map[string]any { switch key { case service.OllamaCloudUsageSessionExtraKey, service.OllamaCloudUsageAutoRefreshExtraKey, - service.OllamaCloudUsageSnapshotExtraKey: + service.OllamaCloudUsageSnapshotExtraKey, + service.OpenCodeGoUsageAutoRefreshExtraKey, + service.OpenCodeGoUsageSnapshotExtraKey: continue default: redacted[key] = value diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 2cd623f7d6a9..1a0733dcdcaf 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -205,6 +205,7 @@ type Account struct { CredentialsStatus map[string]bool `json:"credentials_status,omitempty"` Extra map[string]any `json:"extra"` OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage,omitempty"` + OpenCodeGoUsage *service.OpenCodeGoUsageState `json:"opencode_go_usage,omitempty"` ProxyID *int64 `json:"proxy_id"` ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"` ProxyFallbackOriginName *string `json:"proxy_fallback_origin_name,omitempty"` diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 36fdb8528014..c6145b216bda 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -47,9 +47,11 @@ func ProvideAdminHandlers( auditLogHandler *admin.AuditLogHandler, upstreamBillingProbe *service.UpstreamBillingProbeService, ollamaCloudUsage *service.OllamaCloudUsageService, + opencodeGoUsage *service.OpenCodeGoUsageService, ) *AdminHandlers { accountHandler.SetUpstreamBillingProbeService(upstreamBillingProbe) accountHandler.SetOllamaCloudUsageService(ollamaCloudUsage) + accountHandler.SetOpenCodeGoUsageService(opencodeGoUsage) return &AdminHandlers{ Dashboard: dashboardHandler, User: userHandler, diff --git a/backend/internal/repository/account_repo_opencode_go_usage.go b/backend/internal/repository/account_repo_opencode_go_usage.go new file mode 100644 index 000000000000..ef645301ca55 --- /dev/null +++ b/backend/internal/repository/account_repo_opencode_go_usage.go @@ -0,0 +1,150 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +const ( + opencodeGoBaseURLRegexSQL = `^[hH][tT][tT][pP][sS]://[oO][pP][eE][nN][cC][oO][dD][eE]\.[aA][iI]/[zZ][eE][nN]/[gG][oO]/[vV]1/?$` + opencodeGoBaseURLMatchSQLPrefix = "btrim(" + opencodeGoBaseURLMatchSQLSuffix = ") ~ '" + opencodeGoBaseURLRegexSQL + "'" + opencodeGoUsageEligibleSQL = ` + platform = 'openai' + AND type = 'apikey' + AND ` + opencodeGoBaseURLMatchSQLPrefix + `credentials ->> 'base_url'` + opencodeGoBaseURLMatchSQLSuffix + ` + AND jsonb_typeof(credentials -> 'api_key') = 'string' +` +) + +// SetOpenCodeGoUsageAutoRefresh persists the per-account auto-refresh switch. +func (r *accountRepository) SetOpenCodeGoUsageAutoRefresh(ctx context.Context, account *service.Account, enabled bool) error { + if account == nil { + return service.ErrAccountNilInput + } + if r == nil || r.client == nil || !service.IsOpenCodeGoUsageAccount(account) { + return service.ErrOpenCodeGoUsageUnavailable + } + return r.updateOpenCodeGoUsageExtra(ctx, account, map[string]any{ + service.OpenCodeGoUsageAutoRefreshExtraKey: enabled, + }) +} + +// UpdateOpenCodeGoUsageSnapshot persists the per-account usage snapshot. +func (r *accountRepository) UpdateOpenCodeGoUsageSnapshot(ctx context.Context, account *service.Account, snapshot *service.OpenCodeGoUsageSnapshot) error { + if account == nil || snapshot == nil { + return service.ErrAccountNilInput + } + if r == nil || r.client == nil || !service.IsOpenCodeGoUsageAccount(account) { + return service.ErrOpenCodeGoUsageUnavailable + } + return r.updateOpenCodeGoUsageExtra(ctx, account, map[string]any{ + service.OpenCodeGoUsageSnapshotExtraKey: snapshot, + }) +} + +// updateOpenCodeGoUsageExtra atomically merges managed extra keys onto the +// account row. Snapshots are written per account (no api_key group CAS). +func (r *accountRepository) updateOpenCodeGoUsageExtra(ctx context.Context, account *service.Account, payload map[string]any) error { + encoded, err := json.Marshal(payload) + if err != nil { + return err + } + apply := func(txCtx context.Context, client *dbent.Client) error { + result, err := client.ExecContext(txCtx, ` + UPDATE accounts + SET extra = COALESCE(extra, '{}'::jsonb) || $1::jsonb, + updated_at = NOW() + WHERE deleted_at IS NULL + AND `+opencodeGoUsageEligibleSQL+` + AND id = $2 + `, string(encoded), account.ID) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected != 1 { + return service.ErrOpenCodeGoUsageIdentityChanged + } + return nil + } + if dbent.TxFromContext(ctx) != nil { + return apply(ctx, clientFromContext(ctx, r.client)) + } + tx, err := r.client.Tx(ctx) + if errors.Is(err, dbent.ErrTxStarted) { + return apply(ctx, r.client) + } + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + txCtx := dbent.NewTxContext(ctx, tx) + if err := apply(txCtx, tx.Client()); err != nil { + return err + } + return tx.Commit() +} + +// ListDueOpenCodeGoUsageAccounts returns at most limit eligible accounts whose +// auto-refresh is enabled and whose snapshot is missing or due (next_refresh_at +// at or before now). Invalid/missing next_refresh_at values fail open to due. +func (r *accountRepository) ListDueOpenCodeGoUsageAccounts(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") + } + nextRefreshExpr := "extra -> 'opencode_go_usage_snapshot' #>> '{next_refresh_at}'" + rows, err := r.sql.QueryContext(ctx, ` + SELECT id + FROM accounts + WHERE deleted_at IS NULL + AND status = 'active' + AND `+opencodeGoUsageEligibleSQL+` + AND extra @> '{"opencode_go_usage_auto_refresh": true}'::jsonb + AND ( + extra -> 'opencode_go_usage_snapshot' IS NULL + OR extra -> 'opencode_go_usage_snapshot' = 'null'::jsonb + OR `+ollamaCloudUsageParseRFC3339SQL(nextRefreshExpr)+` IS NULL + OR `+ollamaCloudUsageParseRFC3339SQL(nextRefreshExpr)+`::timestamptz <= $1 + ) + ORDER BY 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 + } + 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 +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 149d4f95bcef..b375f51df311 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -354,6 +354,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.POST("/upstream-billing-probe/batch", h.Admin.Account.ProbeUpstreamBillingBatch) accounts.GET("/ollama-cloud-usage/settings", h.Admin.Account.GetOllamaCloudUsageSettings) accounts.PUT("/ollama-cloud-usage/settings", h.Admin.Account.UpdateOllamaCloudUsageSettings) + accounts.GET("/opencode-go-usage/settings", h.Admin.Account.GetOpenCodeGoUsageSettings) + accounts.PUT("/opencode-go-usage/settings", h.Admin.Account.UpdateOpenCodeGoUsageSettings) accounts.GET("/:id", h.Admin.Account.GetByID) accounts.POST("", h.Admin.Account.Create) accounts.POST("/:id/duplicate", h.Admin.Account.Duplicate) @@ -369,6 +371,9 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.DELETE("/:id/ollama-cloud-usage/session", h.Admin.Account.DeleteOllamaCloudUsageSession) accounts.PUT("/:id/ollama-cloud-usage/auto-refresh", h.Admin.Account.SetOllamaCloudUsageAutoRefresh) accounts.POST("/:id/ollama-cloud-usage/refresh", h.Admin.Account.RefreshOllamaCloudUsage) + accounts.GET("/:id/opencode-go-usage", h.Admin.Account.GetOpenCodeGoUsage) + accounts.PUT("/:id/opencode-go-usage/auto-refresh", h.Admin.Account.SetOpenCodeGoUsageAutoRefresh) + accounts.POST("/:id/opencode-go-usage/refresh", h.Admin.Account.RefreshOpenCodeGoUsage) accounts.DELETE("/:id", h.Admin.Account.Delete) accounts.POST("/:id/test", h.Admin.Account.Test) accounts.POST("/:id/recover-state", h.Admin.Account.RecoverState) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 2d5311bb2442..5d4a1c8c6785 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -464,6 +464,9 @@ const ( // SettingKeyOllamaCloudUsageSettings stores the opt-in global runner switch and interval. SettingKeyOllamaCloudUsageSettings = "ollama_cloud_usage_settings" + // SettingKeyOpenCodeGoUsageSettings stores the opt-in global runner switch and interval. + SettingKeyOpenCodeGoUsageSettings = "opencode_go_usage_settings" + // ========================= // Overload Cooldown (529) // ========================= diff --git a/backend/internal/service/opencode_go_usage.go b/backend/internal/service/opencode_go_usage.go new file mode 100644 index 000000000000..88564c36d4cc --- /dev/null +++ b/backend/internal/service/opencode_go_usage.go @@ -0,0 +1,750 @@ +package service + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand/v2" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/google/uuid" + "golang.org/x/sync/errgroup" + "golang.org/x/sync/singleflight" +) + +const ( + OpenCodeGoUsageAutoRefreshExtraKey = "opencode_go_usage_auto_refresh" + OpenCodeGoUsageSnapshotExtraKey = "opencode_go_usage_snapshot" + + opencodeGoUsageAPIURL = "https://opencode.ai/zen/go/v1/usage" + opencodeGoUsageDefaultIntervalMinutes = 15 + opencodeGoUsageMinIntervalMinutes = 5 + opencodeGoUsageMaxIntervalMinutes = 24 * 60 + opencodeGoUsageCycleInterval = time.Minute + opencodeGoUsageManualRefreshInterval = 10 * time.Second + opencodeGoUsageRequestTimeout = 15 * time.Second + opencodeGoUsageMaxBodyBytes = 512 * 1024 + opencodeGoUsageMaxPerCycle = 20 + opencodeGoUsageConcurrency = 4 + opencodeGoUsageMaxDelay = 24 * time.Hour + opencodeGoUsageLeaderLockKey = "opencode:go:usage:leader" + opencodeGoUsageLeaderLockTTL = 2 * time.Minute +) + +var ( + ErrOpenCodeGoUsageUnavailable = infraerrors.ServiceUnavailable( + "OPENCODE_GO_USAGE_UNAVAILABLE", "OpenCode Go usage is unavailable", + ) + ErrOpenCodeGoUsageAccountInvalid = infraerrors.BadRequest( + "OPENCODE_GO_USAGE_ACCOUNT_INVALID", "account must be an OpenAI API key account using https://opencode.ai/zen/go/v1", + ) + ErrOpenCodeGoUsageIdentityChanged = infraerrors.Conflict( + "OPENCODE_GO_USAGE_IDENTITY_CHANGED", "account identity or proxy changed during refresh; retry", + ) + ErrOpenCodeGoUsageRefreshRateLimited = infraerrors.TooManyRequests( + "OPENCODE_GO_USAGE_REFRESH_RATE_LIMITED", "OpenCode Go usage can be refreshed manually once every 10 seconds", + ) +) + +const ( + OpenCodeGoUsageStatusOK = "ok" + OpenCodeGoUsageStatusUnauthorized = "unauthorized" + OpenCodeGoUsageStatusFailed = "failed" +) + +// OpenCodeGoUsageSettings controls the opt-in periodic refresh runner. +type OpenCodeGoUsageSettings struct { + Enabled bool `json:"enabled"` + IntervalMinutes int `json:"interval_minutes"` +} + +// OpenCodeGoUsageWindow is a narrow, sanitized view of one official usage window. +type OpenCodeGoUsageWindow struct { + Status string `json:"status"` + Percent float64 `json:"percent"` + ResetsAt time.Time `json:"resets_at"` +} + +// OpenCodeGoUsageData intentionally excludes raw upstream payload details. +type OpenCodeGoUsageData struct { + Rolling OpenCodeGoUsageWindow `json:"rolling"` + Weekly OpenCodeGoUsageWindow `json:"weekly"` + Monthly OpenCodeGoUsageWindow `json:"monthly"` +} + +// OpenCodeGoUsageSnapshot is the only usage observation persisted in account extra. +type OpenCodeGoUsageSnapshot struct { + Status string `json:"status"` + Data *OpenCodeGoUsageData `json:"data,omitempty"` + FetchedAt *time.Time `json:"fetched_at,omitempty"` + LastAttemptAt time.Time `json:"last_attempt_at"` + NextRefreshAt time.Time `json:"next_refresh_at"` + FailureCount int `json:"failure_count,omitempty"` + HTTPStatus int `json:"http_status,omitempty"` + LastError string `json:"last_error,omitempty"` +} + +// OpenCodeGoUsageState is the dedicated DTO exposed to administrators. +type OpenCodeGoUsageState struct { + AccountID int64 `json:"account_id"` + Eligible bool `json:"eligible"` + AutoRefreshEnabled bool `json:"auto_refresh_enabled"` + Snapshot *OpenCodeGoUsageSnapshot `json:"snapshot,omitempty"` +} + +type openCodeGoUsageRepository interface { + SetOpenCodeGoUsageAutoRefresh(context.Context, *Account, bool) error + UpdateOpenCodeGoUsageSnapshot(context.Context, *Account, *OpenCodeGoUsageSnapshot) error + ListDueOpenCodeGoUsageAccounts(context.Context, time.Time, int) ([]Account, error) +} + +// GetOpenCodeGoUsageSettings returns fail-safe defaults when the setting is absent. +func (s *SettingService) GetOpenCodeGoUsageSettings(ctx context.Context) (*OpenCodeGoUsageSettings, error) { + defaults := defaultOpenCodeGoUsageSettings() + if s == nil || s.settingRepo == nil { + return defaults, nil + } + raw, err := s.settingRepo.GetValue(ctx, SettingKeyOpenCodeGoUsageSettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return defaults, nil + } + return nil, fmt.Errorf("get OpenCode Go usage settings: %w", err) + } + if strings.TrimSpace(raw) == "" { + return defaults, nil + } + settings := *defaults + if err := json.Unmarshal([]byte(raw), &settings); err != nil { + return nil, fmt.Errorf("parse OpenCode Go usage settings: %w", err) + } + if settings.IntervalMinutes == 0 { + settings.IntervalMinutes = defaults.IntervalMinutes + } + normalizeOpenCodeGoUsageSettings(&settings) + return &settings, nil +} + +func (s *SettingService) SetOpenCodeGoUsageSettings(ctx context.Context, settings *OpenCodeGoUsageSettings) error { + if s == nil || s.settingRepo == nil { + return ErrOpenCodeGoUsageUnavailable + } + if settings == nil { + return infraerrors.BadRequest("INVALID_OPENCODE_GO_USAGE_SETTINGS", "settings cannot be nil") + } + if settings.IntervalMinutes < opencodeGoUsageMinIntervalMinutes || settings.IntervalMinutes > opencodeGoUsageMaxIntervalMinutes { + return infraerrors.BadRequest( + "INVALID_OPENCODE_GO_USAGE_INTERVAL", + fmt.Sprintf("interval_minutes must be between %d and %d", opencodeGoUsageMinIntervalMinutes, opencodeGoUsageMaxIntervalMinutes), + ) + } + normalizeOpenCodeGoUsageSettings(settings) + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal OpenCode Go usage settings: %w", err) + } + return s.settingRepo.Set(ctx, SettingKeyOpenCodeGoUsageSettings, string(data)) +} + +func defaultOpenCodeGoUsageSettings() *OpenCodeGoUsageSettings { + return &OpenCodeGoUsageSettings{ + Enabled: false, + IntervalMinutes: opencodeGoUsageDefaultIntervalMinutes, + } +} + +func normalizeOpenCodeGoUsageSettings(settings *OpenCodeGoUsageSettings) { + if settings.IntervalMinutes < opencodeGoUsageMinIntervalMinutes { + settings.IntervalMinutes = opencodeGoUsageMinIntervalMinutes + } + if settings.IntervalMinutes > opencodeGoUsageMaxIntervalMinutes { + settings.IntervalMinutes = opencodeGoUsageMaxIntervalMinutes + } +} + +// openCodeGoUsageIsAutoRefreshDue decides whether a configured auto-refresh +// account should fetch now. Missing or invalid snapshots fail open to a first +// fetch; otherwise the next_refresh_at horizon (success interval or failure +// backoff) decides. +func openCodeGoUsageIsAutoRefreshDue(snapshot *OpenCodeGoUsageSnapshot, now time.Time) bool { + if snapshot == nil { + return true + } + if snapshot.NextRefreshAt.IsZero() { + return true + } + return !now.Before(snapshot.NextRefreshAt) +} + +// OpenCodeGoUsageService refreshes the official usage JSON without affecting routing state. +type OpenCodeGoUsageService struct { + accountRepo AccountRepository + httpUpstream HTTPUpstream + settingService *SettingService + + parentCtx context.Context + parentCancel context.CancelFunc + wg sync.WaitGroup + mu sync.Mutex + started bool + stopped bool + cycleMu sync.Mutex + refreshGroup singleflight.Group + refreshSlots chan struct{} + now func() time.Time + lockCache LeaderLockCache + db *sql.DB + instanceID string +} + +func NewOpenCodeGoUsageService( + accountRepo AccountRepository, + httpUpstream HTTPUpstream, + settingService *SettingService, +) *OpenCodeGoUsageService { + ctx, cancel := context.WithCancel(context.Background()) + return &OpenCodeGoUsageService{ + accountRepo: accountRepo, + httpUpstream: httpUpstream, + settingService: settingService, + parentCtx: ctx, + parentCancel: cancel, + refreshSlots: make(chan struct{}, opencodeGoUsageConcurrency), + now: time.Now, + instanceID: uuid.NewString(), + } +} + +func ProvideOpenCodeGoUsageService( + accountRepo AccountRepository, + httpUpstream HTTPUpstream, + settingService *SettingService, + lockCache LeaderLockCache, + db *sql.DB, +) *OpenCodeGoUsageService { + svc := NewOpenCodeGoUsageService(accountRepo, httpUpstream, settingService) + svc.lockCache = lockCache + svc.db = db + svc.Start() + return svc +} + +func (s *OpenCodeGoUsageService) Start() { + if s == nil { + return + } + s.mu.Lock() + if s.started || s.stopped { + s.mu.Unlock() + return + } + s.started = true + s.wg.Add(1) + s.mu.Unlock() + go s.runLoop() +} + +func (s *OpenCodeGoUsageService) Stop() { + if s == nil { + return + } + s.mu.Lock() + if s.stopped { + s.mu.Unlock() + return + } + s.stopped = true + s.parentCancel() + s.mu.Unlock() + s.wg.Wait() +} + +func (s *OpenCodeGoUsageService) runLoop() { + defer s.wg.Done() + _ = s.RunDue(s.parentCtx) + ticker := time.NewTicker(opencodeGoUsageCycleInterval) + defer ticker.Stop() + for { + select { + case <-s.parentCtx.Done(): + return + case <-ticker.C: + if err := s.RunDue(s.parentCtx); err != nil { + logger.LegacyPrintf("service.opencode_go_usage", "run_due_failed: err=%v", err) + } + } + } +} + +func (s *OpenCodeGoUsageService) GetSettings(ctx context.Context) (*OpenCodeGoUsageSettings, error) { + if s == nil || s.settingService == nil { + return defaultOpenCodeGoUsageSettings(), nil + } + return s.settingService.GetOpenCodeGoUsageSettings(ctx) +} + +func (s *OpenCodeGoUsageService) UpdateSettings(ctx context.Context, settings *OpenCodeGoUsageSettings) error { + if s == nil || s.settingService == nil { + return ErrOpenCodeGoUsageUnavailable + } + return s.settingService.SetOpenCodeGoUsageSettings(ctx, settings) +} + +func (s *OpenCodeGoUsageService) GetState(ctx context.Context, accountID int64) (*OpenCodeGoUsageState, error) { + if s == nil || s.accountRepo == nil { + return nil, ErrOpenCodeGoUsageUnavailable + } + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil { + return nil, err + } + return OpenCodeGoUsageStateFromAccount(account), nil +} + +func (s *OpenCodeGoUsageService) SetAutoRefresh(ctx context.Context, accountID int64, enabled bool) (*OpenCodeGoUsageState, error) { + if s == nil || s.accountRepo == nil { + return nil, ErrOpenCodeGoUsageUnavailable + } + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil { + return nil, err + } + if !IsOpenCodeGoUsageAccount(account) { + return nil, ErrOpenCodeGoUsageAccountInvalid + } + writer, ok := s.accountRepo.(openCodeGoUsageRepository) + if !ok { + return nil, ErrOpenCodeGoUsageUnavailable + } + if err := writer.SetOpenCodeGoUsageAutoRefresh(ctx, account, enabled); err != nil { + return nil, err + } + return s.GetState(ctx, accountID) +} + +func (s *OpenCodeGoUsageService) Refresh(ctx context.Context, accountID int64) (*OpenCodeGoUsageState, error) { + settings, err := s.GetSettings(ctx) + if err != nil { + return nil, err + } + if _, err := s.refreshAccount(ctx, accountID, settings, false); err != nil { + return nil, err + } + return s.GetState(ctx, accountID) +} + +func (s *OpenCodeGoUsageService) RunDue(ctx context.Context) error { + if s == nil || s.accountRepo == nil { + return nil + } + s.cycleMu.Lock() + defer s.cycleMu.Unlock() + settings, err := s.GetSettings(ctx) + if err != nil { + return err + } + if !settings.Enabled { + return nil + } + release, acquired := tryAcquireSingletonLeaderLock(ctx, s.lockCache, s.db, opencodeGoUsageLeaderLockKey, s.instanceID, opencodeGoUsageLeaderLockTTL) + if !acquired { + return nil + } + defer release() + + writer, ok := s.accountRepo.(openCodeGoUsageRepository) + if !ok { + return ErrOpenCodeGoUsageUnavailable + } + now := s.currentTime() + accounts, err := writer.ListDueOpenCodeGoUsageAccounts(ctx, now, opencodeGoUsageMaxPerCycle) + if err != nil { + return fmt.Errorf("list due OpenCode Go usage accounts: %w", err) + } + var group errgroup.Group + for index := range accounts { + account := accounts[index] + if !account.IsActive() || !openCodeGoUsageAutoRefreshEnabled(&account) { + continue + } + snapshot := decodeOpenCodeGoUsageSnapshot(account.Extra) + if !openCodeGoUsageIsAutoRefreshDue(snapshot, now) { + continue + } + accountID := account.ID + group.Go(func() error { + if _, refreshErr := s.refreshAccount(ctx, accountID, settings, true); refreshErr != nil { + logger.LegacyPrintf("service.opencode_go_usage", "refresh_due_failed: account_id=%d err=%v", accountID, refreshErr) + } + return nil + }) + } + return group.Wait() +} + +func (s *OpenCodeGoUsageService) refreshAccount(ctx context.Context, accountID int64, settings *OpenCodeGoUsageSettings, requireEnabled bool) (*OpenCodeGoUsageSnapshot, error) { + if s == nil || s.accountRepo == nil { + return nil, ErrOpenCodeGoUsageUnavailable + } + if settings == nil { + settings = defaultOpenCodeGoUsageSettings() + } + intervalMinutes := settings.IntervalMinutes + anchor, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil { + return nil, err + } + if !IsOpenCodeGoUsageAccount(anchor) { + return nil, ErrOpenCodeGoUsageAccountInvalid + } + key := strconv.FormatInt(accountID, 10) + value, err, _ := s.refreshGroup.Do(key, func() (any, error) { + select { + case s.refreshSlots <- struct{}{}: + defer func() { <-s.refreshSlots }() + case <-ctx.Done(): + return nil, ctx.Err() + } + account, loadErr := s.accountRepo.GetByID(ctx, accountID) + if loadErr != nil { + return nil, loadErr + } + if !IsOpenCodeGoUsageAccount(account) { + return nil, ErrOpenCodeGoUsageAccountInvalid + } + if !requireEnabled { + if snapshot := decodeOpenCodeGoUsageSnapshot(account.Extra); snapshot != nil && !snapshot.LastAttemptAt.IsZero() { + retryAt := snapshot.LastAttemptAt.Add(opencodeGoUsageManualRefreshInterval) + if now := s.currentTime(); now.Before(retryAt) { + remaining := retryAt.Sub(now) + seconds := int((remaining + time.Second - 1) / time.Second) + return nil, ErrOpenCodeGoUsageRefreshRateLimited.WithMetadata(map[string]string{ + "retry_after_seconds": strconv.Itoa(seconds), + }) + } + } + } + if requireEnabled { + if !account.IsActive() || !openCodeGoUsageAutoRefreshEnabled(account) { + return nil, nil + } + if !openCodeGoUsageIsAutoRefreshDue(decodeOpenCodeGoUsageSnapshot(account.Extra), s.currentTime()) { + return nil, nil + } + } + return s.refreshLoadedAccount(ctx, account, intervalMinutes) + }) + if err != nil || value == nil { + return nil, err + } + snapshot, ok := value.(*OpenCodeGoUsageSnapshot) + if !ok { + return nil, fmt.Errorf("invalid OpenCode Go usage refresh result") + } + return snapshot, nil +} + +func (s *OpenCodeGoUsageService) refreshLoadedAccount(ctx context.Context, account *Account, intervalMinutes int) (*OpenCodeGoUsageSnapshot, error) { + now := s.currentTime().UTC() + apiKey, _ := account.Credentials["api_key"].(string) + if apiKey == "" { + return nil, ErrOpenCodeGoUsageAccountInvalid + } + if s.httpUpstream == nil { + return nil, ErrOpenCodeGoUsageUnavailable + } + proxyURL := "" + if account.ProxyID != nil { + if account.Proxy == nil || account.Proxy.ID != *account.ProxyID { + return nil, ErrOpenCodeGoUsageIdentityChanged + } + proxyURL = account.Proxy.URL() + } + requestCtx, cancel := context.WithTimeout(WithHTTPUpstreamRedirectsDisabled(ctx), opencodeGoUsageRequestTimeout) + defer cancel() + req, err := http.NewRequestWithContext(requestCtx, http.MethodGet, opencodeGoUsageAPIURL, nil) + if err != nil || !isExactOpenCodeGoUsageURL(req.URL) { + return nil, ErrOpenCodeGoUsageUnavailable + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("User-Agent", "sub2api-opencode-go-usage/1") + resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + if err != nil { + return s.persistFailure(ctx, account, intervalMinutes, now, 0, "request_failed", 0, false) + } + if resp == nil || resp.Body == nil { + return s.persistFailure(ctx, account, intervalMinutes, now, 0, "empty_response", 0, false) + } + defer func() { _ = resp.Body.Close() }() + if resp.Request != nil && !isExactOpenCodeGoUsageURL(resp.Request.URL) { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "response_host_mismatch", 0, false) + } + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "redirect_blocked", retryAfter(resp.Header, now), false) + } + if resp.StatusCode == http.StatusUnauthorized { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "unauthorized", retryAfter(resp.Header, now), true) + } + if resp.StatusCode == http.StatusForbidden { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "OpenCode Go subscription required (403)", retryAfter(resp.Header, now), false) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "http_error", retryAfter(resp.Header, now), false) + } + body, readErr := io.ReadAll(io.LimitReader(resp.Body, opencodeGoUsageMaxBodyBytes+1)) + if readErr != nil { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "response_read_failed", 0, false) + } + if len(body) > opencodeGoUsageMaxBodyBytes { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "response_too_large", 0, false) + } + data, parseErr := parseOpenCodeGoUsageJSON(body) + if parseErr != nil { + return s.persistFailure(ctx, account, intervalMinutes, now, resp.StatusCode, "invalid_json", 0, false) + } + snapshot := &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, + Data: data, + FetchedAt: &now, + LastAttemptAt: now, + NextRefreshAt: now.Add(nextOpenCodeGoUsageDelay(intervalMinutes, 0, 0)), + HTTPStatus: resp.StatusCode, + } + if err := s.updateSnapshot(ctx, account, snapshot); err != nil { + return nil, err + } + return snapshot, nil +} + +func (s *OpenCodeGoUsageService) persistFailure( + ctx context.Context, + account *Account, + intervalMinutes int, + now time.Time, + httpStatus int, + reason string, + retryAfterDuration time.Duration, + unauthorized bool, +) (*OpenCodeGoUsageSnapshot, error) { + previous := decodeOpenCodeGoUsageSnapshot(account.Extra) + failureCount := 1 + if previous != nil { + failureCount = previous.FailureCount + 1 + } + status := OpenCodeGoUsageStatusFailed + if unauthorized { + status = OpenCodeGoUsageStatusUnauthorized + } + snapshot := &OpenCodeGoUsageSnapshot{ + Status: status, + LastAttemptAt: now, + NextRefreshAt: now.Add(nextOpenCodeGoUsageDelay(intervalMinutes, failureCount, retryAfterDuration)), + FailureCount: failureCount, + HTTPStatus: httpStatus, + LastError: reason, + } + if previous != nil { + snapshot.Data = previous.Data + snapshot.FetchedAt = previous.FetchedAt + } + if err := s.updateSnapshot(ctx, account, snapshot); err != nil { + return nil, err + } + return snapshot, nil +} + +func (s *OpenCodeGoUsageService) updateSnapshot(ctx context.Context, account *Account, snapshot *OpenCodeGoUsageSnapshot) error { + writer, ok := s.accountRepo.(openCodeGoUsageRepository) + if !ok { + return ErrOpenCodeGoUsageUnavailable + } + return writer.UpdateOpenCodeGoUsageSnapshot(ctx, account, snapshot) +} + +func OpenCodeGoUsageStateFromAccount(account *Account) *OpenCodeGoUsageState { + state := &OpenCodeGoUsageState{} + if account == nil { + return state + } + state.AccountID = account.ID + state.Eligible = IsOpenCodeGoUsageAccount(account) + if !state.Eligible { + return state + } + state.AutoRefreshEnabled = openCodeGoUsageAutoRefreshEnabled(account) + state.Snapshot = decodeOpenCodeGoUsageSnapshot(account.Extra) + return state +} + +func IsOpenCodeGoUsageAccount(account *Account) bool { + if account == nil || account.Type != AccountTypeAPIKey || account.Platform != PlatformOpenAI { + return false + } + baseURL, _ := account.Credentials["base_url"].(string) + return isOpenCodeGoBaseURL(baseURL) +} + +func isOpenCodeGoBaseURL(raw string) bool { + raw = strings.TrimSpace(raw) + if raw == "" || strings.ContainsAny(raw, "?#") { + return false + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Opaque != "" || !strings.EqualFold(parsed.Scheme, "https") || parsed.User != nil || parsed.ForceQuery || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.RawFragment != "" { + return false + } + hostname := strings.ToLower(parsed.Hostname()) + if hostname != "opencode.ai" { + return false + } + authority := strings.ToLower(parsed.Host) + if authority != hostname && authority != hostname+":443" { + return false + } + if parsed.RawPath != "" { + return false + } + return strings.EqualFold(strings.TrimSuffix(parsed.Path, "/"), "/zen/go/v1") +} + +func isExactOpenCodeGoUsageURL(parsed *url.URL) bool { + return parsed != nil && parsed.Scheme == "https" && parsed.Host == "opencode.ai" && parsed.Path == "/zen/go/v1/usage" && + parsed.User == nil && parsed.RawQuery == "" && parsed.Fragment == "" && parsed.RawPath == "" +} + +func openCodeGoUsageAutoRefreshEnabled(account *Account) bool { + if account == nil || account.Extra == nil { + return false + } + enabled, ok := account.Extra[OpenCodeGoUsageAutoRefreshExtraKey].(bool) + return ok && enabled +} + +func decodeOpenCodeGoUsageSnapshot(extra map[string]any) *OpenCodeGoUsageSnapshot { + if extra == nil { + return nil + } + value, ok := extra[OpenCodeGoUsageSnapshotExtraKey] + if !ok || value == nil { + return nil + } + raw, err := json.Marshal(value) + if err != nil { + return nil + } + var snapshot OpenCodeGoUsageSnapshot + if err := json.Unmarshal(raw, &snapshot); err != nil { + return nil + } + if snapshot.Status != OpenCodeGoUsageStatusOK && snapshot.Status != OpenCodeGoUsageStatusUnauthorized && snapshot.Status != OpenCodeGoUsageStatusFailed { + return nil + } + return &snapshot +} + +// nextOpenCodeGoUsageDelay computes the not-before delay for the next refresh: +// interval * 2^min(failureCount-1, 6) capped at 24h, ±10% jitter capped at 5min, +// never below a Retry-After hint or one minute. +func nextOpenCodeGoUsageDelay(intervalMinutes, failureCount int, retryAfterDuration time.Duration) time.Duration { + minimumDelay := retryAfterDuration + base := time.Duration(intervalMinutes) * time.Minute + if base < opencodeGoUsageMinIntervalMinutes*time.Minute { + base = opencodeGoUsageMinIntervalMinutes * time.Minute + } + if failureCount > 0 { + shift := min(failureCount-1, 6) + base *= time.Duration(1 << shift) + } + if base > opencodeGoUsageMaxDelay { + base = opencodeGoUsageMaxDelay + } + if retryAfterDuration > base { + base = retryAfterDuration + } + jitterRange := base / 10 + if jitterRange > 5*time.Minute { + jitterRange = 5 * time.Minute + } + if jitterRange > 0 { + base += time.Duration(rand.Int64N(int64(jitterRange)*2+1)) - jitterRange + } + if base < minimumDelay { + return minimumDelay + } + if base < time.Minute { + return time.Minute + } + return base +} + +func (s *OpenCodeGoUsageService) currentTime() time.Time { + if s != nil && s.now != nil { + return s.now() + } + return time.Now() +} + +type openCodeGoUsageAPIWindow struct { + Status string `json:"status"` + Percent float64 `json:"percent"` + ResetsAt string `json:"resetsAt"` +} + +type openCodeGoUsageAPIUsage struct { + Rolling *openCodeGoUsageAPIWindow `json:"rolling"` + Weekly *openCodeGoUsageAPIWindow `json:"weekly"` + Monthly *openCodeGoUsageAPIWindow `json:"monthly"` +} + +type openCodeGoUsageAPIResponse struct { + Usage *openCodeGoUsageAPIUsage `json:"usage"` +} + +// parseOpenCodeGoUsageJSON parses the upstream usage payload leniently: the top +// level may wrap the windows in a "usage" object or expose them directly, and +// missing windows/fields degrade to zero values instead of failing the whole +// parse. Only structurally invalid JSON is an error. +func parseOpenCodeGoUsageJSON(body []byte) (*OpenCodeGoUsageData, error) { + var wrapped openCodeGoUsageAPIResponse + if err := json.Unmarshal(body, &wrapped); err != nil { + return nil, err + } + usage := wrapped.Usage + if usage == nil { + var direct openCodeGoUsageAPIUsage + if err := json.Unmarshal(body, &direct); err != nil { + return nil, err + } + usage = &direct + } + data := &OpenCodeGoUsageData{} + if usage.Rolling != nil { + data.Rolling = openCodeGoUsageWindowFromAPI(usage.Rolling) + } + if usage.Weekly != nil { + data.Weekly = openCodeGoUsageWindowFromAPI(usage.Weekly) + } + if usage.Monthly != nil { + data.Monthly = openCodeGoUsageWindowFromAPI(usage.Monthly) + } + return data, nil +} + +func openCodeGoUsageWindowFromAPI(window *openCodeGoUsageAPIWindow) OpenCodeGoUsageWindow { + out := OpenCodeGoUsageWindow{Status: window.Status, Percent: window.Percent} + if resetsAt, err := time.Parse(time.RFC3339, window.ResetsAt); err == nil { + out.ResetsAt = resetsAt.UTC() + } + return out +} diff --git a/backend/internal/service/opencode_go_usage_test.go b/backend/internal/service/opencode_go_usage_test.go new file mode 100644 index 000000000000..15065ea0b0bd --- /dev/null +++ b/backend/internal/service/opencode_go_usage_test.go @@ -0,0 +1,496 @@ +package service + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/stretchr/testify/require" +) + +// openCodeGoUsageFixture is the verified upstream 200 response (2026-08-13). +const openCodeGoUsageFixture = `{"usage":{"rolling":{"status":"ok","percent":6,"resetsAt":"2026-08-13T18:26:39.281Z"},"weekly":{"status":"ok","percent":2,"resetsAt":"2026-08-17T00:00:00.281Z"},"monthly":{"status":"ok","percent":1,"resetsAt":"2026-09-13T13:24:47.281Z"}}}` + +type openCodeGoUsageTestRepo struct { + AccountRepository + mu sync.Mutex + accounts map[int64]*Account + due []Account +} + +func (r *openCodeGoUsageTestRepo) GetByID(_ context.Context, id int64) (*Account, error) { + r.mu.Lock() + defer r.mu.Unlock() + account := r.accounts[id] + if account == nil { + return nil, ErrAccountNotFound + } + clone := *account + clone.Credentials = mergeMap(nil, account.Credentials) + clone.Extra = mergeMap(nil, account.Extra) + return &clone, nil +} + +func (r *openCodeGoUsageTestRepo) GetByIDs(_ context.Context, ids []int64) ([]*Account, error) { + r.mu.Lock() + defer r.mu.Unlock() + result := make([]*Account, 0, len(ids)) + for _, id := range ids { + if account := r.accounts[id]; account != nil { + result = append(result, account) + } + } + return result, nil +} + +func (r *openCodeGoUsageTestRepo) SetOpenCodeGoUsageAutoRefresh(_ context.Context, expected *Account, enabled bool) error { + r.mu.Lock() + defer r.mu.Unlock() + account := r.accounts[expected.ID] + if account == nil { + return ErrAccountNotFound + } + if account.Extra == nil { + account.Extra = make(map[string]any) + } + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = enabled + return nil +} + +func (r *openCodeGoUsageTestRepo) UpdateOpenCodeGoUsageSnapshot(_ context.Context, expected *Account, snapshot *OpenCodeGoUsageSnapshot) error { + r.mu.Lock() + defer r.mu.Unlock() + account := r.accounts[expected.ID] + if account == nil { + return ErrAccountNotFound + } + if account.Extra == nil { + account.Extra = make(map[string]any) + } + account.Extra[OpenCodeGoUsageSnapshotExtraKey] = snapshot + return nil +} + +func (r *openCodeGoUsageTestRepo) ListDueOpenCodeGoUsageAccounts(_ context.Context, _ time.Time, limit int) ([]Account, error) { + r.mu.Lock() + defer r.mu.Unlock() + if len(r.due) > 0 { + out := make([]Account, 0, min(limit, len(r.due))) + for _, account := range r.due[:min(limit, len(r.due))] { + out = append(out, cloneOpenCodeGoUsageTestAccount(account)) + } + return out, nil + } + out := make([]Account, 0, len(r.accounts)) + for _, account := range r.accounts { + out = append(out, cloneOpenCodeGoUsageTestAccount(*account)) + if len(out) == limit { + break + } + } + return out, nil +} + +func cloneOpenCodeGoUsageTestAccount(account Account) Account { + account.Credentials = mergeMap(nil, account.Credentials) + account.Extra = mergeMap(nil, account.Extra) + return account +} + +type openCodeGoUsageHTTPStub struct { + status int + body []byte + header http.Header + calls atomic.Int64 + lastRequest *http.Request + lastProxy string + mu sync.Mutex +} + +func (s *openCodeGoUsageHTTPStub) Do(req *http.Request, proxyURL string, _ int64, _ int) (*http.Response, error) { + s.calls.Add(1) + s.mu.Lock() + s.lastRequest = req + s.lastProxy = proxyURL + s.mu.Unlock() + status := s.status + if status == 0 { + status = http.StatusOK + } + header := s.header + if header == nil { + header = http.Header{"Content-Type": []string{"application/json"}} + } + return &http.Response{StatusCode: status, Header: header, Body: io.NopCloser(bytes.NewReader(s.body)), Request: req}, nil +} + +func (s *openCodeGoUsageHTTPStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return s.Do(req, proxyURL, accountID, concurrency) +} + +func openCodeGoUsageAccount(id int64) *Account { + return &Account{ + ID: id, Name: fmt.Sprintf("opencode-%d", id), Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://opencode.ai/zen/go/v1", "api_key": fmt.Sprintf("key-%d", id)}, + Extra: map[string]any{}, Status: StatusActive, Schedulable: true, Concurrency: 1, + } +} + +func newOpenCodeGoUsageTestService(t *testing.T, repo *openCodeGoUsageTestRepo, upstream HTTPUpstream, settingsRepo SettingRepository) *OpenCodeGoUsageService { + t.Helper() + svc := NewOpenCodeGoUsageService(repo, upstream, NewSettingService(settingsRepo, nil)) + t.Cleanup(svc.Stop) + return svc +} + +func TestOpenCodeGoUsageParseJSON200(t *testing.T) { + data, err := parseOpenCodeGoUsageJSON([]byte(openCodeGoUsageFixture)) + require.NoError(t, err) + require.Equal(t, "ok", data.Rolling.Status) + require.Equal(t, 6.0, data.Rolling.Percent) + require.Equal(t, "ok", data.Weekly.Status) + require.Equal(t, 2.0, data.Weekly.Percent) + require.Equal(t, "ok", data.Monthly.Status) + require.Equal(t, 1.0, data.Monthly.Percent) + rollingReset, err := time.Parse(time.RFC3339, "2026-08-13T18:26:39.281Z") + require.NoError(t, err) + require.Equal(t, rollingReset.UTC(), data.Rolling.ResetsAt) + weeklyReset, err := time.Parse(time.RFC3339, "2026-08-17T00:00:00.281Z") + require.NoError(t, err) + require.Equal(t, weeklyReset.UTC(), data.Weekly.ResetsAt) +} + +func TestOpenCodeGoUsageParseJSONLenient(t *testing.T) { + // top-level windows without a usage wrapper + data, err := parseOpenCodeGoUsageJSON([]byte(`{"rolling":{"status":"ok","percent":6,"resetsAt":"2026-08-13T18:26:39.281Z"}}`)) + require.NoError(t, err) + require.Equal(t, 6.0, data.Rolling.Percent) + require.Zero(t, data.Weekly.Percent) + + // missing windows/fields degrade to zero values instead of failing + data, err = parseOpenCodeGoUsageJSON([]byte(`{"usage":{"rolling":{"status":"ok"}}}`)) + require.NoError(t, err) + require.Equal(t, "ok", data.Rolling.Status) + require.Zero(t, data.Rolling.Percent) + require.True(t, data.Rolling.ResetsAt.IsZero()) + + // integer percent parses as float + data, err = parseOpenCodeGoUsageJSON([]byte(`{"usage":{"weekly":{"status":"ok","percent":2}}}`)) + require.NoError(t, err) + require.Equal(t, 2.0, data.Weekly.Percent) + + // malformed JSON is an error + _, err = parseOpenCodeGoUsageJSON([]byte(`{"usage": {broken`)) + require.Error(t, err) +} + +func TestOpenCodeGoUsageRefresh200Success(t *testing.T) { + account := openCodeGoUsageAccount(7) + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + state, err := svc.Refresh(context.Background(), 7) + require.NoError(t, err) + require.True(t, state.Eligible) + require.Equal(t, OpenCodeGoUsageStatusOK, state.Snapshot.Status) + require.Equal(t, 6.0, state.Snapshot.Data.Rolling.Percent) + require.Equal(t, 2.0, state.Snapshot.Data.Weekly.Percent) + require.Equal(t, 1.0, state.Snapshot.Data.Monthly.Percent) + require.Equal(t, http.StatusOK, state.Snapshot.HTTPStatus) + require.Equal(t, 0, state.Snapshot.FailureCount) + require.NotNil(t, state.Snapshot.FetchedAt) + require.False(t, state.Snapshot.NextRefreshAt.IsZero()) + require.Equal(t, "Bearer key-7", stub.lastRequest.Header.Get("Authorization")) + require.Equal(t, "https://opencode.ai/zen/go/v1/usage", stub.lastRequest.URL.String()) + require.Equal(t, "application/json", stub.lastRequest.Header.Get("Accept")) +} + +func TestOpenCodeGoUsageRefresh401Unauthorized(t *testing.T) { + account := openCodeGoUsageAccount(7) + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{status: http.StatusUnauthorized} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + state, err := svc.Refresh(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, OpenCodeGoUsageStatusUnauthorized, state.Snapshot.Status) + require.Equal(t, http.StatusUnauthorized, state.Snapshot.HTTPStatus) + require.Equal(t, "unauthorized", state.Snapshot.LastError) + require.Equal(t, 1, state.Snapshot.FailureCount) + require.False(t, state.Snapshot.NextRefreshAt.IsZero()) + // data is kept and auto-refresh stays enabled + require.True(t, openCodeGoUsageAutoRefreshEnabled(account)) +} + +func TestOpenCodeGoUsageRefresh403SubscriptionRequired(t *testing.T) { + account := openCodeGoUsageAccount(7) + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{status: http.StatusForbidden} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + state, err := svc.Refresh(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, OpenCodeGoUsageStatusFailed, state.Snapshot.Status) + require.Equal(t, http.StatusForbidden, state.Snapshot.HTTPStatus) + require.Equal(t, "OpenCode Go subscription required (403)", state.Snapshot.LastError) + require.Equal(t, 1, state.Snapshot.FailureCount) +} + +func TestOpenCodeGoUsageRefreshMalformedJSON(t *testing.T) { + account := openCodeGoUsageAccount(7) + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{status: http.StatusOK, body: []byte(`{"usage": {broken`)} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + state, err := svc.Refresh(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, OpenCodeGoUsageStatusFailed, state.Snapshot.Status) + require.Equal(t, http.StatusOK, state.Snapshot.HTTPStatus) + require.Equal(t, "invalid_json", state.Snapshot.LastError) + require.Equal(t, 1, state.Snapshot.FailureCount) +} + +func TestOpenCodeGoUsageRefreshFailureKeepsPreviousData(t *testing.T) { + now := time.Now().UTC() + account := openCodeGoUsageAccount(7) + account.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, Data: &OpenCodeGoUsageData{Rolling: OpenCodeGoUsageWindow{Status: "ok", Percent: 6}}, + FetchedAt: &now, LastAttemptAt: now.Add(-30 * time.Second), NextRefreshAt: now.Add(time.Hour), FailureCount: 1, + } + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{status: http.StatusInternalServerError} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + state, err := svc.Refresh(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, OpenCodeGoUsageStatusFailed, state.Snapshot.Status) + require.Equal(t, "http_error", state.Snapshot.LastError) + require.Equal(t, 2, state.Snapshot.FailureCount) + require.NotNil(t, state.Snapshot.Data, "previous data must be retained on failure") + require.Equal(t, 6.0, state.Snapshot.Data.Rolling.Percent) +} + +func TestNextOpenCodeGoUsageDelayBackoff(t *testing.T) { + // success: base interval with ±10% jitter + delay := nextOpenCodeGoUsageDelay(15, 0, 0) + require.GreaterOrEqual(t, delay, 13*time.Minute) + require.LessOrEqual(t, delay, 17*time.Minute) + + // failure 1: 1x interval (2^min(0,6)) + delay = nextOpenCodeGoUsageDelay(15, 1, 0) + require.GreaterOrEqual(t, delay, 13*time.Minute) + require.LessOrEqual(t, delay, 17*time.Minute) + + // failure 2: 2x interval + delay = nextOpenCodeGoUsageDelay(15, 2, 0) + require.GreaterOrEqual(t, delay, 27*time.Minute) + require.LessOrEqual(t, delay, 33*time.Minute) + + // failure 3: 4x interval + delay = nextOpenCodeGoUsageDelay(15, 3, 0) + require.GreaterOrEqual(t, delay, 55*time.Minute) + require.LessOrEqual(t, delay, 65*time.Minute) + + // failure 8: exponent capped at 6 → 64x interval (16h) + delay = nextOpenCodeGoUsageDelay(15, 8, 0) + require.GreaterOrEqual(t, delay, 15*time.Hour) + require.LessOrEqual(t, delay, 17*time.Hour) + + // hard cap at 24h even for huge intervals + delay = nextOpenCodeGoUsageDelay(1440, 8, 0) + require.GreaterOrEqual(t, delay, 23*time.Hour) + require.LessOrEqual(t, delay, 25*time.Hour) + + // Retry-After hint wins over the computed backoff + delay = nextOpenCodeGoUsageDelay(15, 0, 2*time.Hour) + require.GreaterOrEqual(t, delay, 2*time.Hour) + + // floor of one minute + delay = nextOpenCodeGoUsageDelay(5, 0, 0) + require.GreaterOrEqual(t, delay, time.Minute) +} + +func TestOpenCodeGoUsageManualRefreshThrottle(t *testing.T) { + now := time.Now().UTC() + account := openCodeGoUsageAccount(7) + account.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, LastAttemptAt: now.Add(-2 * time.Second), + } + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + svc.now = func() time.Time { return now } + + _, err := svc.Refresh(context.Background(), 7) + require.Error(t, err) + require.True(t, errors.Is(err, ErrOpenCodeGoUsageRefreshRateLimited)) + require.Equal(t, int64(0), stub.calls.Load(), "throttled refresh must not hit upstream") + var appErr *infraerrors.ApplicationError + require.ErrorAs(t, err, &appErr) + require.NotEmpty(t, appErr.Metadata["retry_after_seconds"]) +} + +func TestOpenCodeGoUsageManualRefreshNotThrottledAfterWindow(t *testing.T) { + now := time.Now().UTC() + account := openCodeGoUsageAccount(7) + account.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{ + Status: OpenCodeGoUsageStatusOK, LastAttemptAt: now.Add(-30 * time.Second), + } + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + svc.now = func() time.Time { return now } + + state, err := svc.Refresh(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, OpenCodeGoUsageStatusOK, state.Snapshot.Status) + require.Equal(t, int64(1), stub.calls.Load()) +} + +func TestOpenCodeGoUsageIsAutoRefreshDue(t *testing.T) { + now := time.Now().UTC() + require.True(t, openCodeGoUsageIsAutoRefreshDue(nil, now)) + require.True(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{}, now)) + require.True(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{NextRefreshAt: now.Add(-time.Minute)}, now)) + require.False(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{NextRefreshAt: now.Add(time.Minute)}, now)) +} + +func TestOpenCodeGoUsageRunDueRefreshesDueAccounts(t *testing.T) { + account := openCodeGoUsageAccount(7) + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + settingsRepo := &upstreamBillingProbeSettingRepo{} + require.NoError(t, settingsRepo.Set(context.Background(), SettingKeyOpenCodeGoUsageSettings, `{"enabled":true,"interval_minutes":15}`)) + svc := newOpenCodeGoUsageTestService(t, repo, stub, settingsRepo) + + require.NoError(t, svc.RunDue(context.Background())) + require.Equal(t, int64(1), stub.calls.Load()) + require.Equal(t, OpenCodeGoUsageStatusOK, decodeOpenCodeGoUsageSnapshot(account.Extra).Status) +} + +func TestOpenCodeGoUsageRunDueSkipsWhenDisabled(t *testing.T) { + account := openCodeGoUsageAccount(7) + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + require.NoError(t, svc.RunDue(context.Background())) + require.Equal(t, int64(0), stub.calls.Load()) +} + +func TestIsOpenCodeGoUsageAccount(t *testing.T) { + base := func() *Account { + return &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://opencode.ai/zen/go/v1", "api_key": "k"}} + } + require.True(t, IsOpenCodeGoUsageAccount(base())) + + // trailing slash on the path is allowed + account := base() + account.Credentials["base_url"] = "https://opencode.ai/zen/go/v1/" + require.True(t, IsOpenCodeGoUsageAccount(account)) + + // scheme/host/path are case-insensitive + account = base() + account.Credentials["base_url"] = "HTTPS://OPENCODE.AI/ZEN/GO/V1" + require.True(t, IsOpenCodeGoUsageAccount(account)) + + // wrong path + account = base() + account.Credentials["base_url"] = "https://opencode.ai/v1" + require.False(t, IsOpenCodeGoUsageAccount(account)) + + // wrong host + account = base() + account.Credentials["base_url"] = "https://ollama.com/zen/go/v1" + require.False(t, IsOpenCodeGoUsageAccount(account)) + + // wrong platform + account = base() + account.Platform = PlatformAnthropic + require.False(t, IsOpenCodeGoUsageAccount(account)) + + // non-apikey type + account = base() + account.Type = "oauth" + require.False(t, IsOpenCodeGoUsageAccount(account)) + + // query string rejected + account = base() + account.Credentials["base_url"] = "https://opencode.ai/zen/go/v1?x=1" + require.False(t, IsOpenCodeGoUsageAccount(account)) + + // missing base_url + account = base() + account.Credentials = map[string]any{"api_key": "k"} + require.False(t, IsOpenCodeGoUsageAccount(account)) +} + +func TestOpenCodeGoUsageSettingsDefaultOffAndValidation(t *testing.T) { + repo := &upstreamBillingProbeSettingRepo{} + settingsService := NewSettingService(repo, nil) + settings, err := settingsService.GetOpenCodeGoUsageSettings(context.Background()) + require.NoError(t, err) + require.False(t, settings.Enabled) + require.Equal(t, 15, settings.IntervalMinutes) + + // below the minimum + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 1}) + require.Error(t, err) + // above the maximum + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 2000}) + require.Error(t, err) + // valid update round-trips + err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 30}) + require.NoError(t, err) + settings, err = settingsService.GetOpenCodeGoUsageSettings(context.Background()) + require.NoError(t, err) + require.True(t, settings.Enabled) + require.Equal(t, 30, settings.IntervalMinutes) +} + +func TestOpenCodeGoUsageStateFromAccount(t *testing.T) { + account := openCodeGoUsageAccount(7) + state := OpenCodeGoUsageStateFromAccount(account) + require.True(t, state.Eligible) + require.False(t, state.AutoRefreshEnabled) + require.Nil(t, state.Snapshot) + + account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true + account.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{Status: OpenCodeGoUsageStatusOK} + state = OpenCodeGoUsageStateFromAccount(account) + require.True(t, state.AutoRefreshEnabled) + require.NotNil(t, state.Snapshot) + require.Equal(t, OpenCodeGoUsageStatusOK, state.Snapshot.Status) + + // ineligible account exposes no managed state + account.Platform = PlatformAnthropic + state = OpenCodeGoUsageStateFromAccount(account) + require.False(t, state.Eligible) + require.Nil(t, state.Snapshot) +} + +func TestOpenCodeGoUsageRefreshRejectsIneligibleAccount(t *testing.T) { + account := openCodeGoUsageAccount(7) + account.Credentials["base_url"] = "https://opencode.ai/v1" + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)} + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + + _, err := svc.Refresh(context.Background(), 7) + require.Error(t, err) + require.True(t, errors.Is(err, ErrOpenCodeGoUsageAccountInvalid)) + require.Equal(t, int64(0), stub.calls.Load()) +} diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 2e26c0035c22..41ba5805132a 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -803,6 +803,7 @@ var ProviderSet = wire.NewSet( ProvideAccountTestService, ProvideUpstreamBillingProbeService, ProvideOllamaCloudUsageService, + ProvideOpenCodeGoUsageService, ProvideSettingService, NewDataManagementService, ProvideBackupService, diff --git a/frontend/src/api/__tests__/admin.accounts.opencodeGoUsage.spec.ts b/frontend/src/api/__tests__/admin.accounts.opencodeGoUsage.spec.ts new file mode 100644 index 000000000000..845f2656e23e --- /dev/null +++ b/frontend/src/api/__tests__/admin.accounts.opencodeGoUsage.spec.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { get, post, put } = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), + put: vi.fn() +})) + +vi.mock('@/api/client', () => ({ + apiClient: { get, post, put } +})) + +import { + getOpenCodeGoUsage, + getOpenCodeGoUsageSettings, + refreshOpenCodeGoUsage, + setOpenCodeGoUsageAutoRefresh, + updateOpenCodeGoUsageSettings +} from '@/api/admin/accounts' + +const state = { + account_id: 7, + eligible: true, + auto_refresh_enabled: false +} + +describe('admin OpenCode Go usage API', () => { + beforeEach(() => { + get.mockReset() + post.mockReset() + put.mockReset() + }) + + it('uses dedicated global settings endpoints', async () => { + const settings = { enabled: false, interval_minutes: 60 } + get.mockResolvedValueOnce({ data: settings }) + put.mockResolvedValueOnce({ data: settings }) + + await expect(getOpenCodeGoUsageSettings()).resolves.toEqual(settings) + await expect(updateOpenCodeGoUsageSettings(settings)).resolves.toEqual(settings) + expect(get).toHaveBeenCalledWith('/admin/accounts/opencode-go-usage/settings') + expect(put).toHaveBeenCalledWith('/admin/accounts/opencode-go-usage/settings', settings) + }) + + it('reads account state, toggles auto-refresh and triggers a manual refresh', async () => { + get.mockResolvedValueOnce({ data: state }) + put.mockResolvedValueOnce({ data: { ...state, auto_refresh_enabled: true } }) + post.mockResolvedValueOnce({ data: state }) + + await expect(getOpenCodeGoUsage(7)).resolves.toEqual(state) + await expect(setOpenCodeGoUsageAutoRefresh(7, true)).resolves.toMatchObject({ auto_refresh_enabled: true }) + await expect(refreshOpenCodeGoUsage(7)).resolves.toEqual(state) + + expect(get).toHaveBeenCalledWith('/admin/accounts/7/opencode-go-usage') + expect(put).toHaveBeenCalledWith('/admin/accounts/7/opencode-go-usage/auto-refresh', { enabled: true }) + expect(post).toHaveBeenCalledWith('/admin/accounts/7/opencode-go-usage/refresh') + }) +}) diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 21ce9f092f20..d37a1f7c92f7 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -24,7 +24,9 @@ import type { UpstreamBillingProbeResult, UpstreamBillingProbeSettings, OllamaCloudUsageSettings, - OllamaCloudUsageState + OllamaCloudUsageState, + OpenCodeGoUsageSettings, + OpenCodeGoUsageState } from '@/types' /** @@ -983,6 +985,38 @@ export async function refreshOllamaCloudUsage(id: number): Promise { + const { data } = await apiClient.get('/admin/accounts/opencode-go-usage/settings') + return data +} + +export async function updateOpenCodeGoUsageSettings( + settings: OpenCodeGoUsageSettings +): Promise { + const { data } = await apiClient.put( + '/admin/accounts/opencode-go-usage/settings', + settings + ) + return data +} + +export async function getOpenCodeGoUsage(id: number): Promise { + const { data } = await apiClient.get(`/admin/accounts/${id}/opencode-go-usage`) + return data +} + +export async function setOpenCodeGoUsageAutoRefresh(id: number, enabled: boolean): Promise { + const { data } = await apiClient.put(`/admin/accounts/${id}/opencode-go-usage/auto-refresh`, { + enabled + }) + return data +} + +export async function refreshOpenCodeGoUsage(id: number): Promise { + const { data } = await apiClient.post(`/admin/accounts/${id}/opencode-go-usage/refresh`) + return data +} + export const accountsAPI = { list, listWithEtag, @@ -1043,7 +1077,12 @@ export const accountsAPI = { saveOllamaCloudUsageSession, deleteOllamaCloudUsageSession, setOllamaCloudUsageAutoRefresh, - refreshOllamaCloudUsage + refreshOllamaCloudUsage, + getOpenCodeGoUsageSettings, + updateOpenCodeGoUsageSettings, + getOpenCodeGoUsage, + setOpenCodeGoUsageAutoRefresh, + refreshOpenCodeGoUsage } export default accountsAPI diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 485b4b870102..6cd7a96accc2 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -548,6 +548,10 @@ v-if="account.ollama_cloud_usage?.eligible" :account="account" /> +
() diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 781f50254dfa..9efd63214696 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1742,6 +1742,91 @@ @updated="handleOllamaCloudUsageUpdated" /> +
+
+
+

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

+

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

+
+ + {{ opencodeGoStatusLabel }} + +
+ +
+ +
+ +
+
diff --git a/frontend/src/components/account/__tests__/OpenCodeGoUsageCell.spec.ts b/frontend/src/components/account/__tests__/OpenCodeGoUsageCell.spec.ts new file mode 100644 index 000000000000..0f7227993d25 --- /dev/null +++ b/frontend/src/components/account/__tests__/OpenCodeGoUsageCell.spec.ts @@ -0,0 +1,137 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import OpenCodeGoUsageCell from '../OpenCodeGoUsageCell.vue' +import UsageProgressBar from '../UsageProgressBar.vue' +import type { Account, OpenCodeGoUsageState } from '@/types' + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => { + const short: Record = { + 'admin.accounts.opencodeGo.rollingShort': '5h', + 'admin.accounts.opencodeGo.weeklyShort': 'W', + 'admin.accounts.opencodeGo.monthlyShort': 'M', + 'admin.accounts.opencodeGo.unauthorized': 'unauthorized', + 'admin.accounts.opencodeGo.failed': 'failed', + 'admin.accounts.opencodeGo.ok': 'ok' + } + return short[key] ?? key + } + }) + } +}) + +const usageState = (overrides: Partial = {}): OpenCodeGoUsageState => ({ + account_id: 7, + eligible: true, + auto_refresh_enabled: false, + snapshot: { + status: 'ok', + fetched_at: '2026-07-22T12:00:00Z', + last_attempt_at: '2026-07-22T12:00:00Z', + next_refresh_at: '2026-07-22T13:00:00Z', + data: { + rolling: { percent: 5.6, resets_at: '2026-07-23T03:00:00Z' }, + weekly: { percent: 14.2, resets_at: '2026-07-29T00:00:00Z' }, + monthly: { percent: 33.3, resets_at: '2026-08-01T00:00:00Z' } + } + }, + ...overrides +}) + +const account = (state = usageState()): Account => ({ + id: 7, + name: 'opencode', + platform: 'openai', + type: 'apikey', + opencode_go_usage: state, + proxy_id: null, + concurrency: 1, + priority: 1, + status: 'active', + error_message: null, + last_used_at: null, + expires_at: null, + auto_pause_on_expired: false, + created_at: '2026-07-22T00:00:00Z', + updated_at: '2026-07-22T00:00:00Z', + schedulable: true, + rate_limited_at: null, + rate_limit_reset_at: null, + overload_until: null, + temp_unschedulable_until: null, + temp_unschedulable_reason: null, + session_window_start: null, + session_window_end: null, + session_window_status: null +}) + +describe('OpenCodeGoUsageCell', () => { + it('renders rolling, weekly and monthly windows in a shrinkable mobile-safe cell', () => { + const wrapper = mount(OpenCodeGoUsageCell, { props: { account: account() } }) + const cell = wrapper.get('[data-testid="opencode-go-usage-cell"]') + expect(cell.classes()).toEqual(expect.arrayContaining(['min-w-0', 'max-w-full'])) + expect(cell.classes()).not.toContain('min-w-[12rem]') + + const bars = wrapper.findAllComponents(UsageProgressBar) + expect(bars).toHaveLength(3) + expect(bars[0].props()).toMatchObject({ + label: '5h', + utilization: 5.6, + resetsAt: '2026-07-23T03:00:00Z', + color: 'indigo' + }) + expect(bars[1].props()).toMatchObject({ + label: 'W', + utilization: 14.2, + resetsAt: '2026-07-29T00:00:00Z', + color: 'emerald' + }) + expect(bars[2].props()).toMatchObject({ + label: 'M', + utilization: 33.3, + resetsAt: '2026-08-01T00:00:00Z', + color: 'amber' + }) + + expect(wrapper.find('[data-testid="opencode-go-status-badge"]').exists()).toBe(false) + expect(wrapper.findAll('button')).toHaveLength(0) + }) + + it('shows a status badge when the snapshot status is not ok', () => { + const wrapper = mount(OpenCodeGoUsageCell, { + props: { account: account(usageState({ snapshot: { status: 'unauthorized', last_attempt_at: '2026-07-22T12:00:00Z', next_refresh_at: '2026-07-22T13:00:00Z' } })) } + }) + const badge = wrapper.get('[data-testid="opencode-go-status-badge"]') + expect(badge.text()).toBe('unauthorized') + expect(badge.classes()).toEqual(expect.arrayContaining(['bg-amber-100', 'text-amber-700'])) + }) + + it('shows a red badge for failed snapshots', () => { + const wrapper = mount(OpenCodeGoUsageCell, { + props: { account: account(usageState({ snapshot: { status: 'failed', last_attempt_at: '2026-07-22T12:00:00Z', next_refresh_at: '2026-07-22T13:00:00Z', last_error: 'http_error' } })) } + }) + const badge = wrapper.get('[data-testid="opencode-go-status-badge"]') + expect(badge.text()).toBe('failed') + expect(badge.classes()).toEqual(expect.arrayContaining(['bg-red-100', 'text-red-700'])) + }) + + it('renders a dash when the account is not eligible or has no state', () => { + const wrapper = mount(OpenCodeGoUsageCell, { props: { account: account(usageState({ eligible: false })) } }) + expect(wrapper.find('[data-testid="opencode-go-usage-cell"]').exists()).toBe(false) + expect(wrapper.text()).toBe('-') + }) + + it('reacts to an account snapshot update', async () => { + const wrapper = mount(OpenCodeGoUsageCell, { props: { account: account() } }) + const next = usageState() + next.snapshot!.data!.rolling!.percent = 43 + + await wrapper.setProps({ account: account(next) }) + + expect(wrapper.findAllComponents(UsageProgressBar)[0].props('utilization')).toBe(43) + }) +}) diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 5de4a2fcc97e..c3905d02191b 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -229,6 +229,33 @@ export default { OLLAMA_CLOUD_USAGE_REFRESH_RATE_LIMITED: 'Refresh is limited. Try again in {retry_after_seconds} seconds.' } }, + opencodeGo: { + title: 'OpenCode Go usage', + panelHint: 'Usage windows reported by the upstream OpenCode Go account. Refreshed on demand or automatically when enabled.', + notRefreshed: 'Not refreshed', + refreshNow: 'Refresh usage', + autoRefresh: 'Automatic usage refresh', + autoRefreshHint: 'Runs only when the account switch and the global switch are both enabled.', + rolling: '5 hour', + rollingShort: '5h', + weekly: 'Week', + weeklyShort: 'W', + monthly: 'Month', + monthlyShort: 'M', + status: 'Status', + updatedAt: 'Updated', + ok: 'Current', + unauthorized: 'Session expired', + failed: 'Refresh failed', + windowWithReset: '{percent} used, resets {reset}', + loadFailed: 'Failed to load OpenCode Go usage settings', + autoRefreshFailed: 'Failed to update automatic usage refresh', + refreshSuccess: 'OpenCode Go usage refreshed', + refreshFailed: 'Failed to refresh OpenCode Go usage', + errors: { + OPENCODE_GO_USAGE_REFRESH_RATE_LIMITED: 'Refresh is limited. Try again in {retry_after_seconds} seconds.' + } + }, 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', diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index b20e2ebeee09..296e7bfe9d29 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -450,6 +450,16 @@ export default { saved: 'Ollama Cloud usage refresh settings saved', saveFailed: 'Failed to save Ollama Cloud usage refresh settings' }, + opencodeGoUsage: { + title: 'OpenCode Go Usage Refresh', + description: 'Refresh usage windows reported by the upstream OpenCode Go account for individually opted-in accounts. Disabled by default.', + enabled: 'Enable global automatic refresh', + enabledHint: 'Only accounts with their own automatic refresh switch enabled are refreshed. Manual refresh remains available.', + intervalMinutes: 'Refresh interval (minutes)', + intervalHint: 'Range: 5–1440 minutes.', + saved: 'OpenCode Go usage refresh settings saved', + saveFailed: 'Failed to save OpenCode Go usage refresh settings' + }, gatewayForwarding: { title: 'Request Forwarding', description: 'Control how requests are forwarded to upstream OAuth accounts', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index 1359fb0ee495..5eb59011981d 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -175,6 +175,33 @@ export default { OLLAMA_CLOUD_USAGE_REFRESH_RATE_LIMITED: '刷新过于频繁,请在 {retry_after_seconds} 秒后重试。' } }, + opencodeGo: { + title: 'OpenCode Go 用量', + panelHint: '上游 OpenCode Go 账号上报的用量窗口。可手动刷新,或开启自动刷新。', + notRefreshed: '尚未刷新', + refreshNow: '刷新用量', + autoRefresh: '自动刷新用量', + autoRefreshHint: '只有账号开关和全局开关同时启用时才会定时刷新。', + rolling: '5 小时', + rollingShort: '5h', + weekly: '周', + weeklyShort: '周', + monthly: '月', + monthlyShort: '月', + status: '状态', + updatedAt: '更新时间', + ok: '正常', + unauthorized: '会话已过期', + failed: '刷新失败', + windowWithReset: '已用 {percent},{reset} 重置', + loadFailed: '加载 OpenCode Go 用量设置失败', + autoRefreshFailed: '更新自动刷新设置失败', + refreshSuccess: 'OpenCode Go 用量已刷新', + refreshFailed: '刷新 OpenCode Go 用量失败', + errors: { + OPENCODE_GO_USAGE_REFRESH_RATE_LIMITED: '刷新过于频繁,请在 {retry_after_seconds} 秒后重试。' + } + }, upstreamBilling: { trustWarning: '此倍率由上游站点针对当前 API Key 自行声明。Sub2API 无法验证该值是否与实际扣费一致;上游站点或中间代理可能返回伪造、过期或被篡改的数据。请结合账单、余额变化和实际用量自行核验。', autoProbe: '自动探测上游声明倍率', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 3967f25ed73a..c71da82590e7 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -443,6 +443,16 @@ export default { saved: 'Ollama Cloud 用量刷新设置已保存', saveFailed: '保存 Ollama Cloud 用量刷新设置失败' }, + opencodeGoUsage: { + title: 'OpenCode Go 用量刷新', + description: '刷新上游 OpenCode Go 账号上报的用量窗口;默认关闭,仅对单独开启的账号生效。', + enabled: '启用全局自动刷新', + enabledHint: '仅刷新账号自身也开启自动刷新的账号。手动刷新不受影响。', + intervalMinutes: '刷新间隔(分钟)', + intervalHint: '范围 5–1440 分钟。', + saved: 'OpenCode Go 用量刷新设置已保存', + saveFailed: '保存 OpenCode Go 用量刷新设置失败' + }, gatewayForwarding: { title: '请求转发行为', description: '控制请求转发到上游 OAuth 账号时的行为', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 19ef541bc61f..4aaa348fc6ab 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1107,6 +1107,43 @@ export interface OllamaCloudUsageSettings { debounce_minutes: number } +export type OpenCodeGoUsageStatus = 'ok' | 'unauthorized' | 'failed' + +export interface OpenCodeGoUsageWindow { + status?: string + percent: number + resets_at?: string +} + +export interface OpenCodeGoUsageData { + rolling?: OpenCodeGoUsageWindow + weekly?: OpenCodeGoUsageWindow + monthly?: OpenCodeGoUsageWindow +} + +export interface OpenCodeGoUsageSnapshot { + status: OpenCodeGoUsageStatus + data?: OpenCodeGoUsageData + fetched_at?: string + last_attempt_at?: string + next_refresh_at?: string + failure_count?: number + http_status?: number + last_error?: string +} + +export interface OpenCodeGoUsageState { + account_id: number + eligible: boolean + auto_refresh_enabled: boolean + snapshot?: OpenCodeGoUsageSnapshot +} + +export interface OpenCodeGoUsageSettings { + enabled: boolean + interval_minutes: number +} + export interface Account { id: number name: string @@ -1120,6 +1157,7 @@ export interface Account { credentials?: Record credentials_status?: Record ollama_cloud_usage?: OllamaCloudUsageState + opencode_go_usage?: OpenCodeGoUsageState // Extra fields including Codex usage, OpenAI compact capability, and model-level rate limits. extra?: (CodexUsageSnapshot & OpenAICompactState & { model_rate_limits?: Record diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index b153865ff3bc..0cc619c1f675 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -4861,6 +4861,72 @@
+ +
+
+

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

+

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

+
+
+
+
+ {{ t("common.loading") }} +
+ +
+
+
{ loadAdminApiKey(); loadUpstreamBillingProbeSettings(); loadOllamaCloudUsageSettings(); + loadOpenCodeGoUsageSettings(); loadOverloadCooldownSettings(); loadRateLimit429CooldownSettings(); loadPanelRateLimitSettings();