fix(ai): prevented stale ai usage cache re-plays during invalidation

- Added force-refresh tracking to serialize provider usage probes and prevent stale fallback re-plays after manual invalidation.
- Updated usage request handling to incorporate cache epochs and prevent stale in-flight results from overwriting new data.
- Added integration test verifying that broker invalidations correctly drop server-side last-good usage reports.
This commit is contained in:
can1357
2026-08-14 07:02:29 +02:00
parent e5d59450c8
commit ebcbdd5297
4 changed files with 303 additions and 29 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed `omp usage invalidate` to discard stale OAuth and API-key usage snapshots, then force a cache-bypassing, per-provider serialized refresh so upgraded subscriptions do not silently retain pre-change quota data.
## [17.3.3] - 2026-08-14
### Fixed
+116 -27
View File
@@ -677,6 +677,7 @@ const DEFAULT_USAGE_PROVIDER_MAP = new Map<Provider, UsageProvider>(
);
const USAGE_CACHE_PREFIX = "usage_cache:";
const USAGE_FORCE_REFRESH_CACHE_PREFIX = "force-refresh:";
const USAGE_HEADER_INGEST_INTERVAL_MS = 60_000;
const USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000;
/**
@@ -686,6 +687,12 @@ const USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000;
* on the next poll.
*/
const USAGE_FAILURE_BACKOFF_MS = 10_000;
/**
* A manual invalidation persists across the next CLI process and serializes
* same-provider probes, avoiding a cold-account burst against IP-limited
* upstream usage endpoints.
*/
const USAGE_FORCE_REFRESH_TTL_MS = 5 * 60_000;
// Bumped from 3s — Claude usage retries up to 3 times with exponential backoff
// (~3.5s total worst case); a tight per-request budget aborts retries mid-cycle.
const DEFAULT_USAGE_REQUEST_TIMEOUT_MS = 10_000;
@@ -792,6 +799,11 @@ type UsageRequestDescriptor = {
baseUrl?: string;
};
type ForcedUsageRefresh = {
all: boolean;
providers: Set<Provider>;
};
type AuthApiKeyOptions = {
baseUrl?: string;
modelId?: string;
@@ -2967,6 +2979,47 @@ export class AuthStorage {
return versionOverride === undefined ? provider : `${versionOverride}:${provider}`;
}
#usageForceRefreshCacheKey(provider?: Provider): string {
return provider
? `${USAGE_FORCE_REFRESH_CACHE_PREFIX}provider:${provider}`
: `${USAGE_FORCE_REFRESH_CACHE_PREFIX}all`;
}
#markUsageForceRefresh(provider?: Provider): void {
this.#usageCache.set(this.#usageForceRefreshCacheKey(provider), {
value: true,
expiresAt: Date.now() + USAGE_FORCE_REFRESH_TTL_MS,
});
}
#hasUsageForceRefresh(provider?: Provider): boolean {
const key = this.#usageForceRefreshCacheKey(provider);
const entry = this.#usageCache.get<boolean>(key);
if (entry?.value !== true) return false;
if (entry.expiresAt > Date.now()) return true;
this.#usageCache.set(key, { value: null, expiresAt: 0 });
return false;
}
#usageForceRefresh(requests: readonly UsageRequestDescriptor[]): ForcedUsageRefresh {
const all = this.#hasUsageForceRefresh();
const providers = new Set<Provider>();
for (const request of requests) providers.add(request.provider);
if (!all) {
for (const provider of providers) {
if (!this.#hasUsageForceRefresh(provider)) providers.delete(provider);
}
}
return { all, providers };
}
#clearUsageForceRefresh(refresh: ForcedUsageRefresh): void {
if (refresh.all) this.#usageCache.set(this.#usageForceRefreshCacheKey(), { value: null, expiresAt: 0 });
for (const provider of refresh.providers) {
this.#usageCache.set(this.#usageForceRefreshCacheKey(provider), { value: null, expiresAt: 0 });
}
}
#buildUsageReportCacheKey(request: UsageRequestDescriptor): string {
const baseUrl = this.#normalizeUsageBaseUrl(request.baseUrl) || "default";
const identity = this.#buildUsageCacheIdentity(request.credential);
@@ -3219,19 +3272,23 @@ export class AuthStorage {
}
}
async #fetchUsageCached(request: UsageRequestDescriptor, timeoutMs?: number): Promise<UsageReport | null> {
async #fetchUsageCached(
request: UsageRequestDescriptor,
timeoutMs?: number,
forceRefresh = false,
): Promise<UsageReport | null> {
const cacheKey = this.#buildUsageReportCacheKey(request);
const now = Date.now();
const cached = this.#usageCache.get<UsageReport | null>(cacheKey);
const cached = forceRefresh ? undefined : this.#usageCache.get<UsageReport | null>(cacheKey);
// Fresh cache hit: return whatever's there (success or null fallback).
if (cached && cached.expiresAt > now) {
return cached.value;
}
const inFlight = this.#usageRequestInFlight.get(cacheKey);
if (inFlight) return inFlight;
const usageCacheEpoch = this.#usageCacheEpoch;
const inFlightKey = `${cacheKey}\0${usageCacheEpoch}`;
const inFlight = this.#usageRequestInFlight.get(inFlightKey);
if (inFlight) return inFlight;
const promise = (async () => {
const report = await this.#fetchUsageUncached(request, timeoutMs);
if (usageCacheEpoch !== this.#usageCacheEpoch) return report;
@@ -3251,7 +3308,8 @@ export class AuthStorage {
// re-hit the endpoint on every poll. Most providers serve the last good
// value through transient failures. Session-cookie providers can opt out
// so an expired login does not display stale quota indefinitely.
const retainLastGood = this.#usageProviderResolver?.(request.provider)?.retainLastGoodOnFailure !== false;
const retainLastGood =
!forceRefresh && this.#usageProviderResolver?.(request.provider)?.retainLastGoodOnFailure !== false;
const lastGood = retainLastGood
? (this.#usageCache.getStale<UsageReport | null>(cacheKey)?.value ?? null)
: null;
@@ -3260,10 +3318,10 @@ export class AuthStorage {
this.#usageCache.set(cacheKey, { value: lastGood, expiresAt: coolDown });
return lastGood;
})().finally(() => {
this.#usageRequestInFlight.delete(cacheKey);
this.#usageRequestInFlight.delete(inFlightKey);
});
this.#usageRequestInFlight.set(cacheKey, promise);
this.#usageRequestInFlight.set(inFlightKey, promise);
return promise;
}
@@ -3936,6 +3994,35 @@ export class AuthStorage {
return true;
}
/**
* Fetch every requested report, keeping normal polls parallel while a
* manually invalidated provider probes accounts one at a time.
*/
#fetchUsageRequests(
requests: readonly UsageRequestDescriptor[],
serializedProviders: ReadonlySet<Provider>,
): Promise<Array<UsageReport | null>> {
const tails = new Map<Provider, Promise<void>>();
return Promise.all(
requests.map(request => {
const forceRefresh = serializedProviders.has(request.provider);
if (!forceRefresh) {
return this.#fetchUsageCached(request, this.#usageRequestTimeoutMs);
}
const tail = tails.get(request.provider) ?? Promise.resolve();
const current = tail.then(() => this.#fetchUsageCached(request, this.#usageRequestTimeoutMs, true));
tails.set(
request.provider,
current.then(
() => undefined,
() => undefined,
),
);
return current;
}),
);
}
async fetchUsageReports(options?: {
baseUrlResolver?: (provider: Provider) => string | undefined;
/** Caller's cancel signal; only rejects this caller, never the shared upstream fetch. */
@@ -3954,15 +4041,15 @@ export class AuthStorage {
// dispatch + credential selection) coalesce into one upstream call.
// Each caller's `signal` only cancels THAT caller's await; the
// shared upstream fetch runs to completion so peers aren't punished.
const OVERRIDE_KEY = "__override__";
let shared = this.#usageReportsInFlight.get(OVERRIDE_KEY);
const overrideKey = `__override__\0${this.#usageCacheEpoch}`;
let shared = this.#usageReportsInFlight.get(overrideKey);
if (!shared) {
// Don't forward the caller signal into the shared fetch — first caller's
// abort would otherwise cancel the upstream for every peer.
shared = override().finally(() => {
this.#usageReportsInFlight.delete(OVERRIDE_KEY);
this.#usageReportsInFlight.delete(overrideKey);
});
this.#usageReportsInFlight.set(OVERRIDE_KEY, shared);
this.#usageReportsInFlight.set(overrideKey, shared);
}
const reports = await raceUsageWithSignal(shared, options?.signal);
if (shouldReconcileStoreHookReports && reports) this.#reconcileCodexUsageBlocksFromReports(reports);
@@ -3982,7 +4069,8 @@ export class AuthStorage {
// a single decorrelation snapshot for 30s, defeating the jitter (some
// accounts can be missing from one fetch and present in the next; the
// aggregate cache freezes whichever set landed first).
const cacheKey = this.#buildUsageReportsCacheKey(requests);
const forcedRefresh = this.#usageForceRefresh(requests);
const cacheKey = `${this.#buildUsageReportsCacheKey(requests)}\0${this.#usageCacheEpoch}`;
const inFlight = this.#usageReportsInFlight.get(cacheKey);
if (inFlight) return inFlight;
@@ -3998,9 +4086,7 @@ export class AuthStorage {
});
}
const results = await Promise.all(
requests.map(request => this.#fetchUsageCached(request, this.#usageRequestTimeoutMs)),
);
const results = await this.#fetchUsageRequests(requests, forcedRefresh.providers);
const reports = results.filter((report): report is UsageReport => report !== null);
const deduped = this.#dedupeUsageReports(reports);
// no outer cache write — see comment above.
@@ -4021,6 +4107,7 @@ export class AuthStorage {
};
}),
});
this.#clearUsageForceRefresh(forcedRefresh);
return resolved;
})().finally(() => {
this.#usageReportsInFlight.delete(cacheKey);
@@ -5739,26 +5826,28 @@ export class AuthStorage {
/**
* Drop report snapshots for a user-requested refresh so a failed probe
* cannot replay the pre-invalidation last-good value.
* cannot replay the pre-invalidation last-good value. The persisted marker
* makes the next same-provider refresh serial rather than a cold fan-out.
*/
async #clearUsageReportCache(provider?: string): Promise<void> {
this.#usageCacheEpoch += 1;
const prefix = provider ? `report:${this.#usageCacheProviderKey(provider)}:` : "report:";
if (this.#usageCache.deletePrefix(prefix)) return;
// Third-party stores may not support prefix deletion. Clear every active
// request key instead, including API-key and environment credentials.
const requests = await this.#collectUsageRequests();
for (const request of requests) {
if (provider && request.provider !== provider) continue;
this.#usageCache.set(this.#buildUsageReportCacheKey(request), { value: null, expiresAt: 0 });
if (!this.#usageCache.deletePrefix(prefix)) {
// Third-party stores may not support prefix deletion. Clear every active
// request key instead, including API-key and environment credentials.
const requests = await this.#collectUsageRequests();
for (const request of requests) {
if (provider && request.provider !== provider) continue;
this.#usageCache.set(this.#buildUsageReportCacheKey(request), { value: null, expiresAt: 0 });
}
}
if (!this.#fetchUsageReportsOverride && !this.#store.fetchUsageReports) this.#markUsageForceRefresh(provider);
}
/**
* Discard cached usage reports before a user-requested refresh. The next
* read probes upstream; a failure reports no fresh usage instead of replaying
* an invalidated last-good snapshot.
* read probes upstream serially per provider; a failure reports no fresh
* usage instead of replaying an invalidated last-good snapshot.
*/
async invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void> {
await this.#clearUsageReportCache(provider);
@@ -342,7 +342,6 @@ describe("AuthStorage usage cache: last-good failure fallback", () => {
});
});
describe("AuthStorage usage cache: explicit invalidation", () => {
it("clears cached API-key reports before the next usage read", async () => {
const store = makeStore([
@@ -391,6 +390,138 @@ describe("AuthStorage usage cache: explicit invalidation", () => {
storage.close();
}
});
it("serializes a persisted Codex refresh and returns an upgraded plan", async () => {
const store = makeStore([
{
id: 1,
provider: "openai-codex",
credential: {
type: "oauth",
access: "access-free",
refresh: "refresh-free",
expires: Date.now() + 3_600_000,
accountId: "account-free",
email: "free@example.com",
},
disabledCause: null,
},
{
id: 2,
provider: "openai-codex",
credential: {
type: "oauth",
access: "access-upgraded",
refresh: "refresh-upgraded",
expires: Date.now() + 3_600_000,
accountId: "account-upgraded",
email: "upgraded@example.com",
},
disabledCause: null,
},
{
id: 3,
provider: "openai-codex",
credential: {
type: "oauth",
access: "access-other",
refresh: "refresh-other",
expires: Date.now() + 3_600_000,
accountId: "account-other",
email: "other@example.com",
},
disabledCause: null,
},
]);
let upgraded = false;
const refreshStarted = new Map<string, PromiseWithResolvers<void>>();
const refreshReleases = new Map<string, PromiseWithResolvers<void>>();
for (const accountId of ["account-free", "account-upgraded", "account-other"]) {
refreshStarted.set(accountId, Promise.withResolvers<void>());
refreshReleases.set(accountId, Promise.withResolvers<void>());
}
const startedAccounts: string[] = [];
const usageProvider: UsageProvider = {
id: "openai-codex",
supports: params => params.provider === "openai-codex" && params.credential.type === "oauth",
async fetchUsage(params) {
const accountId = params.credential.accountId;
if (!accountId) return null;
if (upgraded) {
const started = refreshStarted.get(accountId);
const release = refreshReleases.get(accountId);
if (!started || !release) throw new Error(`unexpected account ${accountId}`);
startedAccounts.push(accountId);
started.resolve();
await release.promise;
}
return {
provider: "openai-codex",
fetchedAt: Date.now(),
limits: [
{
id: "openai-codex:7d",
label: "7 days",
scope: { provider: "openai-codex", windowId: "7d" },
amount: { used: 0, limit: 100, unit: "percent" },
status: "ok",
},
],
metadata: {
accountId,
email: params.credential.email,
planType: upgraded && accountId === "account-upgraded" ? "pro" : "free",
},
};
},
};
const initialStorage = new AuthStorage(store, {
usageProviderResolver: provider => (provider === "openai-codex" ? usageProvider : undefined),
});
await initialStorage.reload();
try {
expect(await initialStorage.fetchUsageReports()).toHaveLength(3);
await initialStorage.invalidateUsageCache();
} finally {
initialStorage.close();
}
upgraded = true;
const refreshedStorage = new AuthStorage(store, {
usageProviderResolver: provider => (provider === "openai-codex" ? usageProvider : undefined),
});
await refreshedStorage.reload();
try {
const refresh = refreshedStorage.fetchUsageReports();
const freeStarted = refreshStarted.get("account-free");
const freeRelease = refreshReleases.get("account-free");
if (!freeStarted || !freeRelease) throw new Error("missing free-account refresh gates");
await freeStarted.promise;
expect(startedAccounts).toEqual(["account-free"]);
freeRelease.resolve();
const upgradedStarted = refreshStarted.get("account-upgraded");
const upgradedRelease = refreshReleases.get("account-upgraded");
if (!upgradedStarted || !upgradedRelease) throw new Error("missing upgraded-account refresh gates");
await upgradedStarted.promise;
expect(startedAccounts).toEqual(["account-free", "account-upgraded"]);
upgradedRelease.resolve();
const otherStarted = refreshStarted.get("account-other");
const otherRelease = refreshReleases.get("account-other");
if (!otherStarted || !otherRelease) throw new Error("missing other-account refresh gates");
await otherStarted.promise;
expect(startedAccounts).toEqual(["account-free", "account-upgraded", "account-other"]);
otherRelease.resolve();
const reports = await refresh;
expect(reports?.find(report => report.metadata?.accountId === "account-upgraded")?.metadata?.planType).toBe(
"pro",
);
} finally {
refreshedStorage.close();
}
});
});
describe("AuthStorage usage cache: provider failure policy", () => {
it("drops stale QwenCloud quota after the optional console session expires", async () => {
+51 -1
View File
@@ -16,6 +16,7 @@ import {
import { snapshotResponseSchema } from "@oh-my-pi/pi-ai/auth-broker/wire-schemas";
import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth";
import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai/usage";
import * as claudeUsage from "@oh-my-pi/pi-ai/usage/claude";
import { removeWithRetries } from "../../utils/src/temp";
function requireLimit(report: UsageReport, id: string): UsageLimit {
@@ -48,7 +49,9 @@ describe("RemoteAuthCredentialStore + AuthStorage integration", () => {
accountId: "account-1",
email: "a@example.com",
});
serverStorage = new AuthStorage(serverStore);
serverStorage = new AuthStorage(serverStore, {
usageProviderResolver: provider => (provider === "anthropic" ? claudeUsage.claudeUsageProvider : undefined),
});
await serverStorage.reload();
handle = startAuthBroker({
storage: serverStorage,
@@ -1105,6 +1108,53 @@ describe("RemoteAuthCredentialStore + AuthStorage integration", () => {
clientStorage.close();
});
test("broker invalidation drops server-side last-good usage reports", async () => {
const credential = serverStore!.listAuthCredentials("anthropic")[0];
if (!credential || credential.credential.type !== "oauth") throw new Error("expected OAuth credential");
serverStore!.updateAuthCredential(credential.id, {
...credential.credential,
expires: Date.now() + 3_600_000,
});
await serverStorage!.reload();
let calls = 0;
const fetchSpy = vi.spyOn(claudeUsage.claudeUsageProvider, "fetchUsage").mockImplementation(async () => {
calls += 1;
if (calls > 1) return null;
return {
provider: "anthropic",
fetchedAt: Date.now(),
limits: [
{
id: "anthropic:5h",
label: "Claude 5 Hour",
scope: { provider: "anthropic", windowId: "5h" },
amount: { used: 80, limit: 100, unit: "percent" },
status: "ok",
},
],
metadata: { accountId: "account-1", email: "a@example.com" },
};
});
const brokerClient = new AuthBrokerClient({ url: handle!.url, token });
const initialResult = await brokerClient.fetchSnapshot();
if (initialResult.status !== 200) throw new Error("expected snapshot");
const remoteStore = new RemoteAuthCredentialStore({
client: brokerClient,
initialSnapshot: initialResult.snapshot,
});
const clientStorage = new AuthStorage(remoteStore);
await clientStorage.reload();
try {
expect(await clientStorage.fetchUsageReports()).toHaveLength(1);
await clientStorage.invalidateUsageCache();
expect(await clientStorage.fetchUsageReports()).toEqual([]);
expect(fetchSpy).toHaveBeenCalledTimes(2);
} finally {
clientStorage.close();
}
});
test("account pool exposes only qualified usage reports for visible OAuth identities", async () => {
const brokerClient = new AuthBrokerClient({ url: "http://127.0.0.1:9", token: "unused" });
const now = Date.now();