diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 06bece4e2..4a95cf538 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -7,6 +7,7 @@ - Fixed OpenAI Responses turns ending silently after a provider-hosted web search that produced no visible answer: the turn is now classified as `pause_turn` so the agent automatically continues with the search results instead of stopping. - Fixed completed model streams retaining their provider concurrency permit until after completion became observable, without replacing provider results when lease cleanup fails ([#8284](https://github.com/can1357/oh-my-pi/pull/8284) by [@ethancawse](https://github.com/ethancawse)). - Fixed the DashScope compatible-mode text-only Qwen override (issue #1859) stripping images from `qwen3.8-max`, which became multimodal in the bundled catalog (image input, #8019). The `-max` guard now only vetoes image content for pre-3.8 SKUs, so `qwen3.8-max`/`qwen3.8-max-preview` and later flagships send `image_url` content — restoring `inspect_image` on those models configured against `dashscope.aliyuncs.com/compatible-mode/v1` ([#8305](https://github.com/can1357/oh-my-pi/issues/8305)). +- Fixed xAI (`xai-oauth`) usage reporting falling back to a stale exhausted cache when a fresh weekly cycle with 0% consumed credits omits the `creditUsagePercent` field ([#8325](https://github.com/can1357/oh-my-pi/pull/8325) by [@bubua12](https://github.com/bubua12)). ## [17.2.15] - 2026-08-12 diff --git a/packages/ai/src/usage/xai-oauth.ts b/packages/ai/src/usage/xai-oauth.ts index 91eb688f7..161533d1f 100644 --- a/packages/ai/src/usage/xai-oauth.ts +++ b/packages/ai/src/usage/xai-oauth.ts @@ -50,6 +50,7 @@ interface XaiWeeklyBillingConfig { productUsage: XaiProductUsage[]; onDemandCap?: number; onDemandUsed?: number; + inferredPercent?: boolean; } /** @@ -137,7 +138,16 @@ function parseWeeklyBillingConfig(raw: Record): XaiWeeklyBillin return null; } - const creditUsagePercent = parsePercent(raw.creditUsagePercent); + // Fresh weekly periods (or accounts with 0 usage) omit creditUsagePercent; + // default to 0 only when the weekly period is active (end > now). + // Expired periods without explicit usage data are rejected to retain last good cache. + const inferredPercent = raw.creditUsagePercent === undefined || raw.creditUsagePercent === null; + let creditUsagePercent: number | undefined; + if (inferredPercent) { + creditUsagePercent = end > Date.now() ? 0 : undefined; + } else { + creditUsagePercent = parsePercent(raw.creditUsagePercent); + } if (creditUsagePercent === undefined) return null; const productUsage: XaiProductUsage[] = []; @@ -146,7 +156,8 @@ function parseWeeklyBillingConfig(raw: Record): XaiWeeklyBillin for (const item of raw.productUsage) { if (!isRecord(item)) continue; const product = typeof item.product === "string" ? item.product.trim() : ""; - const usagePercent = parsePercent(item.usagePercent); + const usagePercent = + item.usagePercent === undefined || item.usagePercent === null ? 0 : parsePercent(item.usagePercent); if (!product || usagePercent === undefined) continue; productUsage.push({ product, usagePercent }); } @@ -163,6 +174,7 @@ function parseWeeklyBillingConfig(raw: Record): XaiWeeklyBillin productUsage, onDemandCap: parseOnDemandAmount(raw.onDemandCap), onDemandUsed: parseOnDemandAmount(raw.onDemandUsed), + inferredPercent, }; } @@ -190,6 +202,10 @@ function parseMonthlyBillingConfig(raw: Record): XaiMonthlyBill onDemandUsed: parseOnDemandAmount(raw.onDemandUsed), }; } +function hasPositiveMonthlyLimit(raw: Record): boolean { + const limit = parseOnDemandAmount(raw.monthlyLimit); + return limit !== undefined && limit > 0; +} function buildOnDemandLimit( onDemandCap: number | undefined, @@ -361,10 +377,31 @@ export const xaiOauthUsageProvider: UsageProvider = { : null; } - if (!weekly && !monthly) return null; + // When an account is marked unified billing and weekly credits were only inferred + // from an omitted percentage field: + // - If a positive monthly quota is returned, use the monthly quota alone. + // - If the monthly endpoint returned a valid config without positive monthly quota, + // confirm that this account relies on the weekly reset cycle and use weekly. + // - If the monthly fetch failed (transient network error), reject inferred weekly + // so AuthStorage's retain-last-good cache preserves the previous valid snapshot. + let effectiveWeekly = weekly; + if (weekly?.inferredPercent && creditsLooksUnified) { + if (monthly) { + effectiveWeekly = null; + } else { + const monthlyConfig = + monthlyPayload && isRecord(monthlyPayload) && isRecord(monthlyPayload.config) + ? monthlyPayload.config + : null; + if (!monthlyConfig || hasPositiveMonthlyLimit(monthlyConfig)) { + effectiveWeekly = null; + } + } + } + if (!effectiveWeekly && !monthly) return null; const limits: UsageLimit[] = []; - if (weekly) limits.push(...buildLimits(weekly, accountId)); + if (effectiveWeekly) limits.push(...buildLimits(effectiveWeekly, accountId)); if (monthly) limits.push(...buildLimits(monthly, accountId)); // Deduplicate on-demand if both shapes carried the same cap (keep first). const seen = new Set(); @@ -375,12 +412,13 @@ export const xaiOauthUsageProvider: UsageProvider = { }); if (deduped.length === 0) return null; - const billingKind = weekly && monthly ? "unified" : weekly ? "weekly" : "monthly"; - const endpoint = weekly && monthly ? `${creditsUrl} + ${monthlyUrl}` : weekly ? creditsUrl : monthlyUrl; + const billingKind = effectiveWeekly && monthly ? "unified" : effectiveWeekly ? "weekly" : "monthly"; + const endpoint = + effectiveWeekly && monthly ? `${creditsUrl} + ${monthlyUrl}` : effectiveWeekly ? creditsUrl : monthlyUrl; const raw = - weekly && monthly + effectiveWeekly && monthly ? { credits: creditsPayload, monthly: monthlyPayload } - : weekly + : effectiveWeekly ? creditsPayload : monthlyPayload; diff --git a/packages/ai/test/xai-oauth-usage.test.ts b/packages/ai/test/xai-oauth-usage.test.ts index 59d48d387..09674d4ff 100644 --- a/packages/ai/test/xai-oauth-usage.test.ts +++ b/packages/ai/test/xai-oauth-usage.test.ts @@ -125,6 +125,9 @@ function dualBillingFetch( }); } const payload = url.includes("format=credits") ? creditsPayload : monthlyPayload; + if (payload === null) { + return new Response("internal error", { status: 500 }); + } return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" }, @@ -224,6 +227,114 @@ describe("xai-oauth usage provider", () => { expect(report?.limits[0]?.id).toBe("xai-oauth:credits:1w"); expect(report?.limits[0]?.window?.resetsAt).toBe(Date.parse(periodEnd)); }); + it("rejects an expired weekly period when creditUsagePercent is omitted", async () => { + const periodEnd = new Date(Date.now() - 60_000).toISOString(); + const periodStart = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(); + const report = await xaiOauthUsageProvider.fetchUsage( + { provider: "xai-oauth", credential: makeCredential() }, + { + fetch: capturingFetch({ + config: { + currentPeriod: { + end: periodEnd, + start: periodStart, + type: "USAGE_PERIOD_TYPE_WEEKLY", + }, + }, + }).fetch, + }, + ); + + expect(report).toBeNull(); + }); + + it("reports zero usage when weekly period is active but creditUsagePercent is omitted (fresh reset)", async () => { + const periodEnd = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000).toISOString(); + const periodStart = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const report = await xaiOauthUsageProvider.fetchUsage( + { provider: "xai-oauth", credential: makeCredential() }, + { + fetch: capturingFetch({ + config: { + currentPeriod: { + end: periodEnd, + start: periodStart, + type: "USAGE_PERIOD_TYPE_WEEKLY", + }, + isUnifiedBillingUser: true, + onDemandCap: { val: 0 }, + onDemandUsed: { val: 0 }, + }, + }).fetch, + }, + ); + + expect(report?.metadata?.billingKind).toBe("weekly"); + expect(report?.limits.map(limit => limit.id)).toEqual(["xai-oauth:credits:1w"]); + const credits = report?.limits[0]; + expect(credits?.amount.used).toBe(0); + expect(credits?.amount.limit).toBe(100); + expect(credits?.amount.remaining).toBe(100); + expect(credits?.amount.usedFraction).toBe(0); + expect(credits?.amount.remainingFraction).toBe(1); + expect(credits?.status).toBe("ok"); + expect(credits?.window?.resetsAt).toBe(Date.parse(periodEnd)); + }); + + it("rejects inferred unified weekly usage when the monthly probe encounters a network error", async () => { + const periodEnd = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000).toISOString(); + const periodStart = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const report = await xaiOauthUsageProvider.fetchUsage( + { provider: "xai-oauth", credential: makeCredential() }, + { + fetch: dualBillingFetch( + { + config: { + currentPeriod: { + end: periodEnd, + start: periodStart, + type: "USAGE_PERIOD_TYPE_WEEKLY", + }, + isUnifiedBillingUser: true, + }, + }, + null, + ).fetch, + }, + ); + + expect(report).toBeNull(); + }); + it("rejects inferred unified weekly usage when monthly config has a positive limit but malformed fields", async () => { + const periodEnd = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000).toISOString(); + const periodStart = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const report = await xaiOauthUsageProvider.fetchUsage( + { provider: "xai-oauth", credential: makeCredential() }, + { + fetch: dualBillingFetch( + { + config: { + currentPeriod: { + end: periodEnd, + start: periodStart, + type: "USAGE_PERIOD_TYPE_WEEKLY", + }, + isUnifiedBillingUser: true, + }, + }, + { + config: { + isUnifiedBillingUser: true, + monthlyLimit: { val: 15000 }, + // Missing 'used' and billingPeriod dates + }, + }, + ).fetch, + }, + ); + + expect(report).toBeNull(); + }); it("falls back to monthly included quota when credits has no percent fields", async () => { const { fetch, calls } = dualBillingFetch(makeUnifiedCreditsPayload(), makeUnifiedMonthlyPayload()); @@ -290,7 +401,6 @@ describe("xai-oauth usage provider", () => { ).fetch, }, ); - expect(report?.limits.map(limit => limit.id)).toEqual(["xai-oauth:included:1mo", "xai-oauth:on-demand"]); const onDemand = report?.limits.find(limit => limit.id === "xai-oauth:on-demand"); expect(onDemand?.amount.used).toBe(25); @@ -299,10 +409,11 @@ describe("xai-oauth usage provider", () => { }); it("returns null when both credits and monthly billing shapes are unusable", async () => { + const creditsUnusable = { config: { isUnifiedBillingUser: true, onDemandCap: { val: 0 } } }; const report = await xaiOauthUsageProvider.fetchUsage( { provider: "xai-oauth", credential: makeCredential() }, { - fetch: dualBillingFetch(makeUnifiedCreditsPayload(), { + fetch: dualBillingFetch(creditsUnusable, { config: { isUnifiedBillingUser: true, monthlyLimit: { val: 0 }, used: { val: 0 } }, }).fetch, },