From 3c07a3d77bfa7846ae21eb91ca11208e848a0b39 Mon Sep 17 00:00:00 2001 From: ihonghong <318198986+ihonghong@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:37:25 +0800 Subject: [PATCH] fix(xai): recover validated paid weekly zero usage --- apps/docs/en/gateway/providers.md | 12 ++ apps/docs/gateway/providers.md | 10 + .../codexinspection/xai_billing_zero.go | 182 ++++++++++++++++++ .../codexinspection/xai_billing_zero_test.go | 98 ++++++++++ .../service/codexinspection/xai_probe.go | 17 +- .../model/accountQuotaDisplayWindows.test.ts | 11 +- apps/web/src/types/quota.ts | 2 + .../src/utils/quota/providerRequests.test.ts | 143 ++++++++++++++ apps/web/src/utils/quota/providerRequests.ts | 66 ++++++- .../src/utils/quota/xaiBillingZero.test.ts | 8 + apps/web/src/utils/quota/xaiBillingZero.ts | 128 ++++++++++++ tests/fixtures/xai-billing-zero.json | 178 +++++++++++++++++ 12 files changed, 843 insertions(+), 12 deletions(-) create mode 100644 apps/manager-server/internal/service/codexinspection/xai_billing_zero.go create mode 100644 apps/manager-server/internal/service/codexinspection/xai_billing_zero_test.go create mode 100644 apps/web/src/utils/quota/xaiBillingZero.test.ts create mode 100644 apps/web/src/utils/quota/xaiBillingZero.ts create mode 100644 tests/fixtures/xai-billing-zero.json diff --git a/apps/docs/en/gateway/providers.md b/apps/docs/en/gateway/providers.md index d8573c8be..3b2a39767 100644 --- a/apps/docs/en/gateway/providers.md +++ b/apps/docs/en/gateway/providers.md @@ -39,3 +39,15 @@ Model requests must go to CPA, not CPAMP. For same-domain routing, see [Reverse Model prices affect CPAMP local cost estimates only. They do not change CPA routing or provider billing. ::: + +### xAI weekly periods with zero usage + +Some unified-billing accounts omit `creditUsagePercent` when usage is zero or rounds to zero. +The panel and server inspection accept an implicit 0% used only when settings confirm SuperGrok / SuperGrok Heavy +and a complete, successful gRPC billing response from the same credential matches the active REST weekly period. +The existing remaining-quota display then shows 100%. This does not prove that no tokens were consumed +and cannot be converted into a remaining token count. + +REST period metadata alone, Free / unconfirmed plans, and expired or incomplete responses remain unknown (`-`). +The optional reads use the existing CPA management proxy, add at most six seconds, and never turn an enrichment failure +into an account health failure or an automatic account action. diff --git a/apps/docs/gateway/providers.md b/apps/docs/gateway/providers.md index ef993f109..7eb0573c9 100644 --- a/apps/docs/gateway/providers.md +++ b/apps/docs/gateway/providers.md @@ -39,3 +39,13 @@ Muse / Meta 是一个需要特别区分凭证类型的场景:OAuth Device Flow 模型价格只影响 CPAMP 的本地成本估算,不会改变 CPA 路由或 Provider 账单。 ::: + +### xAI 零使用量的周额度 + +部分统一计费账号在已用百分比为零(或舍入为零)时会省略 `creditUsagePercent`。 +只有设置接口明确确认 SuperGrok / SuperGrok Heavy,且同一凭证的补充 gRPC 账单响应完整、成功、 +处于当前周账期并与 REST 账期一致时,面板和服务端巡检才将其识别为隐含的 0% 已用,页面显示 100% 剩余。 +这不代表没有消耗过 token,也不能换算成剩余 token 数。 + +仅有 REST 周期信息、Free / 未确认套餐、过期或不完整响应仍保持未知(`-`)。 +补充查询只读、通过现有 CPA 管理接口转发,最多额外等待 6 秒;失败不改变原有账单健康判定或触发账号操作。 diff --git a/apps/manager-server/internal/service/codexinspection/xai_billing_zero.go b/apps/manager-server/internal/service/codexinspection/xai_billing_zero.go new file mode 100644 index 000000000..20ed83664 --- /dev/null +++ b/apps/manager-server/internal/service/codexinspection/xai_billing_zero.go @@ -0,0 +1,182 @@ +package codexinspection + +import ( + "context" + "encoding/base64" + "encoding/binary" + "strconv" + "strings" + "time" + + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/model" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/store" +) + +const xaiWebBillingURL = "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig" + +// An omitted REST percentage alone remains unknown (#710). Only a complete, +// successful gRPC response for the same active period establishes implicit zero. +func isXAIValidatedZero(text, start, end string, now time.Time) bool { + from, err := time.Parse(time.RFC3339Nano, start) + if err != nil { + return false + } + until, err := time.Parse(time.RFC3339Nano, end) + if err != nil || now.Before(from) || !now.Before(until) || len(text) > 64*1024 { + return false + } + encoded := strings.Join(strings.Fields(text), "") + if len(encoded) == 0 || len(encoded)%4 != 0 { + return false + } + // grpc-web-text can flush independently padded chunks, including trailers. + var data []byte + for len(encoded) > 0 { + n := len(encoded) + if i := strings.IndexByte(encoded, '='); i >= 0 { + n = ((i / 4) + 1) * 4 + } + chunk, err := base64.StdEncoding.Strict().DecodeString(encoded[:n]) + if err != nil { + return false + } + data = append(data, chunk...) + encoded = encoded[n:] + } + var payload []byte + done := false + for len(data) > 0 { + if done || len(data) < 5 { + return false + } + flag := data[0] + size := uint64(binary.BigEndian.Uint32(data[1:5])) + data = data[5:] + if size > uint64(len(data)) { + return false + } + frame := data[:int(size)] + data = data[int(size):] + if flag == 0 && payload == nil { + payload = frame + } else if flag == 128 && payload != nil { + statuses := 0 + for _, line := range strings.Split(string(frame), "\r\n") { + if strings.HasPrefix(strings.ToLower(line), "grpc-status:") { + statuses++ + if strings.TrimSpace(line[len("grpc-status:"):]) != "0" { + return false + } + } + } + if statuses != 1 { + return false + } + done = true + } else { + return false + } + } + if payload == nil || !done { + return false + } + messages := map[string]bool{"1": true, "1.2": true, "1.3": true, "1.4": true, "1.5": true, "1.6": true, "1.7": true, "1.8": true, "1.12": true, "1.6.1": true, "1.6.2": true, "1.6.3": true, "1.8.2": true, "1.8.3": true, "1.6.3.2": true, "1.6.3.3": true} + structural := map[string]bool{"1": true, "1.8": true, "1.8.1": true, "1.8.2": true, "1.8.3": true, "1.8.2.1": true, "1.8.3.1": true} + seen := map[string]bool{} + values := map[string]uint64{} + var scan func([]byte, string) bool + scan = func(b []byte, path string) bool { + for len(b) > 0 { + tag, n := binary.Uvarint(b) + if n <= 0 { + return false + } + b = b[n:] + field, wire := tag>>3, tag&7 + if field == 0 || field > 536870911 { + return false + } + key := strconv.FormatUint(field, 10) + if path != "" { + key = path + "." + key + } + if key == "1.1" || (messages[key] && wire != 2) { + return false + } + if structural[key] { + if seen[key] { + return false + } + seen[key] = true + } + switch wire { + case 0: + value, n := binary.Uvarint(b) + if n <= 0 { + return false + } + b = b[n:] + values[key] = value + case 2: + size, n := binary.Uvarint(b) + if n <= 0 { + return false + } + b = b[n:] + if size > uint64(len(b)) { + return false + } + if messages[key] && !scan(b[:int(size)], key) { + return false + } + b = b[int(size):] + case 1: + if len(b) < 8 { + return false + } + b = b[8:] + default: + // Any float could contradict implicit zero; never infer from it. + return false + } + } + return true + } + return scan(payload, "") && values["1.8.1"] == 2 && values["1.8.2.1"] == uint64(from.Unix()) && values["1.8.3.1"] == uint64(until.Unix()) +} + +func (s *Service) enrichXAIBillingZero(ctx context.Context, setup store.Setup, settings model.ManagerCodexInspectionConfig, item account, summary *xaiBillingSummary) { + if summary == nil || summary.UsagePercent != nil || !summary.HasWeeklyData || len(summary.ProductUsage) > 0 { + return + } + start, err := time.Parse(time.RFC3339Nano, summary.PeriodStart) + if err != nil { + return + } + end, err := time.Parse(time.RFC3339Nano, summary.PeriodEnd) + if err != nil { + return + } + now := time.Now() + if now.Before(start) || !now.Before(end) { + return + } + bounded, cancel := context.WithTimeout(ctx, 6*time.Second) + defer cancel() + settingsResponse, _, err := s.requestProviderBillingAt(bounded, setup, settings, item, xaiCLIChatProxyBaseURL+"/settings", map[string]string{"Authorization": "Bearer $TOKEN$", "x-xai-token-auth": "xai-grok-cli", "x-grok-client-version": xaiGrokVersion}) + if err != nil || settingsResponse.StatusCode != 200 { + return + } + tier := readString(parseRecord(settingsResponse.Body), "subscription_tier_display") + if tier != "SuperGrok" && tier != "SuperGrok Heavy" { + return + } + response, _, err := s.requestProviderAPICallAt(bounded, setup, settings, item, "POST", xaiWebBillingURL, map[string]string{ + "Authorization": "Bearer $TOKEN$", "Content-Type": "application/grpc-web-text+proto", "Accept": "application/grpc-web-text+proto", "x-grpc-web": "1", "Origin": "https://grok.com", + }, "AAAAAAIIAA==") + if err == nil && response.StatusCode == 200 && isXAIValidatedZero(response.BodyText, summary.PeriodStart, summary.PeriodEnd, time.Now()) { + zero := float64(0) + summary.UsagePercent = &zero + summary.UsagePercentSource = "grpc-implicit-zero" + } +} diff --git a/apps/manager-server/internal/service/codexinspection/xai_billing_zero_test.go b/apps/manager-server/internal/service/codexinspection/xai_billing_zero_test.go new file mode 100644 index 000000000..d00153c93 --- /dev/null +++ b/apps/manager-server/internal/service/codexinspection/xai_billing_zero_test.go @@ -0,0 +1,98 @@ +package codexinspection + +import ( + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/model" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/store" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +func TestXAIValidatedZero(t *testing.T) { + data, err := os.ReadFile("../../../../../tests/fixtures/xai-billing-zero.json") + if err != nil { + t.Fatal(err) + } + var cases []struct { + Name, Text, Start, End, Now string + Want bool + } + if err := json.Unmarshal(data, &cases); err != nil { + t.Fatal(err) + } + for _, tt := range cases { + t.Run(tt.Name, func(t *testing.T) { + now, err := time.Parse(time.RFC3339, tt.Now) + if err != nil { + t.Fatal(err) + } + if got := isXAIValidatedZero(tt.Text, tt.Start, tt.End, now); got != tt.Want { + t.Fatalf("got %v want %v", got, tt.Want) + } + }) + } +} + +func TestRequestXAIBillingZeroEnrichment(t *testing.T) { + for _, tier := range []string{"SuperGrok Heavy", "Free", "unknown"} { + t.Run(tier, func(t *testing.T) { + start := time.Now().UTC().Truncate(time.Second).Add(-time.Hour) + end := start.Add(7 * 24 * time.Hour) + field := func(n byte, b []byte) []byte { return append([]byte{n*8 + 2, byte(len(b))}, b...) } + stamp := func(v int64) []byte { return binary.AppendUvarint([]byte{8}, uint64(v)) } + period := append([]byte{8, 2}, field(2, stamp(start.Unix()))...) + period = append(period, field(3, stamp(end.Unix()))...) + payload := field(1, field(8, period)) + frame := func(flag byte, b []byte) string { + return base64.StdEncoding.EncodeToString(append(binary.BigEndian.AppendUint32([]byte{flag}, uint32(len(b))), b...)) + } + grpc := frame(0, payload) + frame(128, []byte("grpc-status:0\r\n")) + grpcCalls := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + URL, AuthIndex, Method, Data string + Header map[string]string + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Error(err) + return + } + if req.AuthIndex != "test-account" || req.Header["Authorization"] != "Bearer $TOKEN$" { + t.Error("credential scope not preserved") + } + var body any = map[string]any{"config": map[string]any{"isUnifiedBillingUser": true, "currentPeriod": map[string]any{"type": "weekly", "start": start.Format(time.RFC3339), "end": end.Format(time.RFC3339)}}} + if strings.HasSuffix(req.URL, "/settings") { + body = map[string]any{"subscription_tier_display": tier} + } + if req.URL == xaiWebBillingURL { + grpcCalls++ + body = grpc + if req.Method != "POST" || req.Data != "AAAAAAIIAA==" { + t.Error("invalid grpc request") + } + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": body}) + })) + defer upstream.Close() + settings := model.DefaultCodexInspectionConfig() + probe, err := New(nil, nil, upstream.Client()).requestXAIBilling(context.Background(), store.Setup{CPAUpstreamURL: upstream.URL, ManagementKey: "test"}, settings, account{AuthIndex: "test-account", File: authFile{}}) + if err != nil || !probe.Healthy || probe.Partial { + t.Fatalf("unexpected health: %+v %v", probe, err) + } + if tier == "SuperGrok Heavy" { + if grpcCalls != 1 || probe.Summary.UsagePercent == nil || *probe.Summary.UsagePercent != 0 || probe.Summary.UsagePercentSource != "grpc-implicit-zero" { + t.Fatalf("zero not adopted: %+v", probe.Summary) + } + } else if grpcCalls != 0 || probe.Summary.UsagePercent != nil { + t.Fatal("unconfirmed/free usage must stay unknown") + } + }) + } +} diff --git a/apps/manager-server/internal/service/codexinspection/xai_probe.go b/apps/manager-server/internal/service/codexinspection/xai_probe.go index 9430c32b3..c0bcd0a89 100644 --- a/apps/manager-server/internal/service/codexinspection/xai_probe.go +++ b/apps/manager-server/internal/service/codexinspection/xai_probe.go @@ -39,6 +39,8 @@ type xaiProbeDecision struct { } type xaiBillingSummary struct { + PeriodStart string + UsagePercentSource string UsagePercent *float64 UsedPercent *float64 OnDemandUsedPercent *float64 @@ -527,6 +529,9 @@ func (s *Service) requestXAIBilling( probe := xaiBillingProbe{} if weekly.Summary != nil { + if weekly.ZeroFallbackEligible { + s.enrichXAIBillingZero(ctx, setup, settings, item, weekly.Summary) + } probe.Summary = mergeXAIBillingSummary(weekly.Summary, monthly.Summary) probe.StatusCode = weekly.StatusCode probe.Healthy = true @@ -780,10 +785,11 @@ func positiveXAIFloat(value *float64) bool { } type xaiBillingResult struct { - Summary *xaiBillingSummary - Failure *xaiProbeDecision - Partial bool - StatusCode int + ZeroFallbackEligible bool + Summary *xaiBillingSummary + Failure *xaiProbeDecision + Partial bool + StatusCode int } func (s *Service) requestProviderBilling( @@ -811,7 +817,7 @@ func (s *Service) requestProviderBilling( if summary == nil { return xaiBillingResult{Failure: xaiDecision(response.StatusCode, "protocol_changed", "xAI billing response schema changed")}, nil } - return xaiBillingResult{Summary: summary, StatusCode: response.StatusCode}, nil + return xaiBillingResult{Summary: summary, StatusCode: response.StatusCode, ZeroFallbackEligible: config["isUnifiedBillingUser"] == true && config["creditUsagePercent"] == nil && config["credit_usage_percent"] == nil}, nil } return xaiBillingResult{Failure: xaiDecision( response.StatusCode, @@ -1022,6 +1028,7 @@ func parseXAIBillingSummary(config map[string]any) *xaiBillingSummary { return nil } return &xaiBillingSummary{ + PeriodStart: readString(period, "start"), UsagePercent: usage, UsedPercent: monthlyUsed, OnDemandUsedPercent: onDemandUsedPercent, diff --git a/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.test.ts b/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.test.ts index eddbc3ae5..393a8b827 100644 --- a/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.test.ts +++ b/apps/web/src/features/accounts/model/accountQuotaDisplayWindows.test.ts @@ -1640,7 +1640,7 @@ describe('accountQuotaDisplayWindows', () => { ).toEqual([]); }); - it('creates weekly quota window for confirmed paid plan SuperGrok without legacy monthly limit', () => { + it.each([{ used: 42, planType: 'SuperGrok' }, { used: 0, planType: 'SuperGrok' }, { used: 0, planType: undefined }])('shows remaining quota for xAI with $used percent used and plan $planType', ({ used, planType }) => { const stores = { ...emptyStores(), xaiQuota: { @@ -1648,7 +1648,8 @@ describe('accountQuotaDisplayWindows', () => { status: 'success', billing: { periodType: 'weekly', - usagePercent: 42, + usagePercent: used, + ...(used === 0 ? { usagePercentSource: 'grpc-implicit-zero' as const } : {}), periodStart: '2026-09-05T00:00:00Z', periodEnd: '2026-09-12T00:00:00Z', productUsage: [], @@ -1664,7 +1665,7 @@ describe('accountQuotaDisplayWindows', () => { }, } satisfies AccountQuotaStores; const row = buildRow( - { name: 'xai-supergrok.json', type: 'xai', planType: 'SuperGrok' }, + { name: 'xai-supergrok.json', type: 'xai', planType }, stores ); @@ -1679,8 +1680,8 @@ describe('accountQuotaDisplayWindows', () => { key: 'credits-period', label: 'Weekly credits', kind: 'weekly', - remainingPercent: 58, - usedPercent: 42, + remainingPercent: 100 - used, + usedPercent: used, cycleStartMs: Date.parse('2026-09-05T00:00:00Z'), cycleEndMs: Date.parse('2026-09-12T00:00:00Z'), source: 'xai', diff --git a/apps/web/src/types/quota.ts b/apps/web/src/types/quota.ts index 38bd54410..a723f0499 100644 --- a/apps/web/src/types/quota.ts +++ b/apps/web/src/types/quota.ts @@ -527,6 +527,7 @@ export interface XaiBillingUsage { } export interface XaiBillingConfig { + isUnifiedBillingUser?: boolean; currentPeriod?: XaiBillingPeriod | null; current_period?: XaiBillingPeriod | null; creditUsagePercent?: number | string | null; @@ -592,6 +593,7 @@ export interface XaiOfficialApiHealth { } export interface XaiBillingSummary { + usagePercentSource?: 'grpc-implicit-zero'; periodType: XaiBillingPeriodType; usagePercent: number | null; periodStart?: string; diff --git a/apps/web/src/utils/quota/providerRequests.test.ts b/apps/web/src/utils/quota/providerRequests.test.ts index dbed31de5..b8f61792b 100644 --- a/apps/web/src/utils/quota/providerRequests.test.ts +++ b/apps/web/src/utils/quota/providerRequests.test.ts @@ -1,3 +1,4 @@ +import zeroFixtures from '../../../../../tests/fixtures/xai-billing-zero.json'; import type { TFunction } from 'i18next'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -4267,3 +4268,145 @@ describe('fetchDevinQuota', () => { }); }); + +describe('xAI paid zero-usage enrichment', () => { + const start = '2030-01-01T00:00:00Z'; + const end = '2030-01-08T00:00:00Z'; + const success = (body: unknown) => ({ + statusCode: 200, + hasStatusCode: true, + header: {}, + body, + bodyText: typeof body === 'string' ? body : JSON.stringify(body), + }); + const file = { name: 'xai-test.json', auth_index: 'test-account', type: 'xai' }; + const text = 'AAAAACgKJkIYCAISCgiA2M2GBxoKCIDNmIcHWAFiAGgB'; + // Shared protocol fixtures are synthetic and contain no account identifiers. + it('enriches a paid active period to 0% used / 100% remaining through the same auth index', async () => { + const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2030-01-02T00:00:00Z')); + try { + mocks.request.mockImplementation(async (request: { url: string }) => { + if (request.url.endsWith('/settings')) + return success({ subscription_tier_display: 'SuperGrok Heavy' }); + if (request.url.includes('GetGrokCreditsConfig')) return success(zeroFixtures[0].text); + return success({ + config: { isUnifiedBillingUser: true, currentPeriod: { type: 'weekly', start, end } }, + }); + }); + const result = await probeXaiQuota(file, t); + expect(result.summary).toMatchObject({ + usagePercent: 0, + usagePercentSource: 'grpc-implicit-zero', + periodStart: start, + periodEnd: end, + }); + expect(100 - result.summary.usagePercent!).toBe(100); + expect(result.partial).toBe(false); + expect(mocks.request).toHaveBeenCalledWith( + expect.objectContaining({ + authIndex: 'test-account', + method: 'POST', + data: 'AAAAAAIIAA==', + header: expect.objectContaining({ Authorization: 'Bearer $TOKEN$' }), + }), + expect.objectContaining({ timeout: 3000 }), + ); + } finally { + clock.mockRestore(); + } + }); + + it.each(['Free', 'unknown', 'timeout', 'malformed'])( + 'keeps %s responses unknown without downgrading REST health', + async (mode) => { + const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2030-01-02T00:00:00Z')); + try { + mocks.request.mockImplementation(async (request: { url: string }) => { + if (request.url.endsWith('/settings')) { + if (mode === 'timeout') throw new Error('timeout'); + return success({ + subscription_tier_display: mode === 'malformed' ? 'SuperGrok Heavy' : mode, + }); + } + if (request.url.includes('GetGrokCreditsConfig')) return success(text); + return success({ + config: { isUnifiedBillingUser: true, currentPeriod: { type: 'weekly', start, end } }, + }); + }); + const result = await probeXaiQuota(file, t); + expect(result.summary.usagePercent).toBeNull(); + expect(result.summary.usagePercentSource).toBeUndefined(); + expect(result.partial).toBe(false); + expect(result.failures).toEqual([]); + } finally { + clock.mockRestore(); + } + }, + ); +}); + +describe('xAI enrichment boundaries', () => { + it.each([0, 7, 'invalid'])( + 'does not reinterpret a present REST percentage (%s)', + async (percent) => { + const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2030-01-02T00:00:00Z')); + try { + mocks.request.mockResolvedValue({ + statusCode: 200, + hasStatusCode: true, + header: {}, + body: { + config: { + isUnifiedBillingUser: true, + creditUsagePercent: percent, + currentPeriod: { + type: 'weekly', + start: '2030-01-01T00:00:00Z', + end: '2030-01-08T00:00:00Z', + }, + }, + }, + bodyText: '', + }); + await probeXaiQuota({ name: 'test', auth_index: 'test' }, t); + expect(mocks.request).toHaveBeenCalledTimes(2); + } finally { + clock.mockRestore(); + } + }, + ); + + it('propagates caller cancellation during optional enrichment', async () => { + const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2030-01-02T00:00:00Z')); + const controller = new AbortController(); + try { + mocks.request.mockImplementation(async (request: { url: string }) => { + if (request.url.endsWith('/settings')) { + controller.abort(); + throw new Error('cancelled'); + } + return { + statusCode: 200, + hasStatusCode: true, + header: {}, + body: { + config: { + isUnifiedBillingUser: true, + currentPeriod: { + type: 'weekly', + start: '2030-01-01T00:00:00Z', + end: '2030-01-08T00:00:00Z', + }, + }, + }, + bodyText: '', + }; + }); + await expect( + probeXaiQuota({ name: 'test', auth_index: 'test' }, t, { signal: controller.signal }), + ).rejects.toThrow('cancelled'); + } finally { + clock.mockRestore(); + } + }); +}); diff --git a/apps/web/src/utils/quota/providerRequests.ts b/apps/web/src/utils/quota/providerRequests.ts index 13a362cf4..f0912209c 100644 --- a/apps/web/src/utils/quota/providerRequests.ts +++ b/apps/web/src/utils/quota/providerRequests.ts @@ -1,3 +1,9 @@ +import { + isXaiValidatedZero, + XAI_WEB_BILLING_URL, + XAI_WEB_BILLING_DATA, + XAI_WEB_BILLING_HEADERS, +} from './xaiBillingZero'; import type { TFunction } from 'i18next'; import type { AxiosRequestConfig } from 'axios'; import type { @@ -1485,6 +1491,7 @@ export const mergeXaiBillingSummaries = ( const merged: XaiBillingSummary = { periodType: weeklySource.periodType, usagePercent: weeklySource.usagePercent, + usagePercentSource: weeklySource.usagePercentSource, periodStart: weeklySource.periodStart, periodEnd: weeklySource.periodEnd, productUsage: weeklySource.productUsage, @@ -1644,7 +1651,11 @@ const requestXaiBilling = async ( url: string, header: Record, requestConfig?: AxiosRequestConfig -): Promise<{ summary: XaiBillingSummary; statusCode: number | null } | null> => { +): Promise<{ + summary: XaiBillingSummary; + statusCode: number | null; + zeroFallbackEligible: boolean; +} | null> => { const result = await apiCallApi.request( { authIndex, @@ -1667,7 +1678,8 @@ const requestXaiBilling = async ( } const payload = parseXaiBillingPayload(result.body ?? result.bodyText); - const summary = buildXaiBillingSummary(resolveXaiBillingConfig(payload)); + const config = resolveXaiBillingConfig(payload); + const summary = buildXaiBillingSummary(config); if (!summary) { const envelope = parseXaiErrorEnvelope({ statusCode: result.hasStatusCode ? result.statusCode : null, @@ -1680,6 +1692,10 @@ const requestXaiBilling = async ( } return { summary, + zeroFallbackEligible: + config?.isUnifiedBillingUser === true && + config.creditUsagePercent == null && + config.credit_usage_percent == null, statusCode: result.hasStatusCode ? result.statusCode : null, }; }; @@ -1950,6 +1966,52 @@ const requestXaiBillingProbe = async ( const monthlyProbe = monthlyResult.status === 'fulfilled' ? monthlyResult.value : null; const weeklySummary = weeklyProbe?.summary ?? null; const monthlySummary = monthlyProbe?.summary ?? null; + // Optional same-account enrichment; errors never invalidate successful REST billing. + if ( + weeklyProbe?.zeroFallbackEligible && + weeklySummary?.periodType === 'weekly' && + weeklySummary.usagePercent === null && + weeklySummary.productUsage.length === 0 && + Date.parse(weeklySummary.periodStart ?? '') <= Date.now() && + Date.now() < Date.parse(weeklySummary.periodEnd ?? '') + ) { + try { + const fallbackConfig = { ...requestConfig, timeout: Math.min(configuredTimeout || 3000, 3000) }; + const settings = await apiCallApi.request( + { + authIndex, + method: 'GET', + url: `${XAI_CLI_CHAT_PROXY_BASE_URL}/settings`, + header: requestHeader, + }, + fallbackConfig, + ); + const tier = toXaiRecord(settings.body)?.subscription_tier_display; + // Free/unknown billing can also omit usage after exhaustion (#710). + if (settings.statusCode !== 200 || !['SuperGrok', 'SuperGrok Heavy'].includes(String(tier))) { + throw new Error('unconfirmed paid subscription'); + } + const result = await apiCallApi.request( + { + authIndex, + method: 'POST', + url: XAI_WEB_BILLING_URL, + header: XAI_WEB_BILLING_HEADERS, + data: XAI_WEB_BILLING_DATA, + }, + fallbackConfig, + ); + if ( + result.statusCode === 200 && + isXaiValidatedZero(result.bodyText, weeklySummary.periodStart, weeklySummary.periodEnd) + ) { + weeklySummary.usagePercent = 0; + weeklySummary.usagePercentSource = 'grpc-implicit-zero'; + } + } catch (error) { + if (requestConfig?.signal?.aborted) throw error; + } + } const weeklyFailures = weeklyResult.status === 'rejected' ? [weeklyResult.reason] : []; const monthlyFailures = monthlyResult.status === 'rejected' ? [monthlyResult.reason] : []; const weeklyFailure = weeklyFailures[0]; diff --git a/apps/web/src/utils/quota/xaiBillingZero.test.ts b/apps/web/src/utils/quota/xaiBillingZero.test.ts new file mode 100644 index 000000000..028b360d1 --- /dev/null +++ b/apps/web/src/utils/quota/xaiBillingZero.test.ts @@ -0,0 +1,8 @@ +import cases from '../../../../../tests/fixtures/xai-billing-zero.json'; +import { describe, expect, it } from 'vitest'; +import { isXaiValidatedZero } from './xaiBillingZero'; +describe('validated xAI implicit zero', () => { + it.each(cases)('$name', ({ text, start, end, now, want }) => { + expect(isXaiValidatedZero(text, start, end, Date.parse(now))).toBe(want); + }); +}); diff --git a/apps/web/src/utils/quota/xaiBillingZero.ts b/apps/web/src/utils/quota/xaiBillingZero.ts new file mode 100644 index 000000000..c2a9b2ac5 --- /dev/null +++ b/apps/web/src/utils/quota/xaiBillingZero.ts @@ -0,0 +1,128 @@ +/** A missing REST percentage is unknown. Only a complete, successful gRPC + * response for the same active period can establish a proto3 implicit zero. + * See CodexBar #3261/#3325 and CPA Manager Plus #710. + */ +export const XAI_WEB_BILLING_URL = + 'https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig'; +export const XAI_WEB_BILLING_DATA = 'AAAAAAIIAA=='; +export const XAI_WEB_BILLING_HEADERS = { + Authorization: 'Bearer $TOKEN$', + 'Content-Type': 'application/grpc-web-text+proto', + Accept: 'application/grpc-web-text+proto', + 'x-grpc-web': '1', + Origin: 'https://grok.com', +}; + +// Only schema-declared messages are traversed; unknown bytes remain opaque. +const messages = new Set([ + '1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.12', + '1.6.1', + '1.6.2', + '1.6.3', + '1.8.2', + '1.8.3', + '1.6.3.2', + '1.6.3.3', +]); + +export function isXaiValidatedZero( + text: string, + start: string | undefined, + end: string | undefined, + now = Date.now() +): boolean { + try { + const from = Date.parse(start ?? ''); + const until = Date.parse(end ?? ''); + if (!(from <= now && now < until) || text.length > 64 * 1024) return false; + // grpc-web-text may flush multiple separately padded base64 chunks. + const encoded = text.replace(/\s/g, ''); + if (!encoded || encoded.length % 4 || /[^A-Za-z0-9+/=]/.test(encoded)) return false; + const chunks = encoded.match(/[A-Za-z0-9+/]+={0,2}/g) ?? []; + if ( + chunks.join('') !== encoded || + chunks.some((chunk) => chunk.length % 4 || btoa(atob(chunk)) !== chunk) + ) + return false; + const bytes = Uint8Array.from(chunks.map((chunk) => atob(chunk)).join(''), (c) => + c.charCodeAt(0) + ); + let offset = 0; + let data: Uint8Array | undefined; + let done = false; + while (offset < bytes.length) { + if (done || offset + 5 > bytes.length) return false; + const flag = bytes[offset]; + const length = new DataView(bytes.buffer).getUint32(offset + 1); + offset += 5; + if (offset + length > bytes.length) return false; + const payload = bytes.slice(offset, offset + length); + offset += length; + if (flag === 0 && !data) data = payload; + else if (flag === 128 && data) { + const trailer = new TextDecoder('utf-8', { fatal: true }).decode(payload); + const statuses = trailer.split('\r\n').filter((line) => /^grpc-status:/i.test(line)); + if (statuses.length !== 1 || !/^grpc-status:\s*0\s*$/i.test(statuses[0])) return false; + done = true; + } else return false; + } + if (!data || !done) return false; + const values = new Map(); + const seen = new Set(); + const scan = (buffer: Uint8Array, path: string) => { + let i = 0; + const varint = (): bigint => { + let value = 0n; + for (let shift = 0; shift < 70; shift += 7) { + if (i >= buffer.length) throw Error('truncated'); + const b = buffer[i++]; + if (shift === 63 && b > 1) throw Error('overflow'); + value |= BigInt(b & 127) << BigInt(shift); + if (b < 128) return value; + } + throw Error('overflow'); + }; + while (i < buffer.length) { + const tag = varint(); + const field = tag >> 3n; + const wire = Number(tag & 7n); + if (field < 1n || field > 536870911n) throw Error('invalid tag'); + const key = path ? `${path}.${field}` : `${field}`; + if (key === '1.1' || (messages.has(key) && wire !== 2)) + throw Error('unexpected field type'); + // Duplicate structural fields could hide a conflicting period/config. + if (['1', '1.8', '1.8.1', '1.8.2', '1.8.3', '1.8.2.1', '1.8.3.1'].includes(key)) { + if (seen.has(key)) throw Error('duplicate'); + seen.add(key); + } + if (wire === 0) values.set(key, varint()); + else if (wire === 2) { + const length = varint(); + if (length > BigInt(buffer.length - i)) throw Error('truncated'); + const next = i + Number(length); + if (messages.has(key)) scan(buffer.slice(i, next), key); + i = next; + } else if (wire === 1) i += 8; + // Any float could contradict implicit zero. Never infer from it. + else throw Error('not an implicit zero'); + if (i > buffer.length) throw Error('truncated'); + } + }; + scan(data, ''); + return ( + values.get('1.8.1') === 2n && + values.get('1.8.2.1') === BigInt(Math.floor(from / 1000)) && + values.get('1.8.3.1') === BigInt(Math.floor(until / 1000)) + ); + } catch { + return false; + } +} diff --git a/tests/fixtures/xai-billing-zero.json b/tests/fixtures/xai-billing-zero.json new file mode 100644 index 000000000..a01e46a1b --- /dev/null +++ b/tests/fixtures/xai-billing-zero.json @@ -0,0 +1,178 @@ +[ + { + "name": "complete active weekly zero", + "text": "AAAAABwKGkISCAISBgiAse+GBxoGCICmlIcHWAFiAGgBgAAAAA9ncnBjLXN0YXR1czowDQo=", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": true + }, + { + "name": "single base64 stream", + "text": "AAAAABwKGkISCAISBgiAse+GBxoGCICmlIcHWAFiAGgBgAAAAA9ncnBjLXN0YXR1czowDQo=", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": true + }, + { + "name": "opaque unknown bytes", + "text": "AAAAACEKH0ISCAISBgiAse+GBxoGCICmlIcHWAFiAGgBogECAP8=gAAAAA9ncnBjLXN0YXR1czowDQo=", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": true + }, + { + "name": "historical period", + "text": "AAAAABwKGkISCAISBgiAse+GBxoGCICmlIcHWAFiAGgBgAAAAA9ncnBjLXN0YXR1czowDQo=", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-08T00:00:01Z", + "want": false + }, + { + "name": "future period", + "text": "AAAAABwKGkISCAISBgiAse+GBxoGCICmlIcHWAFiAGgBgAAAAA9ncnBjLXN0YXR1czowDQo=", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2029-12-31T23:59:59Z", + "want": false + }, + { + "name": "different rest reset", + "text": "AAAAABwKGkISCAISBgiAse+GBxoGCICmlIcHWAFiAGgBgAAAAA9ncnBjLXN0YXR1czowDQo=", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-09T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": false + }, + { + "name": "missing rest start", + "text": "AAAAABwKGkISCAISBgiAse+GBxoGCICmlIcHWAFiAGgBgAAAAA9ncnBjLXN0YXR1czowDQo=", + "start": "", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": false + }, + { + "name": "missing grpc status", + "text": "AAAAABwKGkISCAISBgiAse+GBxoGCICmlIcHWAFiAGgB", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": false + }, + { + "name": "grpc denied", + "text": "AAAAABwKGkISCAISBgiAse+GBxoGCICmlIcHWAFiAGgBgAAAABBncnBjLXN0YXR1czoxNg0K", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": false + }, + { + "name": "empty successful response", + "text": "AAAAAAA=gAAAAA9ncnBjLXN0YXR1czowDQo=", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": false + }, + { + "name": "truncated protobuf", + "text": "AAAAABsKGkISCAISBgiAse+GBxoGCICmlIcHWAFiAGg=gAAAAA9ncnBjLXN0YXR1czowDQo=", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": false + }, + { + "name": "truncated frame", + "text": "AAAAABwKGkISCAISBgiAse+GBxoGCICmlIcHWAFiAGg=", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": false + }, + { + "name": "duplicate data", + "text": "AAAAABwKGkISCAISBgiAse+GBxoGCICmlIcHWAFiAGgBAAAAABwKGkISCAISBgiAse+GBxoGCICmlIcHWAFiAGgBgAAAAA9ncnBjLXN0YXR1czowDQo=", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": false + }, + { + "name": "explicit nonzero float", + "text": "AAAAACEKHw0AAEBAQhIIAhIGCICx74YHGgYIgKaUhwdYAWIAaAE=gAAAAA9ncnBjLXN0YXR1czowDQo=", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": false + }, + { + "name": "invalid float", + "text": "AAAAACEKHw0AAMB/QhIIAhIGCICx74YHGgYIgKaUhwdYAWIAaAE=gAAAAA9ncnBjLXN0YXR1czowDQo=", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": false + }, + { + "name": "duplicate period", + "text": "AAAAADAKLkISCAISBgiAse+GBxoGCICmlIcHWAFiAGgBQhIIAhIGCICx74YHGgYIgKaUhwc=gAAAAA9ncnBjLXN0YXR1czowDQo=", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": false + }, + { + "name": "invalid field zero", + "text": "AAAAAB4KGkISCAISBgiAse+GBxoGCICmlIcHWAFiAGgBAAA=gAAAAA9ncnBjLXN0YXR1czowDQo=", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": false + }, + { + "name": "overflow varint", + "text": "AAAAACcKGkISCAISBgiAse+GBxoGCICmlIcHWAFiAGgB/////////////wI=gAAAAA9ncnBjLXN0YXR1czowDQo=", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": false + }, + { + "name": "monthly period", + "text": "AAAAABYKFEISCAESBgiAse+GBxoGCICmlIcHgAAAAA9ncnBjLXN0YXR1czowDQo=", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": false + }, + { + "name": "invalid nested data", + "text": "AAAAAB8KHUISCAISBgiAse+GBxoGCICmlIcHWAFiAGgBEgH/gAAAAA9ncnBjLXN0YXR1czowDQo=", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": false + }, + { + "name": "wrong percentage wire type", + "text": "AAAAAB4KHEISCAISBgiAse+GBxoGCICmlIcHWAFiAGgBCgA=gAAAAA9ncnBjLXN0YXR1czowDQo=", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": false + }, + { + "name": "bad base64", + "text": "====", + "start": "2030-01-01T00:00:00Z", + "end": "2030-01-08T00:00:00Z", + "now": "2030-01-01T01:00:00Z", + "want": false + } +] \ No newline at end of file