feat(opencode): 支持 OpenCode Go 官方用量窗口查询与自动刷新

OpenCode Go 上游(https://opencode.ai/zen/go/v1)官方提供用量端点
GET /zen/go/v1/usage(Bearer 复用消费 key),返回 rolling(5h)/weekly/monthly
三窗口的 percent 与 resetsAt。本提交为 openai apikey 账号(base_url 为
opencode.ai/zen/go/v1)接入该用量窗口:

- 后端:OpenCodeGoUsageService——手动刷新(每账号 10s 节流)、
  周期自动刷新(全局开关 + 账号开关双开启,interval 5-1440 分钟,
  leader lock + 每轮 20 账号 + 并发 4)、失败指数退避(上限 24h)、
  Retry-After 尊重;401→unauthorized、403→failed;snapshot 持久化于
  accounts.extra(单账号写入,无分组 CAS、无会话加密)
- admin API:settings GET/PUT、/:id/opencode-go-usage GET、
  auto-refresh PUT、refresh POST(与 Ollama Cloud usage 同构)
- 前端:账号列表行内三窗口进度条(复用 UsageProgressBar)、
  编辑弹窗内配置面板(详情+手动刷新+auto-refresh)、全局设置卡片、
  en/zh i18n

验证:
- 上游端点已用生产真实 Go key 实测 200,响应
  {"usage":{"rolling":{"status":"ok","percent":6,...},...}}(2026-08-13)
- go build ./... OK;service/handler/repo/dto/cmd 全量回归 OK
- 新增单测 20 例(解析 401/403/畸形 JSON、退避、节流等)全绿
- frontend vitest 新增 7 例 + Ollama 回归 4 例全绿;vue-tsc 零错误
This commit is contained in:
alfadb
2026-09-23 15:26:35 +08:00
parent afd069b169
commit 6624947b62
27 changed files with 2420 additions and 7 deletions
+7
View File
@@ -125,6 +125,7 @@ func provideCleanup(
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
upstreamBillingProbe *service.UpstreamBillingProbeService,
ollamaCloudUsage *service.OllamaCloudUsageService,
opencodeGoUsage *service.OpenCodeGoUsageService,
auditLog *service.AuditLogService,
openAIAutoReset *service.OpenAIQuotaAutoResetService,
promptAudit *securityaudit.PromptService,
@@ -385,6 +386,12 @@ func provideCleanup(
}
return nil
}},
{"OpenCodeGoUsageService", func() error {
if opencodeGoUsage != nil {
opencodeGoUsage.Stop()
}
return nil
}},
}
infraSteps := []cleanupStep{
+10 -2
View File
@@ -287,7 +287,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
auditLogService := service.ProvideAuditLogService(auditLogRepository, settingService)
auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService)
upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService)
openCodeGoUsageService := service.ProvideOpenCodeGoUsageService(accountRepository, httpUpstream, settingService, leaderLockCache, db)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
@@ -351,7 +352,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, channelMonitorQuotaFetcher)
channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService)
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager)
application := &Application{
Server: httpServer,
PromptAudit: promptService,
@@ -432,6 +433,7 @@ func provideCleanup(
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
upstreamBillingProbe *service.UpstreamBillingProbeService,
ollamaCloudUsage *service.OllamaCloudUsageService,
opencodeGoUsage *service.OpenCodeGoUsageService,
auditLog *service.AuditLogService,
openAIAutoReset *service.OpenAIQuotaAutoResetService,
promptAudit *securityaudit.PromptService,
@@ -691,6 +693,12 @@ func provideCleanup(
}
return nil
}},
{"OpenCodeGoUsageService", func() error {
if opencodeGoUsage != nil {
opencodeGoUsage.Stop()
}
return nil
}},
}
infraSteps := []cleanupStep{
+1
View File
@@ -95,6 +95,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil, // quotaFlusher
nil, // upstreamBillingProbe
nil, // ollamaCloudUsage
nil, // opencodeGoUsage
nil, // auditLog
nil, // openAIAutoReset
nil, // promptAudit
@@ -66,6 +66,7 @@ type AccountHandler struct {
upstreamBillingProbe *service.UpstreamBillingProbeService
ollamaCloudUsage *service.OllamaCloudUsageService
cfg *config.Config
opencodeGoUsage *service.OpenCodeGoUsageService
}
// SetUpstreamBillingProbeService attaches the optional remote billing probe service.
@@ -77,6 +78,10 @@ func (h *AccountHandler) SetOllamaCloudUsageService(usage *service.OllamaCloudUs
h.ollamaCloudUsage = usage
}
func (h *AccountHandler) SetOpenCodeGoUsageService(usage *service.OpenCodeGoUsageService) {
h.opencodeGoUsage = usage
}
// NewAccountHandler creates a new admin account handler
func NewAccountHandler(
adminService service.AdminService,
@@ -0,0 +1,121 @@
package admin
import (
"strconv"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
type openCodeGoUsageAutoRefreshRequest struct {
Enabled *bool `json:"enabled" binding:"required"`
}
func (h *AccountHandler) GetOpenCodeGoUsageSettings(c *gin.Context) {
if h.opencodeGoUsage == nil {
response.ErrorFrom(c, service.ErrOpenCodeGoUsageUnavailable)
return
}
settings, err := h.opencodeGoUsage.GetSettings(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, settings)
}
func (h *AccountHandler) UpdateOpenCodeGoUsageSettings(c *gin.Context) {
if h.opencodeGoUsage == nil {
response.ErrorFrom(c, service.ErrOpenCodeGoUsageUnavailable)
return
}
var req service.OpenCodeGoUsageSettings
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := h.opencodeGoUsage.UpdateSettings(c.Request.Context(), &req); err != nil {
response.ErrorFrom(c, err)
return
}
settings, err := h.opencodeGoUsage.GetSettings(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, settings)
}
func (h *AccountHandler) GetOpenCodeGoUsage(c *gin.Context) {
if !h.requireOpenCodeGoUsage(c) {
return
}
accountID, ok := openCodeGoUsageAccountID(c)
if !ok {
return
}
state, err := h.opencodeGoUsage.GetState(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, state)
}
func (h *AccountHandler) SetOpenCodeGoUsageAutoRefresh(c *gin.Context) {
if !h.requireOpenCodeGoUsage(c) {
return
}
accountID, ok := openCodeGoUsageAccountID(c)
if !ok {
return
}
var req openCodeGoUsageAutoRefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
state, err := h.opencodeGoUsage.SetAutoRefresh(c.Request.Context(), accountID, *req.Enabled)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, state)
}
func (h *AccountHandler) RefreshOpenCodeGoUsage(c *gin.Context) {
if !h.requireOpenCodeGoUsage(c) {
return
}
accountID, ok := openCodeGoUsageAccountID(c)
if !ok {
return
}
state, err := h.opencodeGoUsage.Refresh(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, state)
}
func (h *AccountHandler) requireOpenCodeGoUsage(c *gin.Context) bool {
if h != nil && h.opencodeGoUsage != nil {
return true
}
response.ErrorFrom(c, service.ErrOpenCodeGoUsageUnavailable)
return false
}
func openCodeGoUsageAccountID(c *gin.Context) (int64, bool) {
if c == nil {
return 0, false
}
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || accountID <= 0 {
response.BadRequest(c, "Invalid account ID")
return 0, false
}
return accountID, true
}
@@ -0,0 +1,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")
}
}
+8 -1
View File
@@ -241,6 +241,10 @@ func AccountFromServiceShallow(a *service.Account) *Account {
if state := service.OllamaCloudUsageStateFromAccount(a); state.Eligible {
ollamaCloudUsage = state
}
var openCodeGoUsage *service.OpenCodeGoUsageState
if state := service.OpenCodeGoUsageStateFromAccount(a); state.Eligible {
openCodeGoUsage = state
}
out := &Account{
ID: a.ID,
Name: a.Name,
@@ -251,6 +255,7 @@ func AccountFromServiceShallow(a *service.Account) *Account {
CredentialsStatus: credsStatus,
Extra: extra,
OllamaCloudUsage: ollamaCloudUsage,
OpenCodeGoUsage: openCodeGoUsage,
ProxyID: a.ProxyID,
ProxyFallbackOriginID: a.ProxyFallbackOriginID,
ProxyFallbackOriginName: a.ProxyFallbackOriginName,
@@ -417,7 +422,9 @@ func redactAccountManagedExtra(extra map[string]any) map[string]any {
switch key {
case service.OllamaCloudUsageSessionExtraKey,
service.OllamaCloudUsageAutoRefreshExtraKey,
service.OllamaCloudUsageSnapshotExtraKey:
service.OllamaCloudUsageSnapshotExtraKey,
service.OpenCodeGoUsageAutoRefreshExtraKey,
service.OpenCodeGoUsageSnapshotExtraKey:
continue
default:
redacted[key] = value
+1
View File
@@ -216,6 +216,7 @@ type Account struct {
CredentialsStatus map[string]bool `json:"credentials_status,omitempty"`
Extra map[string]any `json:"extra"`
OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage,omitempty"`
OpenCodeGoUsage *service.OpenCodeGoUsageState `json:"opencode_go_usage,omitempty"`
ProxyID *int64 `json:"proxy_id"`
ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"`
ProxyFallbackOriginName *string `json:"proxy_fallback_origin_name,omitempty"`
+2
View File
@@ -49,9 +49,11 @@ func ProvideAdminHandlers(
auditLogHandler *admin.AuditLogHandler,
upstreamBillingProbe *service.UpstreamBillingProbeService,
ollamaCloudUsage *service.OllamaCloudUsageService,
opencodeGoUsage *service.OpenCodeGoUsageService,
) *AdminHandlers {
accountHandler.SetUpstreamBillingProbeService(upstreamBillingProbe)
accountHandler.SetOllamaCloudUsageService(ollamaCloudUsage)
accountHandler.SetOpenCodeGoUsageService(opencodeGoUsage)
return &AdminHandlers{
Dashboard: dashboardHandler,
User: userHandler,
@@ -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
}
+5
View File
@@ -364,6 +364,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu
accounts.POST("/upstream-billing-probe/batch", h.Admin.Account.ProbeUpstreamBillingBatch)
accounts.GET("/ollama-cloud-usage/settings", h.Admin.Account.GetOllamaCloudUsageSettings)
accounts.PUT("/ollama-cloud-usage/settings", h.Admin.Account.UpdateOllamaCloudUsageSettings)
accounts.GET("/opencode-go-usage/settings", h.Admin.Account.GetOpenCodeGoUsageSettings)
accounts.PUT("/opencode-go-usage/settings", h.Admin.Account.UpdateOpenCodeGoUsageSettings)
accounts.GET("/:id", h.Admin.Account.GetByID)
accounts.POST("", h.Admin.Account.Create)
accounts.POST("/:id/duplicate", h.Admin.Account.Duplicate)
@@ -381,6 +383,9 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu
accounts.DELETE("/:id/ollama-cloud-usage/session", h.Admin.Account.DeleteOllamaCloudUsageSession)
accounts.PUT("/:id/ollama-cloud-usage/auto-refresh", h.Admin.Account.SetOllamaCloudUsageAutoRefresh)
accounts.POST("/:id/ollama-cloud-usage/refresh", h.Admin.Account.RefreshOllamaCloudUsage)
accounts.GET("/:id/opencode-go-usage", h.Admin.Account.GetOpenCodeGoUsage)
accounts.PUT("/:id/opencode-go-usage/auto-refresh", h.Admin.Account.SetOpenCodeGoUsageAutoRefresh)
accounts.POST("/:id/opencode-go-usage/refresh", h.Admin.Account.RefreshOpenCodeGoUsage)
accounts.DELETE("/:id", h.Admin.Account.Delete)
accounts.POST("/:id/test", h.Admin.Account.Test)
accounts.POST("/:id/recover-state", h.Admin.Account.RecoverState)
@@ -570,6 +570,9 @@ const (
// SettingKeyOllamaCloudUsageSettings stores the opt-in global runner switch and interval.
SettingKeyOllamaCloudUsageSettings = "ollama_cloud_usage_settings"
// SettingKeyOpenCodeGoUsageSettings stores the opt-in global runner switch and interval.
SettingKeyOpenCodeGoUsageSettings = "opencode_go_usage_settings"
// =========================
// Overload Cooldown (529)
// =========================
@@ -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
}
@@ -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())
}
+1
View File
@@ -907,6 +907,7 @@ var ProviderSet = wire.NewSet(
ProvideAccountTestService,
ProvideUpstreamBillingProbeService,
ProvideOllamaCloudUsageService,
ProvideOpenCodeGoUsageService,
ProvideSettingService,
NewDataManagementService,
ProvideBackupService,
@@ -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')
})
})
+41 -2
View File
@@ -29,7 +29,9 @@ import type {
OllamaCloudUsageSettings,
OllamaCloudUsageState,
GrokMediaEligibilityMode,
GrokMediaEligibilityState
GrokMediaEligibilityState,
OpenCodeGoUsageSettings,
OpenCodeGoUsageState
} from '@/types'
/**
@@ -1097,6 +1099,38 @@ export async function refreshOllamaCloudUsage(id: number): Promise<OllamaCloudUs
return data
}
export async function getOpenCodeGoUsageSettings(): Promise<OpenCodeGoUsageSettings> {
const { data } = await apiClient.get<OpenCodeGoUsageSettings>('/admin/accounts/opencode-go-usage/settings')
return data
}
export async function updateOpenCodeGoUsageSettings(
settings: OpenCodeGoUsageSettings
): Promise<OpenCodeGoUsageSettings> {
const { data } = await apiClient.put<OpenCodeGoUsageSettings>(
'/admin/accounts/opencode-go-usage/settings',
settings
)
return data
}
export async function getOpenCodeGoUsage(id: number): Promise<OpenCodeGoUsageState> {
const { data } = await apiClient.get<OpenCodeGoUsageState>(`/admin/accounts/${id}/opencode-go-usage`)
return data
}
export async function setOpenCodeGoUsageAutoRefresh(id: number, enabled: boolean): Promise<OpenCodeGoUsageState> {
const { data } = await apiClient.put<OpenCodeGoUsageState>(`/admin/accounts/${id}/opencode-go-usage/auto-refresh`, {
enabled
})
return data
}
export async function refreshOpenCodeGoUsage(id: number): Promise<OpenCodeGoUsageState> {
const { data } = await apiClient.post<OpenCodeGoUsageState>(`/admin/accounts/${id}/opencode-go-usage/refresh`)
return data
}
export const accountsAPI = {
list,
listWithEtag,
@@ -1160,7 +1194,12 @@ export const accountsAPI = {
saveOllamaCloudUsageSession,
deleteOllamaCloudUsageSession,
setOllamaCloudUsageAutoRefresh,
refreshOllamaCloudUsage
refreshOllamaCloudUsage,
getOpenCodeGoUsageSettings,
updateOpenCodeGoUsageSettings,
getOpenCodeGoUsage,
setOpenCodeGoUsageAutoRefresh,
refreshOpenCodeGoUsage
}
export default accountsAPI
@@ -581,6 +581,10 @@
:account="account"
@updated="handleOllamaCloudUsageUpdated"
/>
<OpenCodeGoUsageCell
v-if="account.opencode_go_usage?.eligible"
:account="account"
/>
<!-- Today stats row (requests, tokens, cost, user_cost) -->
<div
v-if="todayStats"
@@ -662,6 +666,7 @@ import CNProviderQuotaCell from './CNProviderQuotaCell.vue'
import CNProviderBalanceCell from './CNProviderBalanceCell.vue'
import OllamaCloudUsageCell from './OllamaCloudUsageCell.vue'
import { cnQuotaCellVisible as cnQuotaCellVisibleFn, cnBalanceCellVisible as cnBalanceCellVisibleFn } from './credentialsBuilder'
import OpenCodeGoUsageCell from './OpenCodeGoUsageCell.vue' (feat(opencode): 支持 OpenCode Go 官方用量窗口查询与自动刷新)
// Module-level cache shared across all AccountUsageCell instances
const _usageCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
@@ -1992,6 +1992,91 @@
@updated="handleOllamaCloudUsageUpdated"
/>
<section
v-if="account?.opencode_go_usage?.eligible"
class="space-y-4 border-t border-gray-200 pt-4 dark:border-dark-600"
data-testid="opencode-go-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.opencodeGo.title') }}
</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.opencodeGo.panelHint') }}
</p>
</div>
<span
class="whitespace-nowrap rounded px-2 py-1 text-xs font-medium"
:class="opencodeGoStatusOk
? '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'"
>
{{ opencodeGoStatusLabel }}
</span>
</div>
<div v-if="opencodeGoLoading" 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="opencodeGoSnapshot"
class="border-y border-gray-100 py-3 dark:border-dark-700"
data-testid="opencode-go-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.opencodeGo.rolling') }}</span>
<span class="break-words text-gray-900 dark:text-white">{{ opencodeGoWindowSummary(opencodeGoSnapshot.data?.rolling) }}</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.opencodeGo.weekly') }}</span>
<span class="break-words text-gray-900 dark:text-white">{{ opencodeGoWindowSummary(opencodeGoSnapshot.data?.weekly) }}</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.opencodeGo.monthly') }}</span>
<span class="break-words text-gray-900 dark:text-white">{{ opencodeGoWindowSummary(opencodeGoSnapshot.data?.monthly) }}</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.opencodeGo.status') }}</span>
<span class="break-words font-medium text-gray-900 dark:text-white">{{ opencodeGoStatusLabel }}</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.opencodeGo.updatedAt') }}</span>
<span class="break-words text-gray-900 dark:text-white">{{ opencodeGoFormatDate(opencodeGoSnapshot.fetched_at || opencodeGoSnapshot.last_attempt_at) }}</span>
</div>
<p
v-if="opencodeGoSnapshot.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.opencodeGo.errors.${opencodeGoSnapshot.last_error}`, opencodeGoSnapshot.last_error) }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="btn btn-secondary btn-sm"
:disabled="opencodeGoRefreshing"
data-testid="opencode-go-refresh"
@click="refreshOpenCodeGoUsage"
>
<Icon name="refresh" size="xs" class="mr-1.5" :class="{ 'animate-spin': opencodeGoRefreshing }" />
{{ t('admin.accounts.opencodeGo.refreshNow') }}
</button>
</div>
<div 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.opencodeGo.autoRefresh') }}
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.opencodeGo.autoRefreshHint') }}
</p>
</div>
<Toggle
:model-value="opencodeGoState?.auto_refresh_enabled ?? false"
:disabled="opencodeGoSaving"
data-testid="opencode-go-auto-refresh"
@update:model-value="setOpenCodeGoAutoRefresh"
/>
</div>
</template>
</section>
<!-- Anthropic API Key 自动透传开关 -->
<div
v-if="account?.platform === 'anthropic' && account?.type === 'apikey'"
@@ -3019,7 +3104,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from 'vue'
import { ref, reactive, computed, watch, nextTick, onMounted } from 'vue' (feat(opencode): 支持 OpenCode Go 官方用量窗口查询与自动刷新)
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
@@ -3036,7 +3121,9 @@ import type {
OpenAIEndpointCapability,
OllamaCloudUsageState,
GrokMediaEligibilityMode,
GrokMediaEligibilityState
GrokMediaEligibilityState,
OpenCodeGoUsageState,
OpenCodeGoUsageWindow
} from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
@@ -3091,6 +3178,7 @@ import {
getBrowserTimeZone,
parseDateTimeLocalInput
} from '@/utils/format'
import { extractApiErrorMessage, extractI18nErrorMessage } from '@/utils/apiError'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import { getAccountExpiryTimestamp } from '@/components/account/accountExpiry'
import { allSelectedGroupsEnableLongContextPricing } from '@/components/account/longContextBilling'
@@ -3153,6 +3241,88 @@ const handleOllamaCloudUsageUpdated = (state: OllamaCloudUsageState) => {
if (props.account) emit('updated', { ...props.account, ollama_cloud_usage: state })
}
// OpenCode Go usage panel state
const opencodeGoState = ref<OpenCodeGoUsageState | null>(props.account?.opencode_go_usage ?? null)
const opencodeGoLoading = ref(false)
const opencodeGoSaving = ref(false)
const opencodeGoRefreshing = ref(false)
const opencodeGoSnapshot = computed(() => opencodeGoState.value?.snapshot)
const opencodeGoStatusOk = computed(() => opencodeGoSnapshot.value?.status === 'ok')
const opencodeGoStatusLabel = computed(() => {
if (!opencodeGoSnapshot.value) return t('admin.accounts.opencodeGo.notRefreshed')
if (opencodeGoSnapshot.value.status === 'unauthorized') return t('admin.accounts.opencodeGo.unauthorized')
if (opencodeGoSnapshot.value.status === 'failed') return t('admin.accounts.opencodeGo.failed')
return t('admin.accounts.opencodeGo.ok')
})
const opencodeGoFormatPercent = (value?: number) => typeof value === 'number' && Number.isFinite(value)
? `${value.toFixed(value % 1 ? 1 : 0)}%`
: '-'
const opencodeGoFormatDate = (value?: string) => {
if (!value) return '-'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
}
const opencodeGoWindowSummary = (window?: OpenCodeGoUsageWindow) => {
if (!window) return '-'
const reset = window.resets_at ? opencodeGoFormatDate(window.resets_at) : null
return reset
? t('admin.accounts.opencodeGo.windowWithReset', { percent: opencodeGoFormatPercent(window.percent), reset })
: opencodeGoFormatPercent(window.percent)
}
const applyOpenCodeGoState = (next: OpenCodeGoUsageState) => {
opencodeGoState.value = next
if (props.account) emit('updated', { ...props.account, opencode_go_usage: next })
}
const loadOpenCodeGoUsage = async () => {
opencodeGoLoading.value = true
try {
applyOpenCodeGoState(await adminAPI.accounts.getOpenCodeGoUsage(props.account!.id))
} catch (error) {
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.opencodeGo.loadFailed')))
} finally {
opencodeGoLoading.value = false
}
}
const setOpenCodeGoAutoRefresh = async (enabled: boolean) => {
opencodeGoSaving.value = true
try {
applyOpenCodeGoState(await adminAPI.accounts.setOpenCodeGoUsageAutoRefresh(props.account!.id, enabled))
} catch (error) {
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.opencodeGo.autoRefreshFailed')))
} finally {
opencodeGoSaving.value = false
}
}
const refreshOpenCodeGoUsage = async () => {
opencodeGoRefreshing.value = true
try {
applyOpenCodeGoState(await adminAPI.accounts.refreshOpenCodeGoUsage(props.account!.id))
appStore.showSuccess(t('admin.accounts.opencodeGo.refreshSuccess'))
} catch (error) {
appStore.showError(extractI18nErrorMessage(
error,
t,
'admin.accounts.opencodeGo.errors',
t('admin.accounts.opencodeGo.refreshFailed')
))
} finally {
opencodeGoRefreshing.value = false
}
}
watch(() => props.account?.id, () => {
opencodeGoState.value = props.account?.opencode_go_usage ?? null
if (opencodeGoState.value && !opencodeGoState.value.snapshot) void loadOpenCodeGoUsage()
})
onMounted(() => {
if (opencodeGoState.value && !opencodeGoState.value.snapshot) void loadOpenCodeGoUsage()
})
// Platform-specific hint for Base URL
const baseUrlHint = computed(() => {
if (!props.account) return t('admin.accounts.baseUrlHint')
@@ -0,0 +1,60 @@
<template>
<div
v-if="state?.eligible"
class="min-w-0 max-w-full space-y-1"
data-testid="opencode-go-usage-cell"
>
<UsageProgressBar
v-if="snapshot?.data?.rolling"
:label="t('admin.accounts.opencodeGo.rollingShort')"
:utilization="snapshot.data.rolling.percent"
:resets-at="snapshot.data.rolling.resets_at"
color="indigo"
data-testid="opencode-go-rolling"
/>
<UsageProgressBar
v-if="snapshot?.data?.weekly"
:label="t('admin.accounts.opencodeGo.weeklyShort')"
:utilization="snapshot.data.weekly.percent"
:resets-at="snapshot.data.weekly.resets_at"
color="emerald"
data-testid="opencode-go-weekly"
/>
<UsageProgressBar
v-if="snapshot?.data?.monthly"
:label="t('admin.accounts.opencodeGo.monthlyShort')"
:utilization="snapshot.data.monthly.percent"
:resets-at="snapshot.data.monthly.resets_at"
color="amber"
data-testid="opencode-go-monthly"
/>
<span
v-if="snapshot && snapshot.status !== 'ok'"
class="inline-block rounded px-1.5 py-0.5 text-[10px] font-medium"
:class="snapshot.status === 'unauthorized'
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'"
data-testid="opencode-go-status-badge"
>
{{ statusLabel }}
</span>
</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 { useI18n } from 'vue-i18n'
import type { Account } from '@/types'
import UsageProgressBar from './UsageProgressBar.vue'
const props = defineProps<{ account: Account }>()
const { t } = useI18n()
const state = computed(() => props.account.opencode_go_usage)
const snapshot = computed(() => state.value?.snapshot)
const statusLabel = computed(() => {
if (snapshot.value?.status === 'unauthorized') return t('admin.accounts.opencodeGo.unauthorized')
if (snapshot.value?.status === 'failed') return t('admin.accounts.opencodeGo.failed')
return t('admin.accounts.opencodeGo.ok')
})
</script>
@@ -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<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => {
const short: Record<string, string> = {
'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> = {}): 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)
})
})
@@ -299,6 +299,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',
@@ -477,6 +477,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',
@@ -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: '自动探测上游声明倍率',
@@ -470,6 +470,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 账号时的行为',
+38
View File
@@ -1155,6 +1155,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
@@ -1168,6 +1205,7 @@ export interface Account {
credentials?: Record<string, unknown>
credentials_status?: Record<string, boolean>
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<string, { rate_limited_at: string; rate_limit_reset_at: string }>
+105
View File
@@ -4910,6 +4910,72 @@
</div>
</div>
<!-- OpenCode Go Usage Settings -->
<div class="card" data-testid="opencode-go-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.opencodeGoUsage.title") }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.opencodeGoUsage.description") }}
</p>
</div>
<div class="space-y-5 p-6">
<div v-if="opencodeGoUsageLoading" 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.opencodeGoUsage.enabled") }}
</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.opencodeGoUsage.enabledHint") }}
</p>
</div>
<Toggle
v-model="opencodeGoUsageForm.enabled"
:aria-label="t('admin.settings.opencodeGoUsage.enabled')"
data-testid="opencode-go-usage-global-enabled"
/>
</div>
<div v-if="opencodeGoUsageForm.enabled" class="space-y-4 border-t border-gray-100 pt-4 dark:border-dark-700">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300" for="opencode-go-usage-interval">
{{ t("admin.settings.opencodeGoUsage.intervalMinutes") }}
</label>
<input
id="opencode-go-usage-interval"
v-model.number="opencodeGoUsageForm.interval_minutes"
type="number"
min="5"
max="1440"
class="input w-32"
data-testid="opencode-go-usage-global-interval"
@keydown.enter.prevent="saveOpenCodeGoUsageSettings"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.opencodeGoUsage.intervalHint") }}
</p>
</div>
</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="opencodeGoUsageSaving"
data-testid="opencode-go-usage-global-save"
@click="saveOpenCodeGoUsageSettings"
>
{{ opencodeGoUsageSaving ? t("common.saving") : t("common.save") }}
</button>
</div>
</template>
</div>
</div>
<!-- Gateway Scheduling Settings -->
<div class="card">
<div
@@ -9088,6 +9154,13 @@ const ollamaCloudUsageForm = reactive({
debounce_minutes: 1,
});
const opencodeGoUsageLoading = ref(true);
const opencodeGoUsageSaving = ref(false);
const opencodeGoUsageForm = reactive({
enabled: false,
interval_minutes: 60,
});
// Overload Cooldown (529) 状态
const overloadCooldownLoading = ref(true);
const overloadCooldownSaving = ref(false);
@@ -11980,6 +12053,37 @@ async function saveOllamaCloudUsageSettings() {
}
}
async function loadOpenCodeGoUsageSettings() {
opencodeGoUsageLoading.value = true;
try {
Object.assign(
opencodeGoUsageForm,
await adminAPI.accounts.getOpenCodeGoUsageSettings(),
);
} catch (_error: unknown) {
// Keep the fail-safe disabled defaults when this optional setting cannot be loaded.
} finally {
opencodeGoUsageLoading.value = false;
}
}
async function saveOpenCodeGoUsageSettings() {
opencodeGoUsageSaving.value = true;
try {
const updated = await adminAPI.accounts.updateOpenCodeGoUsageSettings({
...opencodeGoUsageForm,
});
Object.assign(opencodeGoUsageForm, updated);
appStore.showSuccess(t("admin.settings.opencodeGoUsage.saved"));
} catch (error: unknown) {
appStore.showError(
extractApiErrorMessage(error, t("admin.settings.opencodeGoUsage.saveFailed")),
);
} finally {
opencodeGoUsageSaving.value = false;
}
}
// Overload Cooldown 方法
async function loadOverloadCooldownSettings() {
overloadCooldownLoading.value = true;
@@ -12730,6 +12834,7 @@ onMounted(() => {
loadAdminApiKey();
loadUpstreamBillingProbeSettings();
loadOllamaCloudUsageSettings();
loadOpenCodeGoUsageSettings();
loadOverloadCooldownSettings();
loadRateLimit429CooldownSettings();
loadPanelRateLimitSettings();