2026-08-13 22:22:37 +08:00
package service
import (
"context"
2026-08-13 23:41:01 +08:00
"crypto/sha256"
2026-08-13 22:22:37 +08:00
"database/sql"
2026-08-13 23:41:01 +08:00
"encoding/hex"
2026-08-13 22:22:37 +08:00
"encoding/json"
"errors"
"fmt"
"io"
2026-08-13 23:41:01 +08:00
"maps"
2026-08-13 22:22:37 +08:00
"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"
2026-08-13 23:41:01 +08:00
// OpenCodeGoUsageMinFetchInterval is the hard floor between two successful
// fetches of the same group, mirroring the floor nextOpenCodeGoUsageDelay
// applies to next_refresh_at. Activity may bring a refresh forward to this
// bound but never past it. Exported so the repository can apply the same
// floor inside the SQL due filter.
OpenCodeGoUsageMinFetchInterval = opencodeGoUsageMinIntervalMinutes * time . Minute
2026-08-13 22:22:37 +08:00
opencodeGoUsageAPIURL = "https://opencode.ai/zen/go/v1/usage"
opencodeGoUsageDefaultIntervalMinutes = 15
opencodeGoUsageMinIntervalMinutes = 5
opencodeGoUsageMaxIntervalMinutes = 24 * 60
2026-08-13 23:41:01 +08:00
opencodeGoUsageDefaultDebounceMinutes = 1
opencodeGoUsageMinDebounceMinutes = 1
opencodeGoUsageMaxDebounceMinutes = 60
2026-08-13 22:22:37 +08:00
opencodeGoUsageCycleInterval = time . Minute
2026-08-13 23:41:01 +08:00
opencodeGoUsageManualRefreshInterval = 30 * time . Second
2026-08-13 22:22:37 +08:00
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 (
2026-09-11 20:34:35 +08:00
"OPENCODE_GO_USAGE_ACCOUNT_INVALID" ,
"account must be an OpenCode Go platform subscription (go mode) account, or an API key account under openai/anthropic/kimi/zhipu/deepseek/minimax with base_url pointing at the official OpenCode Go base URLs" ,
2026-08-13 22:22:37 +08:00
)
ErrOpenCodeGoUsageIdentityChanged = infraerrors . Conflict (
"OPENCODE_GO_USAGE_IDENTITY_CHANGED" , "account identity or proxy changed during refresh; retry" ,
)
ErrOpenCodeGoUsageRefreshRateLimited = infraerrors . TooManyRequests (
2026-08-13 23:41:01 +08:00
"OPENCODE_GO_USAGE_REFRESH_RATE_LIMITED" , "OpenCode Go usage can be refreshed manually once every 30 seconds" ,
2026-08-13 22:22:37 +08:00
)
)
const (
OpenCodeGoUsageStatusOK = "ok"
OpenCodeGoUsageStatusUnauthorized = "unauthorized"
OpenCodeGoUsageStatusFailed = "failed"
)
2026-08-13 23:41:01 +08:00
// OpenCodeGoUsageSettings controls the opt-in request-driven refresh runner.
//
// IntervalMinutes is the max-wait bound: when model requests keep arriving and
// the trailing debounce keeps sliding, a refresh is forced after this long.
// DebounceMinutes is the quiet period after the latest request in a group.
2026-08-13 22:22:37 +08:00
type OpenCodeGoUsageSettings struct {
Enabled bool `json:"enabled"`
2026-08-13 23:41:01 +08:00
IntervalMinutes int `json:"interval_minutes"` // max wait while requests continue
DebounceMinutes int `json:"debounce_minutes"` // trailing quiet period after last request
2026-08-13 22:22:37 +08:00
}
// 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.
2026-08-13 23:41:01 +08:00
//
// NextRefreshAt remains a persisted compatibility field. For status=ok it is a
// max-wait horizon marker only; automatic success refreshes are driven by model
// request activity (group last_used_at + debounce/max-wait), not by this field
// alone. For failed/unauthorized snapshots it is the failure not-before time
// (Retry-After / exponential backoff) and is enforced as max(activityDue, NextRefreshAt).
2026-08-13 22:22:37 +08:00
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 {
2026-08-13 23:41:01 +08:00
ListOpenCodeGoUsageGroupAccounts ( context . Context , [] * Account ) ([] Account , error )
2026-08-13 22:22:37 +08:00
SetOpenCodeGoUsageAutoRefresh ( context . Context , * Account , bool ) error
UpdateOpenCodeGoUsageSnapshot ( context . Context , * Account , * OpenCodeGoUsageSnapshot ) error
2026-08-13 23:41:01 +08:00
DisableOpenCodeGoUsageAutoRefresh ( context . Context , * Account ) error
ListDueOpenCodeGoUsageAccounts ( context . Context , time . Time , time . Duration , time . Duration , int ) ([] Account , error )
2026-08-13 22:22:37 +08:00
}
// 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
}
2026-08-13 23:41:01 +08:00
if settings . DebounceMinutes == 0 {
settings . DebounceMinutes = defaults . DebounceMinutes
}
2026-08-13 22:22:37 +08:00
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" )
}
2026-08-13 23:41:01 +08:00
if settings . DebounceMinutes == 0 {
// Legacy clients that omit debounce_minutes keep the fail-safe default.
settings . DebounceMinutes = opencodeGoUsageDefaultDebounceMinutes
}
2026-08-13 22:22:37 +08:00
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 ),
)
}
2026-08-13 23:41:01 +08:00
if settings . DebounceMinutes < opencodeGoUsageMinDebounceMinutes || settings . DebounceMinutes > opencodeGoUsageMaxDebounceMinutes {
return infraerrors . BadRequest (
"INVALID_OPENCODE_GO_USAGE_DEBOUNCE" ,
fmt . Sprintf ( "debounce_minutes must be between %d and %d" , opencodeGoUsageMinDebounceMinutes , opencodeGoUsageMaxDebounceMinutes ),
)
}
// The due time is min(lastUsed+debounce, fetchedAt+maxWait). Once the debounce
// reaches the max wait the debounce term can never win, so the knob would be
// silently inert instead of doing what the operator asked for.
if settings . DebounceMinutes >= settings . IntervalMinutes {
return infraerrors . BadRequest (
"INVALID_OPENCODE_GO_USAGE_DEBOUNCE" ,
fmt . Sprintf ( "debounce_minutes (%d) must be less than interval_minutes (%d)" , settings . DebounceMinutes , settings . IntervalMinutes ),
)
}
2026-08-13 22:22:37 +08:00
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 ,
2026-08-13 23:41:01 +08:00
DebounceMinutes : opencodeGoUsageDefaultDebounceMinutes ,
2026-08-13 22:22:37 +08:00
}
}
func normalizeOpenCodeGoUsageSettings ( settings * OpenCodeGoUsageSettings ) {
if settings . IntervalMinutes < opencodeGoUsageMinIntervalMinutes {
settings . IntervalMinutes = opencodeGoUsageMinIntervalMinutes
}
if settings . IntervalMinutes > opencodeGoUsageMaxIntervalMinutes {
settings . IntervalMinutes = opencodeGoUsageMaxIntervalMinutes
}
2026-08-13 23:41:01 +08:00
if settings . DebounceMinutes <= 0 {
settings . DebounceMinutes = opencodeGoUsageDefaultDebounceMinutes
}
if settings . DebounceMinutes < opencodeGoUsageMinDebounceMinutes {
settings . DebounceMinutes = opencodeGoUsageMinDebounceMinutes
}
if settings . DebounceMinutes > opencodeGoUsageMaxDebounceMinutes {
settings . DebounceMinutes = opencodeGoUsageMaxDebounceMinutes
}
}
func openCodeGoUsageDurations ( settings * OpenCodeGoUsageSettings ) ( debounce , maxWait time . Duration ) {
normalized := defaultOpenCodeGoUsageSettings ()
if settings != nil {
* normalized = * settings
}
normalizeOpenCodeGoUsageSettings ( normalized )
return time . Duration ( normalized . DebounceMinutes ) * time . Minute ,
time . Duration ( normalized . IntervalMinutes ) * time . Minute
2026-08-13 22:22:37 +08:00
}
// openCodeGoUsageIsAutoRefreshDue decides whether a configured auto-refresh
2026-08-13 23:41:01 +08:00
// group should fetch now. groupLastUsedAt must be MAX(last_used_at) across the
// exact api_key group so shared multi-account groups do not miss activity.
//
// Success: a request must be newer than fetched_at; dueAt = min(lastUsed+debounce, fetchedAt+maxWait).
// Failure: a request must be newer than last_attempt_at; activity due uses the same min formula,
// then dueAt = max(activityDue, next_refresh_at) so Retry-After / exponential backoff win.
// Missing or invalid snapshots fail open to a first fetch.
func openCodeGoUsageIsAutoRefreshDue (
snapshot * OpenCodeGoUsageSnapshot ,
groupLastUsedAt * time . Time ,
now time . Time ,
debounce , maxWait time . Duration ,
) bool {
dueAt , ok := openCodeGoUsageAutoRefreshDueAt ( snapshot , groupLastUsedAt , debounce , maxWait )
if ! ok {
return false
}
return ! now . Before ( dueAt )
}
func openCodeGoUsageAutoRefreshDueAt (
snapshot * OpenCodeGoUsageSnapshot ,
groupLastUsedAt * time . Time ,
debounce , maxWait time . Duration ,
) ( time . Time , bool ) {
if debounce <= 0 {
debounce = time . Duration ( opencodeGoUsageDefaultDebounceMinutes ) * time . Minute
}
if maxWait <= 0 {
maxWait = time . Duration ( opencodeGoUsageDefaultIntervalMinutes ) * time . Minute
}
2026-08-13 22:22:37 +08:00
if snapshot == nil {
2026-08-13 23:41:01 +08:00
return time . Time {}, true
2026-08-13 22:22:37 +08:00
}
2026-08-13 23:41:01 +08:00
switch snapshot . Status {
case OpenCodeGoUsageStatusOK :
if snapshot . FetchedAt == nil || snapshot . FetchedAt . IsZero () {
return time . Time {}, true
}
fetchedAt := snapshot . FetchedAt . UTC ()
if groupLastUsedAt == nil || ! groupLastUsedAt . After ( fetchedAt ) {
return time . Time {}, false
}
lastUsed := groupLastUsedAt . UTC ()
dueAt := minTime ( lastUsed . Add ( debounce ), fetchedAt . Add ( maxWait ))
// Keep the pre-existing hard floor between successful fetches. The success
// path no longer consults next_refresh_at, which is where
// nextOpenCodeGoUsageDelay used to apply opencodeGoUsageMinIntervalMinutes;
// without this, request traffic spaced slightly wider than the debounce
// drives the group's outbound rate far above the previous minimum.
if floor := fetchedAt . Add ( OpenCodeGoUsageMinFetchInterval ); dueAt . Before ( floor ) {
return floor , true
}
return dueAt , true
case OpenCodeGoUsageStatusFailed , OpenCodeGoUsageStatusUnauthorized :
if snapshot . LastAttemptAt . IsZero () {
return time . Time {}, true
}
lastAttempt := snapshot . LastAttemptAt . UTC ()
if groupLastUsedAt == nil || ! groupLastUsedAt . After ( lastAttempt ) {
return time . Time {}, false
}
lastUsed := groupLastUsedAt . UTC ()
activityDue := minTime ( lastUsed . Add ( debounce ), lastAttempt . Add ( maxWait ))
if ! snapshot . NextRefreshAt . IsZero () && snapshot . NextRefreshAt . UTC (). After ( activityDue ) {
return snapshot . NextRefreshAt . UTC (), true
}
return activityDue , true
default :
return time . Time {}, true
2026-08-13 22:22:37 +08:00
}
2026-08-13 23:41:01 +08:00
}
// maxOpenCodeGoUsageGroupLastUsed returns the newest last_used_at among group members.
func maxOpenCodeGoUsageGroupLastUsed ( accounts [] Account ) * time . Time {
var latest * time . Time
for i := range accounts {
candidate := accounts [ i ]. LastUsedAt
if candidate == nil || candidate . IsZero () {
continue
}
if latest == nil || candidate . After ( * latest ) {
ts := candidate . UTC ()
latest = & ts
}
}
return latest
}
// scheduleOpenCodeGoUsageActivity records that an OpenCode Go API-key account
// actually attempted an upstream model request (including 429/5xx/transport errors).
// Local auth/validation failures must not call this. DeferredService dedupes writes.
func scheduleOpenCodeGoUsageActivity ( deferred * DeferredService , account * Account ) {
if deferred == nil || account == nil || ! IsOpenCodeGoUsageAccount ( account ) {
return
}
deferred . ScheduleLastUsedUpdate ( account . ID )
2026-08-13 22:22:37 +08:00
}
// 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
}
2026-08-13 23:41:01 +08:00
if err := s . ResolveOpenCodeGoUsageAccounts ( ctx , [] * Account { account }); err != nil {
return nil , err
}
2026-08-13 22:22:37 +08:00
return OpenCodeGoUsageStateFromAccount ( account ), nil
}
2026-08-13 23:41:01 +08:00
// ResolveOpenCodeGoUsageAccounts overlays group-owned managed state onto the
// supplied account objects. The repository resolves all matching siblings in one
// bounded query, so account-list responses do not issue one query per row.
func ( s * OpenCodeGoUsageService ) ResolveOpenCodeGoUsageAccounts ( ctx context . Context , accounts [] * Account ) error {
if s == nil || s . accountRepo == nil || len ( accounts ) == 0 {
return nil
}
writer , ok := s . accountRepo .( openCodeGoUsageRepository )
if ! ok {
return nil
}
eligible := make ([] * Account , 0 , len ( accounts ))
for _ , account := range accounts {
if _ , ok := openCodeGoUsageGroupFingerprint ( account ); ok {
eligible = append ( eligible , account )
}
}
if len ( eligible ) == 0 {
return nil
}
siblings , err := writer . ListOpenCodeGoUsageGroupAccounts ( ctx , eligible )
if err != nil {
return fmt . Errorf ( "resolve OpenCode Go usage groups: %w" , err )
}
sources := make ( map [ string ] * Account )
for index := range siblings {
candidate := & siblings [ index ]
fingerprint , valid := openCodeGoUsageGroupFingerprint ( candidate )
if ! valid {
continue
}
current := sources [ fingerprint ]
if current == nil || candidate . UpdatedAt . After ( current . UpdatedAt ) ||
( candidate . UpdatedAt . Equal ( current . UpdatedAt ) && candidate . ID < current . ID ) {
sources [ fingerprint ] = candidate
}
}
resolvedSources := make ( map [ string ] * Account , len ( sources ))
for fingerprint , source := range sources {
clone := * source
clone . Extra = make ( map [ string ] any , len ( source . Extra ))
maps . Copy ( clone . Extra , source . Extra )
resolvedSources [ fingerprint ] = & clone
}
for index := range siblings {
candidate := & siblings [ index ]
fingerprint , valid := openCodeGoUsageGroupFingerprint ( candidate )
source := resolvedSources [ fingerprint ]
if ! valid || source == nil {
continue
}
candidateSnapshot := decodeOpenCodeGoUsageSnapshot ( candidate . Extra )
currentSnapshot := decodeOpenCodeGoUsageSnapshot ( source . Extra )
if candidateSnapshot != nil && ( currentSnapshot == nil || candidateSnapshot . LastAttemptAt . After ( currentSnapshot . LastAttemptAt )) {
source . Extra [ OpenCodeGoUsageSnapshotExtraKey ] = candidate . Extra [ OpenCodeGoUsageSnapshotExtraKey ]
}
}
for _ , account := range eligible {
fingerprint , _ := openCodeGoUsageGroupFingerprint ( account )
applyOpenCodeGoUsageManagedExtra ( account , resolvedSources [ fingerprint ])
}
return nil
}
func applyOpenCodeGoUsageManagedExtra ( target , source * Account ) {
if target == nil {
return
}
if target . Extra == nil {
target . Extra = make ( map [ string ] any )
}
for _ , key := range [] string {
OpenCodeGoUsageAutoRefreshExtraKey ,
OpenCodeGoUsageSnapshotExtraKey ,
} {
delete ( target . Extra , key )
if source != nil && source . Extra != nil {
if value , ok := source . Extra [ key ]; ok {
target . Extra [ key ] = value
}
}
}
}
2026-08-13 22:22:37 +08:00
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
}
2026-08-13 23:41:01 +08:00
if err := s . ResolveOpenCodeGoUsageAccounts ( ctx , [] * Account { account }); err != nil {
return nil , err
}
2026-08-13 22:22:37 +08:00
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 ()
2026-08-13 23:41:01 +08:00
debounce , maxWait := openCodeGoUsageDurations ( settings )
accounts , err := writer . ListDueOpenCodeGoUsageAccounts ( ctx , now , debounce , maxWait , opencodeGoUsageMaxPerCycle )
2026-08-13 22:22:37 +08:00
if err != nil {
return fmt . Errorf ( "list due OpenCode Go usage accounts: %w" , err )
}
var group errgroup . Group
2026-08-13 23:41:01 +08:00
seenGroups := make ( map [ string ] struct {}, len ( accounts ))
2026-08-13 22:22:37 +08:00
for index := range accounts {
account := accounts [ index ]
2026-08-13 23:41:01 +08:00
fingerprint , valid := openCodeGoUsageGroupFingerprint ( & account )
if ! valid || ! account . IsActive () || ! openCodeGoUsageAutoRefreshEnabled ( & account ) {
2026-08-13 22:22:37 +08:00
continue
}
2026-08-13 23:41:01 +08:00
if _ , duplicate := seenGroups [ fingerprint ]; duplicate {
continue
}
seenGroups [ fingerprint ] = struct {}{}
2026-08-13 22:22:37 +08:00
snapshot := decodeOpenCodeGoUsageSnapshot ( account . Extra )
2026-08-13 23:41:01 +08:00
// ListDue stamps Account.LastUsedAt with the api_key group MAX(last_used_at).
if ! openCodeGoUsageIsAutoRefreshDue ( snapshot , account . LastUsedAt , now , debounce , maxWait ) {
2026-08-13 22:22:37 +08:00
continue
}
accountID := account . ID
2026-08-13 23:41:01 +08:00
expected := account
2026-08-13 22:22:37 +08:00
group . Go ( func () error {
if _ , refreshErr := s . refreshAccount ( ctx , accountID , settings , true ); refreshErr != nil {
2026-08-13 23:41:01 +08:00
if errors . Is ( refreshErr , ErrOpenCodeGoUsageIdentityChanged ) {
if disableErr := writer . DisableOpenCodeGoUsageAutoRefresh ( ctx , & expected ); disableErr != nil {
logger . LegacyPrintf ( "service.opencode_go_usage" , "disable_auto_refresh_failed: account_id=%d err=%v" , accountID , disableErr )
}
return nil
}
2026-08-13 22:22:37 +08:00
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
2026-08-13 23:41:01 +08:00
debounce , maxWait := openCodeGoUsageDurations ( settings )
2026-08-13 22:22:37 +08:00
anchor , err := s . accountRepo . GetByID ( ctx , accountID )
if err != nil {
return nil , err
}
2026-08-13 23:41:01 +08:00
key , valid := openCodeGoUsageGroupFingerprint ( anchor )
if ! valid {
2026-08-13 22:22:37 +08:00
return nil , ErrOpenCodeGoUsageAccountInvalid
}
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
}
2026-08-13 23:41:01 +08:00
currentKey , currentValid := openCodeGoUsageGroupFingerprint ( account )
if ! currentValid {
2026-08-13 22:22:37 +08:00
return nil , ErrOpenCodeGoUsageAccountInvalid
}
2026-08-13 23:41:01 +08:00
if currentKey != key {
return nil , ErrOpenCodeGoUsageIdentityChanged
}
if err := s . ResolveOpenCodeGoUsageAccounts ( ctx , [] * Account { account }); err != nil {
return nil , err
}
2026-08-13 22:22:37 +08:00
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
}
2026-08-13 23:41:01 +08:00
groupLastUsed := account . LastUsedAt
if writer , ok := s . accountRepo .( openCodeGoUsageRepository ); ok {
siblings , listErr := writer . ListOpenCodeGoUsageGroupAccounts ( ctx , [] * Account { account })
if listErr != nil {
// Fall back to this account's own last_used_at. That is a narrower
// activity signal than the group maximum, so the due check may skip a
// refresh it would otherwise have run; surface it rather than
// silently changing the due semantics.
logger . LegacyPrintf ( "service.opencode_go_usage" ,
"group_last_used_lookup_failed: account_id=%d err=%v" , account . ID , listErr )
} else {
groupLastUsed = maxOpenCodeGoUsageGroupLastUsed ( siblings )
}
}
if ! openCodeGoUsageIsAutoRefreshDue ( decodeOpenCodeGoUsageSnapshot ( account . Extra ), groupLastUsed , s . currentTime (), debounce , maxWait ) {
2026-08-13 22:22:37 +08:00
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 {
2026-10-06 23:57:15 +08:00
return s . persistFailure ( ctx , account , intervalMinutes , now , resp . StatusCode , "forbidden" , retryAfter ( resp . Header , now ), false )
2026-08-13 22:22:37 +08:00
}
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
}
2026-09-11 20:34:35 +08:00
// isOpenCodeGoUsageMountPlatform 收敛允许以 base_url 挂载 OpenCode Go 用量身份的
// 平台白名单,与 ollama 的 isOllamaCloudUsagePlatform 对齐。repository 侧 SQL
// 白名单(opencodeGoUsageMountPlatformsSQL)是本列表的镜像,两侧必须同步修改。
// opencode_go 平台本身不在名单内:平台账号走 IsOpenCodeGoPlan 判定,不依赖
// base_url。
func isOpenCodeGoUsageMountPlatform ( platform string ) bool {
switch platform {
case PlatformOpenAI , PlatformAnthropic , PlatformKimi , PlatformZhipu , PlatformDeepseek , PlatformMiniMax :
return true
default :
return false
}
}
// IsOpenCodeGoUsageAccount 判定账号是否参与 OpenCode Go 用量窗口,资格来源分派:
// - platform == opencode_go:平台字段是权威来源,以 IsOpenCodeGoPlan 为准(必须是
// Go 订阅;Zen 按量付费无订阅配额窗口,上游 CN 配额链路同样排除)。此分支不要求
// base_url 匹配——opencode_go 账号的 base_url 可能是 CC/Responses 基址
2026-09-11 21:33:31 +08:00
// ( /zen/go/v1)或 Anthropic 基址(/zen/go),甚至为空。这是有意取舍:平台 +
// 模式已是权威来源,且账号指向自建代理/中转时 key 仍是官方 OpenCode key,从
// 官方端点取用量恰恰是正确数据源,强制要求官方 host 会破坏这类合法用法;
// 前提假设是该账号的 api_key 为官方 OpenCode Go 订阅 key。
2026-09-11 20:34:35 +08:00
// - 其它平台:仅限挂载白名单(isOpenCodeGoUsageMountPlatform)内的 API Key 账号,
// 以 base_url 严格指向官方 OpenCode Go 基址判定(见 isOpenCodeGoBaseURL)。
//
// 两种情况都要求 account.Type == AccountTypeAPIKey。
2026-08-13 22:22:37 +08:00
func IsOpenCodeGoUsageAccount ( account * Account ) bool {
2026-09-11 20:34:35 +08:00
if account == nil || account . Type != AccountTypeAPIKey {
return false
}
if account . IsOpenCodeGo () {
return account . IsOpenCodeGoPlan ()
}
if ! isOpenCodeGoUsageMountPlatform ( account . Platform ) {
2026-08-13 22:22:37 +08:00
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
}
2026-09-11 20:34:35 +08:00
// 官方两个基址变体:CC/Responses 的 /zen/go/v1 与 Anthropic 的 /zen/go。
// TrimSuffix 归一尾斜杠后,Zen 基址(/zen、/zen/v1)自然被拒绝。
path := strings . TrimSuffix ( parsed . Path , "/" )
return strings . EqualFold ( path , "/zen/go/v1" ) || strings . EqualFold ( path , "/zen/go" )
2026-08-13 22:22:37 +08:00
}
2026-09-11 20:34:35 +08:00
// openCodeGoUsageIdentity 返回 OpenCode Go 用量组的身份。host 固定为 opencode.ai、
// 以 api_key 聚合是平台无关的有意设计:同一个 OpenCode Go 订阅 key 无论以
// opencode_go 平台账号存在,还是挂在 openai/anthropic 等挂载平台下,都属于
// 同一组、共享一次外呼与同一份快照。
2026-08-13 23:41:01 +08:00
func openCodeGoUsageIdentity ( account * Account ) map [ string ] any {
if ! IsOpenCodeGoUsageAccount ( account ) {
return nil
}
apiKey , ok := account . Credentials [ "api_key" ].( string )
if ! ok || apiKey == "" {
return nil
}
return map [ string ] any { "host" : "opencode.ai" , "api_key" : apiKey }
}
2026-09-11 20:34:35 +08:00
// openCodeGoUsageGroupFingerprint 是组身份的指纹(sha256("opencode.ai\x00"+apiKey))。
// 与 openCodeGoUsageIdentity 一样保持平台无关:同一订阅 key 跨平台(opencode_go
// 平台账号与挂载平台账号)必须得到相同指纹,才会被归入同一刷新组。
2026-08-13 23:41:01 +08:00
func openCodeGoUsageGroupFingerprint ( account * Account ) ( string , bool ) {
identity := openCodeGoUsageIdentity ( account )
if identity == nil {
return "" , false
}
apiKey , _ := identity [ "api_key" ].( string )
sum := sha256 . Sum256 ([] byte ( "opencode.ai\x00" + apiKey ))
return hex . EncodeToString ( sum [:]), true
}
2026-08-13 22:22:37 +08:00
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,
2026-10-06 23:55:46 +08:00
// never below a Retry-After hint or one minute. The hint is honored over the
// local backoff but clamped to the same 24h ceiling: it arrives over a network
// path we do not control (upstream CDN, account proxy), and an unbounded value
// would freeze a group's auto refresh indefinitely.
2026-08-13 22:22:37 +08:00
func nextOpenCodeGoUsageDelay ( intervalMinutes , failureCount int , retryAfterDuration time . Duration ) time . Duration {
2026-10-06 23:55:46 +08:00
if retryAfterDuration < 0 {
retryAfterDuration = 0
}
if retryAfterDuration > opencodeGoUsageMaxDelay {
retryAfterDuration = opencodeGoUsageMaxDelay
}
2026-08-13 22:22:37 +08:00
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
}