mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-28 02:41:37 +08:00
199 lines
6.7 KiB
Go
199 lines
6.7 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
|
)
|
|
|
|
const (
|
|
grokQuotaUpstreamTimeout = 20 * time.Second
|
|
grokQuotaProbeInput = "."
|
|
grokQuotaDefaultModel = "grok-4.3"
|
|
)
|
|
|
|
type GrokQuotaProbeResult struct {
|
|
Source string `json:"source"`
|
|
Snapshot *xai.QuotaSnapshot `json:"snapshot,omitempty"`
|
|
StatusCode int `json:"status_code,omitempty"`
|
|
HeadersObserved bool `json:"headers_observed"`
|
|
ResetSupported bool `json:"reset_supported"`
|
|
FetchedAt int64 `json:"fetched_at"`
|
|
}
|
|
|
|
type GrokQuotaResetResult struct {
|
|
Supported bool `json:"supported"`
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type GrokQuotaService struct {
|
|
accountRepo AccountRepository
|
|
proxyRepo ProxyRepository
|
|
tokenProvider *GrokTokenProvider
|
|
httpUpstream HTTPUpstream
|
|
}
|
|
|
|
func NewGrokQuotaService(
|
|
accountRepo AccountRepository,
|
|
proxyRepo ProxyRepository,
|
|
tokenProvider *GrokTokenProvider,
|
|
httpUpstream HTTPUpstream,
|
|
) *GrokQuotaService {
|
|
return &GrokQuotaService{
|
|
accountRepo: accountRepo,
|
|
proxyRepo: proxyRepo,
|
|
tokenProvider: tokenProvider,
|
|
httpUpstream: httpUpstream,
|
|
}
|
|
}
|
|
|
|
func (s *GrokQuotaService) ProbeUsage(ctx context.Context, accountID int64) (*GrokQuotaProbeResult, error) {
|
|
account, token, proxyURL, err := s.prepareProbe(ctx, accountID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
body, err := buildGrokQuotaProbeBody(account)
|
|
if err != nil {
|
|
return nil, infraerrors.Newf(http.StatusBadRequest, "GROK_QUOTA_PROBE_BODY_ERROR", "failed to build probe body: %v", err)
|
|
}
|
|
targetURL, err := xai.BuildResponsesURL(account.GetGrokBaseURL())
|
|
if err != nil {
|
|
return nil, infraerrors.Newf(http.StatusBadRequest, "GROK_QUOTA_BASE_URL_INVALID", "invalid Grok base_url: %v", err)
|
|
}
|
|
|
|
callCtx, cancel := context.WithTimeout(ctx, grokQuotaUpstreamTimeout)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(callCtx, http.MethodPost, targetURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, infraerrors.Newf(http.StatusInternalServerError, "GROK_QUOTA_PROBE_REQUEST_BUILD_FAILED", "failed to build upstream request: %v", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", "sub2api-grok-quota-probe/1.0")
|
|
|
|
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, maxInt(account.Concurrency, 1))
|
|
if err != nil {
|
|
return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_QUOTA_PROBE_REQUEST_FAILED", "upstream probe failed: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
snapshot := xai.ObserveQuotaHeaders(resp.Header, resp.StatusCode, "active_probe")
|
|
_ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
|
|
grokQuotaSnapshotExtraKey: snapshot,
|
|
})
|
|
|
|
result := &GrokQuotaProbeResult{
|
|
Source: "active_probe",
|
|
Snapshot: snapshot,
|
|
StatusCode: resp.StatusCode,
|
|
HeadersObserved: snapshot.HeadersObserved,
|
|
ResetSupported: false,
|
|
FetchedAt: time.Now().Unix(),
|
|
}
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
|
return result, nil
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 240))
|
|
bodyText := truncate(strings.TrimSpace(string(bodyBytes)), 240)
|
|
slog.Warn("grok_quota_probe_failed", "account_id", account.ID, "status", resp.StatusCode, "body", bodyText)
|
|
return nil, infraerrors.Newf(mapUpstreamStatus(resp.StatusCode), "GROK_QUOTA_PROBE_UPSTREAM_ERROR", "upstream returned %d: %s", resp.StatusCode, bodyText)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *GrokQuotaService) ResetQuota(ctx context.Context, accountID int64) (*GrokQuotaResetResult, error) {
|
|
if _, err := s.loadGrokOAuthAccount(ctx, accountID); err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, infraerrors.New(http.StatusNotImplemented, "GROK_QUOTA_RESET_UNSUPPORTED", "xAI does not expose a Grok subscription quota reset endpoint for OAuth accounts")
|
|
}
|
|
|
|
func (s *GrokQuotaService) prepareProbe(ctx context.Context, accountID int64) (*Account, string, string, error) {
|
|
if s == nil || s.tokenProvider == nil || s.httpUpstream == nil {
|
|
return nil, "", "", infraerrors.New(http.StatusInternalServerError, "GROK_QUOTA_NOT_CONFIGURED", "grok quota service is not configured")
|
|
}
|
|
account, err := s.loadGrokOAuthAccount(ctx, accountID)
|
|
if err != nil {
|
|
return nil, "", "", err
|
|
}
|
|
|
|
token, err := s.tokenProvider.GetAccessToken(ctx, account)
|
|
if err != nil {
|
|
return nil, "", "", infraerrors.Newf(http.StatusBadGateway, "GROK_QUOTA_TOKEN_UNAVAILABLE", "failed to acquire access token: %v", err)
|
|
}
|
|
if strings.TrimSpace(token) == "" {
|
|
return nil, "", "", infraerrors.New(http.StatusBadGateway, "GROK_QUOTA_TOKEN_UNAVAILABLE", "access token is empty")
|
|
}
|
|
|
|
return account, token, s.resolveProxyURL(ctx, account), nil
|
|
}
|
|
|
|
func (s *GrokQuotaService) resolveProxyURL(ctx context.Context, account *Account) string {
|
|
if account == nil || account.ProxyID == nil {
|
|
return ""
|
|
}
|
|
switch {
|
|
case account.Proxy != nil:
|
|
return account.Proxy.URL()
|
|
case s != nil && s.proxyRepo != nil:
|
|
if proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && proxy != nil {
|
|
return proxy.URL()
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *GrokQuotaService) loadGrokOAuthAccount(ctx context.Context, accountID int64) (*Account, error) {
|
|
if s == nil || s.accountRepo == nil {
|
|
return nil, infraerrors.New(http.StatusInternalServerError, "GROK_QUOTA_NOT_CONFIGURED", "grok quota service is not configured")
|
|
}
|
|
account, err := s.accountRepo.GetByID(ctx, accountID)
|
|
if err != nil {
|
|
return nil, infraerrors.Newf(http.StatusNotFound, "GROK_QUOTA_ACCOUNT_NOT_FOUND", "account not found: %v", err)
|
|
}
|
|
if account == nil {
|
|
return nil, infraerrors.New(http.StatusNotFound, "GROK_QUOTA_ACCOUNT_NOT_FOUND", "account not found")
|
|
}
|
|
if account.Platform != PlatformGrok {
|
|
return nil, infraerrors.New(http.StatusBadRequest, "GROK_QUOTA_INVALID_PLATFORM", "account is not a Grok account")
|
|
}
|
|
if account.Type != AccountTypeOAuth {
|
|
return nil, infraerrors.New(http.StatusBadRequest, "GROK_QUOTA_INVALID_TYPE", "account is not an OAuth account")
|
|
}
|
|
return account, nil
|
|
}
|
|
|
|
func buildGrokQuotaProbeBody(account *Account) ([]byte, error) {
|
|
model := grokQuotaDefaultModel
|
|
if account != nil {
|
|
if mapped := strings.TrimSpace(account.GetMappedModel("grok")); mapped != "" {
|
|
model = mapped
|
|
}
|
|
}
|
|
return json.Marshal(map[string]any{
|
|
"model": model,
|
|
"input": grokQuotaProbeInput,
|
|
"max_output_tokens": 1,
|
|
"store": false,
|
|
})
|
|
}
|
|
|
|
func maxInt(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|