diff --git a/docs/providers/xai.md b/docs/providers/xai.md index a40c3908951b..8640586c4530 100644 --- a/docs/providers/xai.md +++ b/docs/providers/xai.md @@ -85,7 +85,11 @@ bundled xAI model provider reuses it as a fallback too. `openclaw status --usage`, `/status`, and the Control UI usage cards show SuperGrok quota when the xAI provider is signed in with OAuth. OpenClaw fetches the Grok billing window for that subscription and reports its reset time through -the normal provider-usage surface. API-key-only xAI setups are intentionally not +the normal provider-usage surface. When xAI omits an included-usage percent on an +otherwise valid weekly or monthly billing period, OpenClaw reports that included +usage was omitted instead of inventing a percentage or showing generic +"No usage data". Pay-as-you-go on-demand counters are not treated as SuperGrok +subscription quota. API-key-only xAI setups are intentionally not shown as SuperGrok usage because xAI Console API credits and SuperGrok subscription quota are separate billing buckets. diff --git a/extensions/xai/usage.test.ts b/extensions/xai/usage.test.ts index ba981a92c7a6..e6995f005570 100644 --- a/extensions/xai/usage.test.ts +++ b/extensions/xai/usage.test.ts @@ -73,6 +73,122 @@ describe("fetchXaiUsage", () => { expect(result.plan).toBe("Premium+"); }); + it("keeps an explicit zero included-usage percent as a weekly window", async () => { + const reset = "2026-09-28T12:01:29Z"; + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { + config: { + creditUsagePercent: 0, + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-09-21T12:01:29Z", + end: reset, + }, + }, + }), + ); + + const result = await fetchXaiUsage("oauth-token", 5000, mockFetch); + + expect(result).toMatchObject({ + displayName: "SuperGrok", + plan: "SuperGrok", + windows: [ + { + label: "Weekly", + usedPercent: 0, + resetAt: new Date(reset).getTime(), + }, + ], + }); + expect(result.error).toBeUndefined(); + expect(result.summary).toBeUndefined(); + }); + + it("reports omitted included usage for a valid weekly billing period without inventing a percent", async () => { + const reset = "2026-09-28T12:01:29Z"; + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { + config: { + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-09-21T12:01:29Z", + end: reset, + }, + onDemandCap: { val: 0 }, + onDemandUsed: { val: 0 }, + isUnifiedBillingUser: true, + prepaidBalance: { val: 0 }, + billingPeriodStart: "2026-09-21T12:01:29Z", + billingPeriodEnd: reset, + }, + }), + ); + + const result = await fetchXaiUsage("oauth-token", 5000, mockFetch); + + expect(result).toEqual({ + provider: "xai", + displayName: "SuperGrok", + windows: [], + billing: [ + { + type: "balance", + label: "Prepaid balance", + amount: 0, + unit: "USD", + }, + ], + plan: "SuperGrok", + summary: "Included usage omitted", + }); + }); + + it("does not treat on-demand counters as included SuperGrok quota", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { + config: { + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-09-21T12:01:29Z", + end: "2026-09-28T12:01:29Z", + }, + onDemandCap: { val: 10000 }, + onDemandUsed: { val: 2500 }, + prepaidBalance: { val: 0 }, + }, + }), + ); + + const result = await fetchXaiUsage("oauth-token", 5000, mockFetch); + + expect(result.windows).toEqual([]); + expect(result.summary).toBe("Included usage omitted"); + expect(result.error).toBeUndefined(); + expect(result.plan).toBe("SuperGrok"); + }); + + it("reports omitted included usage for a valid monthly billing period", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { + config: { + currentPeriod: { + type: "USAGE_PERIOD_TYPE_MONTHLY", + start: "2026-09-01T00:00:00Z", + end: "2026-10-01T00:00:00Z", + }, + }, + }), + ); + + const result = await fetchXaiUsage("oauth-token", 5000, mockFetch); + + expect(result.windows).toEqual([]); + expect(result.summary).toBe("Included usage omitted"); + expect(result.error).toBeUndefined(); + expect(result.plan).toBe("SuperGrok"); + }); + it("returns token-expired errors for billing auth failures", async () => { const mockFetch = createProviderUsageFetch(async () => makeResponse(401, { error: "expired" })); @@ -90,6 +206,25 @@ describe("fetchXaiUsage", () => { ["malformed JSON", "{not-json", "Malformed billing response"], ["missing config", {}, "Malformed billing response"], ["missing usage fields", { config: { prepaidBalance: { val: "100" } } }, "No usage data"], + [ + "weekly period without bounds", + { config: { currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY" } } }, + "No usage data", + ], + [ + "unusable included-usage percent", + { + config: { + creditUsagePercent: -1, + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-09-21T12:01:29Z", + end: "2026-09-28T12:01:29Z", + }, + }, + }, + "No usage data", + ], ])("returns a stable error for %s", async (_name, payload, error) => { const mockFetch = createProviderUsageFetch(async () => makeResponse(200, payload)); diff --git a/extensions/xai/usage.ts b/extensions/xai/usage.ts index 3fbe14f8f588..bb5f806ccfdb 100644 --- a/extensions/xai/usage.ts +++ b/extensions/xai/usage.ts @@ -69,8 +69,45 @@ function parsePercent(value: unknown): number | undefined { return clampPercent(value); } +function readCurrentPeriod(config: BillingConfig) { + return asOptionalRecord(config["currentPeriod"] ?? config["current_period"]); +} + +function readPeriodType(currentPeriod: Record | undefined): string { + return normalizeOptionalString(currentPeriod?.type) ?? ""; +} + +function readPeriodBoundMs( + config: BillingConfig, + currentPeriod: Record | undefined, + bound: "start" | "end", +): number | undefined { + const periodKey = bound === "start" ? "start" : "end"; + const billingKey = bound === "start" ? "billingPeriodStart" : "billingPeriodEnd"; + const billingSnakeKey = bound === "start" ? "billing_period_start" : "billing_period_end"; + return parseDateStringTimestampMs( + currentPeriod?.[periodKey] ?? config[billingKey] ?? config[billingSnakeKey], + ); +} + +function hasRecognizedUsagePeriod(config: BillingConfig): boolean { + const currentPeriod = readCurrentPeriod(config); + const periodType = readPeriodType(currentPeriod); + if (!periodType.endsWith("WEEKLY") && !periodType.endsWith("MONTHLY")) { + return false; + } + return ( + readPeriodBoundMs(config, currentPeriod, "start") !== undefined || + readPeriodBoundMs(config, currentPeriod, "end") !== undefined + ); +} + +function hasIncludedUsagePercentField(config: BillingConfig): boolean { + return (config["creditUsagePercent"] ?? config["credit_usage_percent"]) !== undefined; +} + function resolveUsageWindow(config: BillingConfig): UsageWindow | undefined { - const currentPeriod = asOptionalRecord(config["currentPeriod"] ?? config["current_period"]); + const currentPeriod = readCurrentPeriod(config); const explicitPercent = parsePercent( config["creditUsagePercent"] ?? config["credit_usage_percent"], ); @@ -85,7 +122,7 @@ function resolveUsageWindow(config: BillingConfig): UsageWindow | undefined { return undefined; } - const periodType = normalizeOptionalString(currentPeriod?.type) ?? ""; + const periodType = readPeriodType(currentPeriod); const label = periodType.endsWith("WEEKLY") ? "Weekly" : periodType.endsWith("MONTHLY") || @@ -94,9 +131,7 @@ function resolveUsageWindow(config: BillingConfig): UsageWindow | undefined { config["billing_period_end"] !== undefined ? "Monthly" : "Usage"; - const resetAt = parseDateStringTimestampMs( - currentPeriod?.end ?? config["billingPeriodEnd"] ?? config["billing_period_end"], - ); + const resetAt = readPeriodBoundMs(config, currentPeriod, "end"); return { label, usedPercent: percent, @@ -132,21 +167,38 @@ function buildSuperGrokUsageSnapshot(data: unknown): ProviderUsageSnapshot { } const window = resolveUsageWindow(config); - if (!window) { + const billing = resolveBilling(config); + const plan = + parsePlan(payload?.["subscription_tier"] ?? payload?.["subscriptionTier"]) ?? "SuperGrok"; + if (window) { + return { + provider: XAI_PROVIDER_ID, + displayName: "SuperGrok", + windows: [window], + billing, + plan, + }; + } + + // xAI omits default-zero included-usage scalars on valid weekly/monthly + // billing responses. Do not invent a percent, and do not read on-demand + // pay-as-you-go counters as SuperGrok subscription quota. + if (!hasIncludedUsagePercentField(config) && hasRecognizedUsagePeriod(config)) { return { provider: XAI_PROVIDER_ID, displayName: "SuperGrok", windows: [], - error: "No usage data", + billing, + plan, + summary: "Included usage omitted", }; } return { provider: XAI_PROVIDER_ID, displayName: "SuperGrok", - windows: [window], - billing: resolveBilling(config), - plan: parsePlan(payload?.["subscription_tier"] ?? payload?.["subscriptionTier"]) ?? "SuperGrok", + windows: [], + error: "No usage data", }; }