From 1257b8b21b1dad310a367931082efc12af25bfbb Mon Sep 17 00:00:00 2001 From: yhualin Date: Fri, 4 Sep 2026 16:43:05 +0800 Subject: [PATCH 1/5] feat(quota): support Google Antigravity weekly quota and Ollama Cloud quota --- src/providers/quota.ts | 212 +++++++++++- .../providers/provider-account-quota.test.ts | 62 +++- tests/providers/provider-quota.test.ts | 302 ++++++++++++++++++ 3 files changed, 567 insertions(+), 9 deletions(-) diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 9cdaafe6b6..c2911a2129 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -13,6 +13,7 @@ import { resolveProviderApiKey } from "./key-store"; import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth"; import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store"; import { antigravityUserAgent } from "../adapters/client-fingerprint"; +import { isCanonicalOllamaCloudUrl } from "../adapters/ollama-native-url"; import { providerOutboundPost, providerRedirectError, type ProviderOutboundDependencies } from "../lib/provider-outbound"; import { apiKeyPoolEntryId } from "./api-keys"; import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "./xai-transport"; @@ -83,6 +84,8 @@ const OPENCODE_GO_USAGE_URL = `${OPENCODE_GO_BASE_URL}/usage`; const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; const DEEPSEEK_BASE_URL = "https://api.deepseek.com"; const CLINE_BASE_URL = "https://api.cline.bot"; +const OLLAMA_CLOUD_BASE_URL = "https://ollama.com"; +const OLLAMA_CLOUD_USAGE_URL = `${OLLAMA_CLOUD_BASE_URL}/api/usage`; const ZAI_BASE_URL = "https://api.z.ai"; const ZAI_CN_BASE_URL = "https://open.bigmodel.cn"; const MINIMAX_REMAINS_URL = "https://www.minimax.io/v1/token_plan/remains"; @@ -328,6 +331,15 @@ function isCanonicalClineBaseUrl(baseUrl: string): boolean { return normalized === CLINE_BASE_URL || normalized === `${CLINE_BASE_URL}/api/v1`; } +function isCanonicalOllamaCloudBaseUrl(baseUrl?: string): boolean { + if (!baseUrl) return false; + try { + return isCanonicalOllamaCloudUrl(baseUrl); + } catch { + return false; + } +} + function isCanonicalZaiBaseUrl(baseUrl: string): boolean { const normalized = normalizedBaseUrl(baseUrl); return normalized === ZAI_BASE_URL @@ -659,6 +671,78 @@ async function fetchClineQuota(provider: string, config: OcxProviderConfig): Pro return windows > 0 ? report(provider, "cline:plan-usage-limits", quota) : null; } +/** + * Ollama Cloud `GET https://ollama.com/api/usage` — returns account usage. + * Legacy plans report rolling 5-hour `limits.session.usage` and 7-day + * `limits.weekly.usage`. Migrated monthly-credit plans report + * `limits.monthly.usage`. `usage` values are normalized fractions (0..1). + */ +function parseOllamaPercent(usageValue: unknown): number | undefined { + const usage = toFiniteNumber(usageValue); + if (usage === undefined || usage < 0) return undefined; + const percent = Math.round(usage * 10000) / 100; + return normalizePercent(percent); +} + +export function parseOllamaCloudQuota(body: Record | null): ProviderQuota | null { + if (!body) return null; + const limits = asRecord(body.limits); + if (!limits) return null; + + const quota: ProviderQuota = { updatedAt: Date.now() }; + let windows = 0; + + const session = asRecord(limits.session); + if (session) { + const percent = parseOllamaPercent(session.usage); + if (percent !== undefined) { + quota.fiveHourPercent = percent; + windows += 1; + } + } + + const weekly = asRecord(limits.weekly); + if (weekly) { + const percent = parseOllamaPercent(weekly.usage); + if (percent !== undefined) { + quota.weeklyPercent = percent; + windows += 1; + } + } + + const monthly = asRecord(limits.monthly); + if (monthly) { + const percent = parseOllamaPercent(monthly.usage); + if (percent !== undefined) { + quota.monthlyPercent = percent; + windows += 1; + } + } + + return windows > 0 ? quota : null; +} + +async function fetchOllamaCloudQuota(provider: string, config: OcxProviderConfig): Promise { + const effectiveBaseUrl = config.baseUrl ?? getProviderRegistryEntry(provider)?.baseUrl ?? ""; + if (!isCanonicalOllamaCloudBaseUrl(effectiveBaseUrl)) return null; + const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); + if (!apiKey) return null; + const response = await fetch(OLLAMA_CLOUD_USAGE_URL, { + headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + if (response.status === 404) return null; + return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429 + ? TERMINAL_QUOTA_FAILURE + : null; + } + const body = asRecord(await readQuotaJson(response)); + const quota = parseOllamaCloudQuota(body); + return quota ? report(provider, "ollama-cloud:usage", quota) : null; +} + /** * Z.AI GLM Coding Plan `GET /api/monitor/usage/quota/limit` — the coding-plan * limits arrive as a `limits` array of `TOKENS_LIMIT` (newer plans call the @@ -2287,10 +2371,11 @@ function classifyAntigravityFamily(modelId: string, modelInfo: Record): number | undefined { - const remaining = normalizePercent(toFiniteNumber(quotaInfo.remainingFraction) !== undefined - ? toFiniteNumber(quotaInfo.remainingFraction)! * 100 - : toFiniteNumber(quotaInfo.remainingPercentage) !== undefined - ? toFiniteNumber(quotaInfo.remainingPercentage)! * 100 + const target = asRecord(quotaInfo.remaining) ?? quotaInfo; + const remaining = normalizePercent(toFiniteNumber(target.remainingFraction) !== undefined + ? toFiniteNumber(target.remainingFraction)! * 100 + : toFiniteNumber(target.remainingPercentage) !== undefined + ? toFiniteNumber(target.remainingPercentage)! * 100 : undefined); if (remaining === undefined) return undefined; return normalizePercent(100 - remaining); @@ -2325,6 +2410,75 @@ function antigravityWindowsFromModels(body: Record | null): Pro return customWindows; } +/** + * Parse Google Antigravity quota from `v1internal:retrieveUserQuotaSummary`. + * Groups contain Gemini models and Claude/3P models, each with 5h and weekly limit buckets. + */ +function parseAntigravityQuotaSummary(body: Record | null): ProviderQuota | null { + const groups = Array.isArray(body?.groups) ? (body.groups as unknown[]) : []; + if (groups.length === 0) return null; + + const customWindowsMap = new Map(); + + for (const rawGroup of groups) { + const group = asRecord(rawGroup); + if (!group) continue; + const groupName = `${typeof group.displayName === "string" ? group.displayName : ""} ${typeof group.description === "string" ? group.description : ""}`.toLowerCase(); + const isGemini = groupName.includes("gemini"); + const isClaude = groupName.includes("claude") || groupName.includes("3p") || groupName.includes("gpt"); + + const buckets = Array.isArray(group.buckets) ? (group.buckets as unknown[]) : []; + for (const rawBucket of buckets) { + const bucket = asRecord(rawBucket); + if (!bucket) continue; + const windowStr = `${typeof bucket.window === "string" ? bucket.window : ""} ${typeof bucket.bucketId === "string" ? bucket.bucketId : ""} ${typeof bucket.displayName === "string" ? bucket.displayName : ""}`.toLowerCase(); + const percent = antigravityUsedPercent(bucket); + if (percent === undefined) continue; + const resetAt = normalizeResetAt(bucket.resetTime); + + const isWeekly = windowStr.includes("week"); + const is5h = windowStr.includes("5h") || windowStr.includes("five"); + + if (isGemini) { + const label = is5h ? "Gem" : isWeekly ? "Gem (Weekly)" : ""; + if (label && !customWindowsMap.has(label)) { + customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }); + } + } else if (isClaude) { + const label = is5h ? "Cla" : isWeekly ? "Cla (Weekly)" : ""; + if (label && !customWindowsMap.has(label)) { + customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }); + } + } else { + const baseLabel = typeof group.displayName === "string" ? group.displayName : "Other"; + const label = isWeekly ? `${baseLabel} (Weekly)` : baseLabel; + if (!customWindowsMap.has(label)) { + customWindowsMap.set(label, { label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }); + } + } + } + } + + const PREFERRED_ORDER = ["Gem", "Gem (Weekly)", "Cla", "Cla (Weekly)"]; + const customWindows = Array.from(customWindowsMap.values()).sort((a, b) => { + const ia = PREFERRED_ORDER.indexOf(a.label); + const ib = PREFERRED_ORDER.indexOf(b.label); + if (ia !== -1 && ib !== -1) return ia - ib; + if (ia !== -1) return -1; + if (ib !== -1) return 1; + return a.label.localeCompare(b.label); + }); + + if (customWindows.length === 0) { + return null; + } + + return { + customWindows, + updatedAt: Date.now(), + }; +} + const ANTIGRAVITY_ACCOUNT_QUOTA_BASE = "https://daily-cloudcode-pa.googleapis.com"; let antigravityOutboundDependencies: ProviderOutboundDependencies = {}; @@ -2341,6 +2495,28 @@ export function setAntigravityAccountQuotaTransportForTests(dependencies: Provid * A redirect or non-2xx yields null (unavailable), never a partial row. */ export async function fetchAntigravityUsageQuota(accessToken: string, projectId: string): Promise { + const summaryUrl = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:retrieveUserQuotaSummary`; + try { + const summaryResponse = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, summaryUrl, { + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": antigravityUserAgent(), + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ project: projectId }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, antigravityOutboundDependencies); + if (await providerRedirectError(summaryResponse, summaryUrl)) return null; + if (summaryResponse.status === 401 || summaryResponse.status === 403) return null; + if (summaryResponse.ok) { + const quota = parseAntigravityQuotaSummary(asRecord(await readQuotaJson(summaryResponse))); + if (quota) return quota; + } + } catch { + // Fallback to fetchAvailableModels on error + } + const url = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:fetchAvailableModels`; const response = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, { headers: { @@ -2369,6 +2545,30 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig return null; } const baseUrl = (config.baseUrl || ANTIGRAVITY_ACCOUNT_QUOTA_BASE).replace(/\/+$/, ""); + + try { + const summaryResponse = await fetch(`${baseUrl}/v1internal:retrieveUserQuotaSummary`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": antigravityUserAgent(), + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ project: credential.projectId }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (summaryResponse.status === 401 || summaryResponse.status === 403) return null; + if (summaryResponse.ok) { + const quota = parseAntigravityQuotaSummary(asRecord(await readQuotaJson(summaryResponse))); + if (quota) { + return report(provider, "google-antigravity:retrieveUserQuotaSummary", quota); + } + } + } catch { + // Fallback on network/fetch error + } + const response = await fetch(`${baseUrl}/v1internal:fetchAvailableModels`, { method: "POST", headers: { @@ -2446,6 +2646,10 @@ async function maybeFetchProviderQuota( if ((provider.authMode ?? "key") === "key" && name === "cline-pass") { return fetchClineQuota(name, provider); } + if ((provider.authMode ?? "key") === "key" + && (name === "ollama-cloud" || isCanonicalOllamaCloudBaseUrl(provider.baseUrl))) { + return fetchOllamaCloudQuota(name, provider); + } if ((provider.authMode ?? "key") === "key" && (name === "zai" || name === "glm" || name === "glm-cn" || name === "zhipu-bigmodel-coding")) { return fetchZaiQuota(name, provider); diff --git a/tests/providers/provider-account-quota.test.ts b/tests/providers/provider-account-quota.test.ts index ccebd5c032..0f0b242df6 100644 --- a/tests/providers/provider-account-quota.test.ts +++ b/tests/providers/provider-account-quota.test.ts @@ -438,9 +438,30 @@ describe("google-antigravity per-account quota (#1082)", () => { }); } + function antigravitySummaryBody(gemRemaining: number, claRemaining: number): string { + return JSON.stringify({ + groups: [ + { + displayName: "Gemini Models", + buckets: [ + { bucketId: "gemini-weekly", window: "weekly", remainingFraction: gemRemaining, resetTime: "2026-09-09T12:00:00Z" }, + { bucketId: "gemini-5h", window: "5h", remainingFraction: gemRemaining, resetTime: "2026-09-02T12:00:00Z" }, + ], + }, + { + displayName: "Claude and GPT models", + buckets: [ + { bucketId: "3p-weekly", window: "weekly", remainingFraction: claRemaining, resetTime: "2026-09-09T18:00:00Z" }, + { bucketId: "3p-5h", window: "5h", remainingFraction: claRemaining, resetTime: "2026-09-02T18:00:00Z" }, + ], + }, + ], + }); + } + afterEach(() => setAntigravityAccountQuotaTransportForTests(null)); - test("probes each account with its own bearer and project id on the fixed Google host over the pinned transport", async () => { + test("probes each account with its own bearer and project id on the fixed Google host using retrieveUserQuotaSummary", async () => { const expires = Date.now() + 60 * 60_000; await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }); await saveCredential("google-antigravity", { access: "agy-second", refresh: "r2", expires, projectId: "proj-second", accountId: "agy-b", email: "b@example.com" }); @@ -453,6 +474,9 @@ describe("google-antigravity per-account quota (#1082)", () => { const auth = new Headers(requestOptions?.headers).get("authorization") ?? ""; const project = String(JSON.parse(String(body)).project); seen.push({ url, auth, project, address: pinned.address }); + if (url.endsWith("retrieveUserQuotaSummary")) { + return new Response(auth.endsWith("agy-first") ? antigravitySummaryBody(0.86, 0.38) : antigravitySummaryBody(0.97, 0.91), { status: 200, headers: { "content-type": "application/json" } }); + } return new Response(auth.endsWith("agy-first") ? antigravityBody(0.86, 0.38) : antigravityBody(0.97, 0.91), { status: 200, headers: { "content-type": "application/json" } }); }, }); @@ -463,16 +487,45 @@ describe("google-antigravity per-account quota (#1082)", () => { const [idA, idB] = [idFor("a@example.com"), idFor("b@example.com")]; expect(Object.keys(byId).sort()).toEqual([idA, idB].sort()); const windows = (id: string) => byId[id]!.quota!.customWindows!.map(w => `${w.label}=${w.percent}`); - expect(windows(idA)).toEqual(["Gem=14", "Cla=62"]); - expect(windows(idB)).toEqual(["Gem=3", "Cla=9"]); + expect(windows(idA)).toEqual(["Gem=14", "Gem (Weekly)=14", "Cla=62", "Cla (Weekly)=62"]); + expect(windows(idB)).toEqual(["Gem=3", "Gem (Weekly)=3", "Cla=9", "Cla (Weekly)=9"]); expect(byId[idA]!.quota!.customWindows![0]!.resetAt).toBeDefined(); expect(seen.map(s => `${s.auth}|${s.project}`).sort()).toEqual(["Bearer agy-first|proj-first", "Bearer agy-second|proj-second"]); for (const s of seen) { - expect(s.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"); + expect(s.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"); expect(s.address).toBe("142.250.0.1"); } }); + test("falls back to fetchAvailableModels when retrieveUserQuotaSummary returns 404", async () => { + const expires = Date.now() + 60 * 60_000; + await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }); + await saveCredential("google-antigravity", { access: "agy-second", refresh: "r2", expires, projectId: "proj-second", accountId: "agy-b", email: "b@example.com" }); + globalThis.fetch = (async () => { throw new Error("plain fetch must not be used for account bearers"); }) as typeof fetch; + + const seen: Array<{ url: string; auth: string; project: string; address: string }> = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async (url, pinned, body, _signal, requestOptions) => { + const auth = new Headers(requestOptions?.headers).get("authorization") ?? ""; + const project = String(JSON.parse(String(body)).project); + seen.push({ url, auth, project, address: pinned.address }); + if (url.endsWith("retrieveUserQuotaSummary")) { + return new Response(null, { status: 404 }); + } + return new Response(auth.endsWith("agy-first") ? antigravityBody(0.86, 0.38) : antigravityBody(0.97, 0.91), { status: 200, headers: { "content-type": "application/json" } }); + }, + }); + + const rows = await fetchProviderAccountQuotas("google-antigravity"); + const byId = Object.fromEntries(rows.map(row => [row.accountId, row])); + const [idA, idB] = [idFor("a@example.com"), idFor("b@example.com")]; + const windows = (id: string) => byId[id]!.quota!.customWindows!.map(w => `${w.label}=${w.percent}`); + expect(windows(idA)).toEqual(["Gem=14", "Cla=62"]); + expect(windows(idB)).toEqual(["Gem=3", "Cla=9"]); + expect(byId[idA]!.quota!.customWindows![0]!.resetAt).toBeDefined(); + }); + test("a rejected destination never receives a bearer; the row is unavailable, not 0%", async () => { const expires = Date.now() + 60 * 60_000; await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }); @@ -506,4 +559,3 @@ describe("google-antigravity per-account quota (#1082)", () => { } }); }); - diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index 582b03b457..59eefeb265 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -13,6 +13,7 @@ import { saveCredential } from "../../src/oauth/store"; import { clearProviderQuotaCache, fetchProviderQuotaReports, + parseOllamaCloudQuota, parseXaiCreditsResponse, QUOTA_RESPONSE_MAX_BYTES, readProviderQuotaJsonForTests, @@ -2801,4 +2802,305 @@ describe("fetchProviderQuotaReports", () => { const pruned = await fetchProviderQuotaReports(disabledConfig, true); expect(pruned.reports).toEqual([]); }); + + test("Google Antigravity maps 5-hour and weekly quota from retrieveUserQuotaSummary", async () => { + await saveCredential("google-antigravity", { + access: "agy-summary-access", + refresh: "agy-summary-refresh", + expires: Date.now() + 3600_000, + projectId: "agy-summary-project", + }); + + const seen: Array<{ url: string; auth?: string; body?: string }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const headers = init?.headers as Record | undefined; + seen.push({ url, auth: headers?.Authorization, body: typeof init?.body === "string" ? init.body : undefined }); + + if (url.endsWith("retrieveUserQuotaSummary")) { + return new Response(JSON.stringify({ + groups: [ + { + displayName: "Gemini Models", + description: "Models within this group: Gemini Flash, Gemini Pro", + buckets: [ + { + bucketId: "gemini-weekly", + displayName: "Weekly Limit Remaining", + window: "weekly", + resetTime: "2026-09-11T02:25:41Z", + remainingFraction: 0.88, + }, + { + bucketId: "gemini-5h", + displayName: "Five Hour Limit Remaining", + window: "5h", + resetTime: "2026-09-04T12:25:41Z", + remainingFraction: 0.75, + }, + ], + }, + { + displayName: "Claude and GPT models", + description: "Models within this group: Claude Opus, Claude Sonnet, GPT-OSS", + buckets: [ + { + bucketId: "3p-weekly", + displayName: "Weekly Limit Remaining", + window: "weekly", + resetTime: "2026-09-04T10:57:22Z", + remainingFraction: 0.95, + }, + { + bucketId: "3p-5h", + displayName: "Five Hour Limit Remaining", + window: "5h", + resetTime: "2026-09-04T12:54:45Z", + remainingFraction: 1.0, + }, + ], + }, + ], + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + const config = { + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + adapter: "google", + authMode: "oauth", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + }, + }, + } as unknown as OcxConfig; + + const result = await fetchProviderQuotaReports(config, true); + expect(result.reports).toHaveLength(1); + const report = result.reports[0]!; + expect(report.provider).toBe("google-antigravity"); + expect(report.source).toBe("google-antigravity:retrieveUserQuotaSummary"); + expect(report.quota.customWindows).toEqual([ + { label: "Gem", percent: 25, resetAt: Date.parse("2026-09-04T12:25:41Z") }, + { label: "Gem (Weekly)", percent: 12, resetAt: Date.parse("2026-09-11T02:25:41Z") }, + { label: "Cla", percent: 0, resetAt: Date.parse("2026-09-04T12:54:45Z") }, + { label: "Cla (Weekly)", percent: 5, resetAt: Date.parse("2026-09-04T10:57:22Z") }, + ]); + expect(seen[0]?.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"); + expect(seen[0]?.auth).toBe("Bearer agy-summary-access"); + expect(seen[0]?.body).toBe(JSON.stringify({ project: "agy-summary-project" })); + }); + + test("Google Antigravity supports nested remaining object and deduplicates custom windows", async () => { + await saveCredential("google-antigravity", { + access: "agy-nested-access", + refresh: "agy-nested-refresh", + expires: Date.now() + 3600_000, + projectId: "agy-nested-project", + }); + + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("retrieveUserQuotaSummary")) { + return new Response(JSON.stringify({ + groups: [ + { + displayName: "Gemini Models", + buckets: [ + { + bucketId: "gemini-weekly", + window: "weekly", + resetTime: "2026-09-11T00:00:00Z", + remaining: { remainingFraction: 0.70 }, + }, + { + bucketId: "gemini-5h", + window: "5h", + resetTime: "2026-09-04T08:00:00Z", + remaining: { remainingFraction: 0.60 }, + }, + ], + }, + { + displayName: "Claude and GPT models", + buckets: [ + { + bucketId: "claude-3p-5h-1", + displayName: "Claude 5 Hour Limit", + window: "5h", + resetTime: "2026-09-04T09:00:00Z", + remaining: { remainingFraction: 0.90 }, + }, + { + bucketId: "claude-3p-5h-duplicate", + displayName: "GPT 5 Hour Limit", + window: "5h", + resetTime: "2026-09-04T09:30:00Z", + remaining: { remainingFraction: 0.50 }, + }, + { + bucketId: "claude-3p-weekly", + displayName: "Claude Weekly Limit", + window: "weekly", + resetTime: "2026-09-11T09:00:00Z", + remaining: { remainingFraction: 0.85 }, + }, + ], + }, + ], + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + const config = { + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + adapter: "google", + authMode: "oauth", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + }, + }, + } as unknown as OcxConfig; + + const result = await fetchProviderQuotaReports(config, true); + expect(result.reports).toHaveLength(1); + const report = result.reports[0]!; + expect(report.quota.customWindows).toEqual([ + { label: "Gem", percent: 40, resetAt: Date.parse("2026-09-04T08:00:00Z") }, + { label: "Gem (Weekly)", percent: 30, resetAt: Date.parse("2026-09-11T00:00:00Z") }, + { label: "Cla", percent: 10, resetAt: Date.parse("2026-09-04T09:00:00Z") }, + { label: "Cla (Weekly)", percent: 15, resetAt: Date.parse("2026-09-11T09:00:00Z") }, + ]); + }); + + test("Ollama Cloud maps 5-hour session and weekly windows from /api/usage (legacy plan)", async () => { + const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const headers = init?.headers as Record | undefined; + seen.push({ url, authorization: headers?.Authorization, redirect: init?.redirect }); + return new Response(JSON.stringify({ + activity: { cost: "0.00000", period: { type: "last_4_weeks" }, models: [] }, + limits: { + session: { usage: 0.091, models: [{ name: "glm-5.3-flash", request_count: 228 }] }, + weekly: { usage: 0.592, models: [{ name: "glm-5.3", request_count: 2572 }] }, + }, + }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports( + keyQuotaConfig("ollama-cloud", "https://ollama.com/v1"), + true, + ); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("ollama-cloud:usage"); + expect(result.reports[0]?.quota).toMatchObject({ + fiveHourPercent: 9.1, + weeklyPercent: 59.2, + }); + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe("https://ollama.com/api/usage"); + expect(seen[0]?.authorization).toBe("Bearer ollama-cloud-secret"); + expect(seen[0]?.redirect).toBe("error"); + }); + + test("Ollama Cloud maps monthly window from /api/usage (migrated plan)", async () => { + globalThis.fetch = (async () => { + return new Response(JSON.stringify({ + activity: { cost: "1.68054", period: { type: "last_4_weeks" } }, + limits: { + monthly: { usage: 0.004, models: [{ name: "glm-5.3-flash", request_count: 147 }] }, + }, + }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports( + keyQuotaConfig("ollama-cloud", "https://ollama.com/v1"), + true, + ); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("ollama-cloud:usage"); + expect(result.reports[0]?.quota).toMatchObject({ + monthlyPercent: 0.4, + }); + expect(result.reports[0]?.quota.fiveHourPercent).toBeUndefined(); + expect(result.reports[0]?.quota.weeklyPercent).toBeUndefined(); + }); + + test("Ollama Cloud maps combined windows when both legacy and migrated limits exist", async () => { + globalThis.fetch = (async () => { + return new Response(JSON.stringify({ + limits: { + session: { usage: 0.1 }, + weekly: { usage: 0.2 }, + monthly: { usage: 0.3 }, + }, + }), { status: 200 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports( + keyQuotaConfig("ollama-cloud", "https://ollama.com/v1"), + true, + ); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.quota).toMatchObject({ + fiveHourPercent: 10, + weeklyPercent: 20, + monthlyPercent: 30, + }); + }); + + test("Ollama Cloud treats 404 as a no-report, not terminal", async () => { + globalThis.fetch = (async () => new Response("not found", { status: 404 })) as typeof fetch; + const config = keyQuotaConfig("ollama-cloud", "https://ollama.com/v1"); + + const result = await fetchProviderQuotaReports(config, true); + expect(result.reports).toEqual([]); + }); + + test("Ollama Cloud treats 401 as terminal failure (invalid key)", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ error: "invalid credentials" }), { status: 401 })) as typeof fetch; + const config = keyQuotaConfig("ollama-cloud", "https://ollama.com/v1"); + + const result = await fetchProviderQuotaReports(config, true); + expect(result.reports).toEqual([]); + }); + + test("Ollama Cloud never sends the key to a non-canonical base URL", async () => { + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + seen.push(String(input)); + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports( + keyQuotaConfig("ollama-cloud", "https://attacker.example/v1"), + true, + ); + + expect(result.reports).toEqual([]); + expect(seen).toEqual([]); + }); + + test("parseOllamaCloudQuota handles edge cases", () => { + expect(parseOllamaCloudQuota(null)).toBeNull(); + expect(parseOllamaCloudQuota({})).toBeNull(); + expect(parseOllamaCloudQuota({ limits: {} })).toBeNull(); + expect(parseOllamaCloudQuota({ limits: { session: { usage: 0 } } })).toMatchObject({ + fiveHourPercent: 0, + }); + expect(parseOllamaCloudQuota({ limits: { session: { usage: 1 } } })).toMatchObject({ + fiveHourPercent: 100, + }); + expect(parseOllamaCloudQuota({ limits: { session: { usage: 1.25 } } })).toMatchObject({ + fiveHourPercent: 100, + }); + }); }); From ac922e01f5bf0495f8a62a5ce6592be5f51d6fa2 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 11:43:14 +0900 Subject: [PATCH 2/5] fix(quota): pin the Antigravity summary probe to Google's own host Carries #3447 by @hualiny (Antigravity weekly windows via retrieveUserQuotaSummary, plus Ollama Cloud quota from /api/usage), rebased rename-aware onto tests/providers/. The provider-level probe added there sent the stored account bearer to an operator-configured baseUrl with default redirect following, while fetchAntigravityUsageQuota in the same file already pins the identical request. Route it through providerOutboundPost against ANTIGRAVITY_ACCOUNT_QUOTA_BASE with the providerRedirectError check so a configured baseUrl stays a routing choice for model requests, not a second destination for Google's accounting. The fetchAvailableModels fallback is unchanged from dev. Co-authored-by: hualiny <82697947+hualiny@users.noreply.github.com> --- src/providers/quota.ts | 11 ++- tests/providers/provider-quota.test.ts | 123 ++++++++++++++++++++++--- 2 files changed, 116 insertions(+), 18 deletions(-) diff --git a/src/providers/quota.ts b/src/providers/quota.ts index c2911a2129..d2931c59fd 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -2546,9 +2546,13 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig } const baseUrl = (config.baseUrl || ANTIGRAVITY_ACCOUNT_QUOTA_BASE).replace(/\/+$/, ""); + // The summary probe is pinned to Google's own host through the provider-outbound + // transport, mirroring `fetchAntigravityUsageQuota` above: a configured `baseUrl` is a + // routing choice for requests, not a second source of Google's accounting, and this + // request carries the account bearer. + const summaryUrl = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:retrieveUserQuotaSummary`; try { - const summaryResponse = await fetch(`${baseUrl}/v1internal:retrieveUserQuotaSummary`, { - method: "POST", + const summaryResponse = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, summaryUrl, { headers: { Accept: "application/json", "Content-Type": "application/json", @@ -2557,7 +2561,8 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig }, body: JSON.stringify({ project: credential.projectId }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); + }, antigravityOutboundDependencies); + if (await providerRedirectError(summaryResponse, summaryUrl)) return null; if (summaryResponse.status === 401 || summaryResponse.status === 403) return null; if (summaryResponse.ok) { const quota = parseAntigravityQuotaSummary(asRecord(await readQuotaJson(summaryResponse))); diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index 59eefeb265..051d22313f 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -17,6 +17,7 @@ import { parseXaiCreditsResponse, QUOTA_RESPONSE_MAX_BYTES, readProviderQuotaJsonForTests, + setAntigravityAccountQuotaTransportForTests, setProviderQuotaBeforePublishForTests, } from "../../src/providers/quota"; import type { OcxConfig } from "../../src/types"; @@ -93,6 +94,7 @@ afterEach(() => { clearAccountQuota(); clearProviderQuotaCache(); setProviderQuotaBeforePublishForTests(null); + setAntigravityAccountQuotaTransportForTests(null); if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousOpencodexHome; if (previousCodexHome === undefined) delete process.env.CODEX_HOME; @@ -2812,12 +2814,13 @@ describe("fetchProviderQuotaReports", () => { }); const seen: Array<{ url: string; auth?: string; body?: string }> = []; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - const headers = init?.headers as Record | undefined; - seen.push({ url, auth: headers?.Authorization, body: typeof init?.body === "string" ? init.body : undefined }); - - if (url.endsWith("retrieveUserQuotaSummary")) { + globalThis.fetch = (async () => { throw new Error("plain fetch must not be used for account bearers"); }) as typeof fetch; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async (url, _pinned, body, _signal, requestOptions) => { + const auth = new Headers(requestOptions?.headers).get("authorization") ?? undefined; + seen.push({ url, auth, body: typeof body === "string" ? body : undefined }); + if (!url.endsWith("retrieveUserQuotaSummary")) return new Response("not found", { status: 404 }); return new Response(JSON.stringify({ groups: [ { @@ -2862,9 +2865,8 @@ describe("fetchProviderQuotaReports", () => { }, ], }), { status: 200, headers: { "content-type": "application/json" } }); - } - return new Response("not found", { status: 404 }); - }) as typeof fetch; + }, + }); const config = { defaultProvider: "google-antigravity", @@ -2901,9 +2903,11 @@ describe("fetchProviderQuotaReports", () => { projectId: "agy-nested-project", }); - globalThis.fetch = (async (input: RequestInfo | URL) => { - const url = String(input); - if (url.endsWith("retrieveUserQuotaSummary")) { + globalThis.fetch = (async () => { throw new Error("plain fetch must not be used for account bearers"); }) as typeof fetch; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async (url) => { + if (!url.endsWith("retrieveUserQuotaSummary")) return new Response("not found", { status: 404 }); return new Response(JSON.stringify({ groups: [ { @@ -2951,9 +2955,8 @@ describe("fetchProviderQuotaReports", () => { }, ], }), { status: 200, headers: { "content-type": "application/json" } }); - } - return new Response("not found", { status: 404 }); - }) as typeof fetch; + }, + }); const config = { defaultProvider: "google-antigravity", @@ -2977,6 +2980,96 @@ describe("fetchProviderQuotaReports", () => { ]); }); + // The provider-level Antigravity probe carries the stored account bearer. A configured + // `baseUrl` is a routing choice for model requests, not a second source of Google's + // accounting, so the summary probe stays pinned to Google's own host — the same guarantee + // `fetchAntigravityUsageQuota` already makes for the per-account path. + test("Google Antigravity does not send the account bearer to a configured baseUrl", async () => { + await saveCredential("google-antigravity", { + access: "agy-pinned-access", + refresh: "agy-pinned-refresh", + expires: Date.now() + 3600_000, + projectId: "agy-pinned-project", + }); + + const plainFetchUrls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + plainFetchUrls.push(String(input)); + throw new Error("plain fetch must not be used for account bearers"); + }) as typeof fetch; + + const seen: Array<{ url: string; auth?: string; hostname: string }> = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async (url: string) => { + seen.push({ url, hostname: new URL(url).hostname }); + return { hostname: new URL(url).hostname, addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }; + }, + pinnedPost: async (url, _pinned, _body, _signal, requestOptions) => { + seen.push({ url, auth: new Headers(requestOptions?.headers).get("authorization") ?? undefined, hostname: new URL(url).hostname }); + if (!url.endsWith("retrieveUserQuotaSummary")) return new Response("not found", { status: 404 }); + return new Response(JSON.stringify({ + groups: [{ + displayName: "Gemini Models", + buckets: [{ bucketId: "gemini-5h", window: "5h", resetTime: "2026-09-04T08:00:00Z", remainingFraction: 0.6 }], + }], + }), { status: 200, headers: { "content-type": "application/json" } }); + }, + }); + + const result = await fetchProviderQuotaReports({ + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + adapter: "google", + authMode: "oauth", + baseUrl: "http://127.0.0.1:1/", + }, + }, + } as unknown as OcxConfig, true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]!.source).toBe("google-antigravity:retrieveUserQuotaSummary"); + // Every bearer-carrying request went to Google's own host, not the configured loopback base. + expect(seen.map(entry => entry.hostname)).toEqual(["daily-cloudcode-pa.googleapis.com", "daily-cloudcode-pa.googleapis.com"]); + expect(seen.find(entry => entry.auth)?.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"); + expect(seen.find(entry => entry.auth)?.auth).toBe("Bearer agy-pinned-access"); + expect(plainFetchUrls).toEqual([]); + }); + + test("Google Antigravity refuses a redirected summary response", async () => { + await saveCredential("google-antigravity", { + access: "agy-redirect-access", + refresh: "agy-redirect-refresh", + expires: Date.now() + 3600_000, + projectId: "agy-redirect-project", + }); + globalThis.fetch = (async () => { throw new Error("plain fetch must not be used for account bearers"); }) as typeof fetch; + + const posted: string[] = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async (url) => { + posted.push(url); + return new Response(null, { status: 302, headers: { location: "http://127.0.0.1/" } }); + }, + }); + + const result = await fetchProviderQuotaReports({ + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + adapter: "google", + authMode: "oauth", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + }, + }, + } as unknown as OcxConfig, true); + + expect(result.reports).toEqual([]); + // The redirect short-circuits the summary probe; the redirect target is never fetched. + expect(posted).toEqual(["https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"]); + }); + test("Ollama Cloud maps 5-hour session and weekly windows from /api/usage (legacy plan)", async () => { const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { From 464bb27b6cb321c4aa0a266f349178f60f4815fe Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 11:43:37 +0900 Subject: [PATCH 3/5] docs(providers): state that Antigravity quota probes are pinned to Google's host Co-authored-by: hualiny <82697947+hualiny@users.noreply.github.com> --- docs-site/src/content/docs/reference/configuration/providers.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index baaf32a8e5..d7b7172dc8 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -177,6 +177,7 @@ predictions. Explicit provider/model price overrides still take precedence. | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google transport/auth mode. Default `ai-studio`. | | `directGeminiWireRenames?` | `boolean` | Google only. Applies only to direct AI Studio requests. Omitted or `true` keeps the `-tiered` wire rename for Gemini Flash ids (`gemini-3.7-flash` -> `gemini-3.7-flash-tiered`); `false` sends the requested bare ids to the wire unchanged. Vertex preserves the requested model ID, and Cloud Code Assist routing is unchanged. Set `false` when the configured upstream still serves the bare ids. | | `project?` | `string` | Vertex or Antigravity Cloud Code Assist project id. | +| — | — | Antigravity account quota probes (`retrieveUserQuota` and `retrieveUserQuotaSummary`) always go to Google's own Cloud Code host through the pinned outbound transport, regardless of a configured `baseUrl`; the account bearer is never sent to an operator-configured endpoint and a redirect aborts the probe. Only the model-list fallback still honors `baseUrl`. | | `location?` | `string` | Vertex location; environment fallback is `GOOGLE_CLOUD_LOCATION`. | | `mcpServers?` | `Record` | Cursor only: stdio or Streamable HTTP MCP servers. | | `desktopExecutor?` | `DesktopExecutorConfig` | Cursor only: external computer-use and record-screen commands. | From 4a721e459dc733a83fa1e513b44de7e383e6caf1 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 11:47:28 +0900 Subject: [PATCH 4/5] test(quota): inject the pinned Antigravity transport in the multi-provider quota test After the summary probe moved off globalThis.fetch, this test made a real request to Google (sandbox DNS failure masked it as a fallthrough). Inject the seam with a 404 so the fetchAvailableModels fallback is what the test exercises, as before. Co-authored-by: hualiny <82697947+hualiny@users.noreply.github.com> --- tests/providers/provider-quota.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index 051d22313f..5a02e53f28 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -203,6 +203,14 @@ describe("fetchProviderQuotaReports", () => { await saveCredential("google-antigravity", { access: "agy-access-secret", refresh: "agy-refresh-secret", expires: Date.now() + 3600_000, projectId: "agy-project-secret" }); await saveCredential("kimi", { access: "kimi-access-secret", refresh: "kimi-refresh-secret", expires: Date.now() + 3600_000 }); + // The Antigravity summary probe is pinned to Google's host through the provider-outbound + // transport and never touches globalThis.fetch; without this seam the test would make a + // real network request. A 404 here exercises the fetchAvailableModels fallback below. + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async () => new Response("not found", { status: 404 }), + }); + const seen: { url: string; authorization?: string; body?: string }[] = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); From 80873166e2073c0c17a71eaac76e6ecb8881d5ce Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 12:15:39 +0900 Subject: [PATCH 5/5] feat(quota): detect usage-window resets and notify on them Rebase of the maintainer's #2783 onto the Antigravity/Ollama quota layer, with the three review blockers closed as six bounded fixes: https-only webhook URLs (B1), manual redirect handling on webhook sends (B2), MIN_INTERVAL_MS and MIN_POLL_SECONDS raised together to 600s (B3), configured cadence reaching the poller through a dynamic import that keeps the core-boundary guard green (B4), an in-flight fence on poll ticks (B5), and durable seen-claims (B6). Seven new test basenames registered in the layout map and fixture under tests/usage/. Docs: server.md floor, https requirement, redirect refusal. --- .../260828_quota_reset_detection/000_plan.md | 160 +++++ .../001_audit_response.md | 93 +++ .../002_wp2_audit_response.md | 91 +++ .../003_wp3_audit_response.md | 88 +++ .../004_wp3_review_response.md | 144 +++++ .../010_phase2_detection_core.md | 155 +++++ .../020_phase3_observation_wiring.md | 219 +++++++ .../030_phase4_sinks_and_surface.md | 204 +++++++ .../040_phase5_hardening_delivery.md | 105 ++++ .../050_activation_evidence.md | 64 ++ .../060_closeout.md | 90 +++ .../docs/reference/cli/providers-accounts.md | 1 + .../docs/reference/configuration/server.md | 88 +++ .../content/docs/reference/management-api.md | 1 + scripts/test-layout/layout.json | 7 + src/cli/config-command.ts | 10 +- src/cli/provider-runtime.ts | 59 ++ src/cli/provider.ts | 1 + src/codex/quota.ts | 92 +++ src/config.ts | 65 +++ src/providers/quota.ts | 90 +++ src/quota/reset-activation.ts | 81 +++ src/quota/reset-detector.ts | 305 ++++++++++ src/quota/reset-notify-config.ts | 162 +++++ src/quota/reset-observer.ts | 125 ++++ src/quota/reset-poller.ts | 160 +++++ src/quota/reset-seen-store.ts | 385 ++++++++++++ src/quota/reset-sinks.ts | 199 +++++++ src/quota/window-mapping.ts | 106 ++++ src/server/background-lifecycle.ts | 24 +- src/server/management-api.ts | 12 + src/server/management/quota-reset-routes.ts | 57 ++ src/types/config.ts | 54 +- tests/fixtures/test-layout-expected.json | 7 + tests/helpers/import-graph.ts | 152 +++++ tests/helpers/quota-reset-burst-child.ts | 35 ++ tests/usage/quota-reset-account-key.test.ts | 51 ++ tests/usage/quota-reset-core-boundary.test.ts | 173 ++++++ tests/usage/quota-reset-detector.test.ts | 326 +++++++++++ tests/usage/quota-reset-notify-config.test.ts | 92 +++ tests/usage/quota-reset-notify.test.ts | 551 ++++++++++++++++++ tests/usage/quota-reset-observation.test.ts | 309 ++++++++++ tests/usage/quota-reset-seen-store.test.ts | 250 ++++++++ 43 files changed, 5440 insertions(+), 3 deletions(-) create mode 100644 devlog/_plan/260828_quota_reset_detection/000_plan.md create mode 100644 devlog/_plan/260828_quota_reset_detection/001_audit_response.md create mode 100644 devlog/_plan/260828_quota_reset_detection/002_wp2_audit_response.md create mode 100644 devlog/_plan/260828_quota_reset_detection/003_wp3_audit_response.md create mode 100644 devlog/_plan/260828_quota_reset_detection/004_wp3_review_response.md create mode 100644 devlog/_plan/260828_quota_reset_detection/010_phase2_detection_core.md create mode 100644 devlog/_plan/260828_quota_reset_detection/020_phase3_observation_wiring.md create mode 100644 devlog/_plan/260828_quota_reset_detection/030_phase4_sinks_and_surface.md create mode 100644 devlog/_plan/260828_quota_reset_detection/040_phase5_hardening_delivery.md create mode 100644 devlog/_plan/260828_quota_reset_detection/050_activation_evidence.md create mode 100644 devlog/_plan/260828_quota_reset_detection/060_closeout.md create mode 100644 src/quota/reset-activation.ts create mode 100644 src/quota/reset-detector.ts create mode 100644 src/quota/reset-notify-config.ts create mode 100644 src/quota/reset-observer.ts create mode 100644 src/quota/reset-poller.ts create mode 100644 src/quota/reset-seen-store.ts create mode 100644 src/quota/reset-sinks.ts create mode 100644 src/quota/window-mapping.ts create mode 100644 src/server/management/quota-reset-routes.ts create mode 100644 tests/helpers/import-graph.ts create mode 100644 tests/helpers/quota-reset-burst-child.ts create mode 100644 tests/usage/quota-reset-account-key.test.ts create mode 100644 tests/usage/quota-reset-core-boundary.test.ts create mode 100644 tests/usage/quota-reset-detector.test.ts create mode 100644 tests/usage/quota-reset-notify-config.test.ts create mode 100644 tests/usage/quota-reset-notify.test.ts create mode 100644 tests/usage/quota-reset-observation.test.ts create mode 100644 tests/usage/quota-reset-seen-store.test.ts diff --git a/devlog/_plan/260828_quota_reset_detection/000_plan.md b/devlog/_plan/260828_quota_reset_detection/000_plan.md new file mode 100644 index 0000000000..ce77bd0420 --- /dev/null +++ b/devlog/_plan/260828_quota_reset_detection/000_plan.md @@ -0,0 +1,160 @@ +# Quota reset detection and notification + +Unit: `260828_quota_reset_detection` +Branch: `codex/quota-reset-detection` (target `dev`) +Class: C4 (new subsystem, config surface, background timer, outbound network sink) + +## Objective + +When a usage window resets, opencodex should notice and say so exactly once. + +Two reset shapes matter and they are not the same event: + +- **scheduled** — the window's own clock ran out. The previous snapshot carried a + `resetAt` in the future, wall-clock passed it, and the next snapshot reports a + lower used-percent. This is the weekly/5-hour rollover an operator can already predict. +- **surprise** — used-percent drops while the previous `resetAt` is *still in the + future*, or `resetAt` jumps forward before its own deadline. Upstream moved the window + out of band. Nobody can predict this one, which is exactly why it needs a signal. + +The deliverable is detection plus a default-OFF notification sink, not a routing change. + +## Constraints + +- Bun-native TypeScript, strict `tsc`. No Node-only APIs. +- `src/router.ts`, `src/server/lifecycle.ts`, `src/server/responses/core.ts` must not + gain a transitive `src/lab/` import (`tests/core-lab-boundary.test.ts`). The new + subsystem is itself optional and must not become a second core-path passenger. +- Notification default OFF. A user with no reset config runs no timer and invokes no sink. +- Event payloads carry closed-union labels and numbers only. No account ids, no emails, + no tokens, no paths. `bun run privacy:scan` scans *repository files*, not runtime + output, so payload privacy is a design obligation the scanner cannot enforce. +- `src/codex/reset-credit-recovery.ts` owns credit *consumption* and stays untouched. + A deliberate credit redemption is not a surprise reset. + +## Current state (verified 260828) + +Detection is absent. `rg -ni 'resetdetect|quotareset|reset-event|resetEvent' src` returns +three hits, all inside `function quotaResetAt(...)` in `src/providers/quota.ts:1646` — a +DTO field reader. Notification is absent: `rg -n 'webhook' src scripts docs-site/src` +returns zero matches. + +What does exist, and what the design leans on: + +| Fact | Location | +|---|---| +| Codex per-account windows (`weeklyResetAt`, `shortResetAt`, `monthlyResetAt`, `resetCredits`) | `src/codex/quota.ts:7` | +| The one writer holding both prev and next in scope | `src/codex/quota.ts:274` (`const existing = accountQuota.get(accountId)`) | +| Commit points that snapshot becomes durable through | `src/codex/quota.ts:289`, `:336` | +| Disk snapshot, version 1, 6-hour read-side age limit | `src/codex/quota.ts:40`, `:41`, `:485` | +| Provider-side windows (`fiveHourResetAt` etc.) | `src/providers/quota.ts:93` | +| Provider-level snapshot commit — the ONLY place a newer report displaces an older one | `src/providers/quota.ts:2343`, with `previous` in scope at `:2290` | +| Provider per-account cache replacement sites | `src/providers/quota.ts:1585`, `:1592`, `:1603` | +| Provider quota has NO background refresh — one caller, request-driven | `src/server/management/provider-routes.ts:421` | +| Reset-sentinel normalization (`0`/negative are not clocks) | `src/providers/quota.ts:279` | +| Opt-in background job pattern (unref'd timer, gate in the callee) | `src/storage/policy-scheduler.ts:13`, `src/storage/policy-job.ts:445` | +| Bounded ring + snapshot accessor for a read route | `src/server/memory-watchdog.ts:48` | +| Optional-subsystem teardown registry | `src/lib/optional-shutdown-hooks.ts:32` | +| Strict optional config section template | `src/config.ts:843`, `:898`, `:2058`, `:2179` | +| SSRF policy for an operator-supplied URL | `src/lib/destination-policy.ts:377` | + +## Four traps the design has to survive + +These are the reasons a naive "percent went down, fire" detector is wrong here. + +1. **Credits-only writes rewrite `updatedAt` with byte-identical windows.** + `src/codex/quota.ts:276` (`creditsOnly`) copies every window field from `existing` + and changes only `resetCredits`. Keying on `updatedAt` fires on nothing. +2. **Writers never hydrate from disk.** `hydrateAccountQuotasFromDisk` is called by the + three readers only (`src/codex/quota.ts:511`, `:516`, `:542`). A cold-start write can + see `existing === undefined` while a valid snapshot sits on disk. Treating absent-prev + as a reset invents an event on every restart. +3. **Rows get deleted for reasons that are not resets.** Reauth clears the row on purpose + (`src/codex/auth-api.ts:2019`), reconciliation drops non-live accounts + (`src/codex/quota.ts:540`), and account purge clears it + (`src/codex/account-lifecycle.ts:39`). Delete-then-readd looks like 0% arriving fresh. +4. **Header writes are partial snapshots.** `src/server/responses/core.ts:3793` writes on + every pooled response and may omit the burst tuple entirely; the merge at + `src/codex/quota.ts:323` carries forward what the payload lacks. A detector must diff + the *committed* snapshot, not the incoming payload. + +5. **Provider reports are keyed by provider, not by account.** `clearProviderQuotaCache()` plus + an account switch makes the next `anthropic` report a *different account's* usage — lower + percent, different `resetAt`. That is an identity change, not a reset. Events must be keyed + by `(provider, account, window)`. +6. **Provider quota is never refreshed on its own.** `fetchProviderQuotaReports` has exactly + one caller — the `/api/provider-quotas` route. With no dashboard open and no CLI call, no + two consecutive snapshots exist, so a reset passes unobserved indefinitely. The opt-in + poller in wp3 is therefore load-bearing, not a nicety. +7. **Two `normalizeResetAt` implementations disagree.** `src/providers/quota.ts:279` treats + `<= 0` as a sentinel and scales seconds to ms; `src/codex/quota.ts:192` admits `0` and + does no scaling. The detector normalizes at its own boundary rather than trusting either. + +Consequence: absent-prev is never a reset, identity is `(scope, account, window)`, and window +values — not the write timestamp — decide whether anything happened. + +Observation cadence is also bounded by design: the provider cache TTL is 5 minutes +(`src/providers/quota.ts:37`) and the per-account TTL is 10 (`:1425`), so a reset instant can +only ever be bracketed between two observations, never timestamped exactly. Events carry +`detectedAt` and the observed `resetAt`, and never claim to know when the reset occurred. + +## Detection contract + +``` +observe(scope, windowLabel, prev, next, now) -> ResetEvent | null +``` + +`kind: "scheduled"` requires `prev.resetAt !== undefined && now >= prev.resetAt` and +a percent drop. `kind: "surprise"` requires a material percent drop (>= 5 points, so +rounding noise cannot trip it) while `prev.resetAt` is still ahead of `now`, or +`next.resetAt` advancing past `prev.resetAt` before that deadline. Every other +transition, including any missing `prev`, returns `null`. + +Idempotence key: `scope | windowLabel | resetAtBucket`. Persisted, because "exactly once" +has to hold across a restart, and the whole point of a surprise reset is that it happens +while nobody is watching. + +## Work-phase map (dependency-ordered) + +Locked at the close of the wp1 docs cycle. Files named here are the authoritative +deliverable list; a later cycle amends its own doc rather than reinterpreting this table. + +| Phase | Doc | Delivers | New files | Depends on | +|---|---|---|---|---| +| wp1 | `000`, `001`, `010`–`040` | roadmap, contract, 7 traps, audit response | 6 docs | — | +| wp2 amendment | `002_wp2_audit_response.md` | the 9-blocker A-gate response | 1 doc | wp1 | +| wp2 | `010_phase2_detection_core.md` | pure detector + durable claim store | `src/quota/reset-detector.ts`, `src/quota/reset-seen-store.ts`, 2 test files | wp1 | +| wp3 | `020_phase3_observation_wiring.md` | codex + provider seams, opt-in poller | `src/quota/reset-observer.ts`, `src/quota/reset-poller.ts`, 1 test file; edits `src/codex/quota.ts`, `src/providers/quota.ts`, `src/server/background-lifecycle.ts` | wp2 | +| wp4 | `030_phase4_sinks_and_surface.md` | config section, sinks, event ring, API + CLI | `src/quota/reset-notify-config.ts`, `src/quota/reset-sinks.ts`, `src/server/management/quota-reset-routes.ts`, 1 test file; edits `src/types/config.ts`, `src/config.ts` (schema, register, write-validate, warn ×3, `validFileConfigDiagnostics`), `src/cli/config-command.ts` (redact `webhookUrl`), `src/server/management-api.ts`, `src/cli/provider-runtime.ts`, `src/cli/registry.ts` | wp3 | +| wp5 | `040_phase5_hardening_delivery.md` | boundary guard, full gates, docs, evidence, PR | `tests/quota-reset-core-boundary.test.ts`, `050_activation_evidence.md`, `060_closeout.md`; edits 3 docs-site pages | wp4 | + +Ordering is structural: nothing can be wired before the contract exists, no sink can fire +before something detects, and delivery proves the whole chain. Each phase closes with +something independently verifiable. + +## Out of scope + +Routing/failover reaction to a reset; automatic credit consumption; GUI work beyond what +an operator needs to read the event log; any credential or OAuth change; `src/lab/`. + +## Verifiers (run, not assumed) + +| Command | Exit | Observes this change? | +|---|---|---| +| `bun x tsc --noEmit` | 0 on baseline 295860825 | Yes — `tsconfig.json` includes `src/**/*.ts` | +| `bun test tests/.test.ts` | 0 (8 pass on `codex-quota-parser-parity`) | Yes — names the new test file directly | +| `bun run test` | full suite | Yes | +| `bun run privacy:scan` | 0 | Repository text only — NOT runtime payloads | +| `bun test tests/core-lab-boundary.test.ts` | 0 | Yes — walks the runtime import graph | +| `bun test tests/quota-reset-core-boundary.test.ts` | added in wp5 | Yes — the existing Lab guard hardcodes `/src/lab/` (`tests/core-lab-boundary.test.ts:63`) and cannot see `src/quota/` | + +`bun install` was required first: a fresh worktree fails with +`Cannot find module 'zod/v4'` and every focused run reports a spurious single error. + +## Bypass ledger + +The default-OFF guarantee is enforced by a test (E7-class), not by anything unbypassable. +Executing surface: `bun run test`. Known bypass: a contributor who wires the sink into a +path the test does not observe. Residual risk: a future caller invoking the sink directly +rather than through the gate. Final enforcement layer: none — the boundary is the test plus +review. Wording is deliberately "early warning", not "enforcement". diff --git a/devlog/_plan/260828_quota_reset_detection/001_audit_response.md b/devlog/_plan/260828_quota_reset_detection/001_audit_response.md new file mode 100644 index 0000000000..d050774ddf --- /dev/null +++ b/devlog/_plan/260828_quota_reset_detection/001_audit_response.md @@ -0,0 +1,93 @@ +# A-phase audit response + +Two dispatched grok-4.6 auditors did not return: the first errored with +`Selected model is at capacity`, the second went silent through three bounded wait cycles +and was retired under DISPATCH-RETIRE-01. The audit below was performed directly against +the tree at `c752929d7`. Stating that plainly because a claimed-but-absent reviewer is the +one failure mode the A gate exists to catch. + +## Citation audit — PASS + +All 33 cited `path:line` claims were read back and match. Sample: +`src/codex/quota.ts:274` is `const existing = accountQuota.get(accountId);`; +`src/providers/quota.ts:2290` is the `previous` binding; `:2343` is the `cache = {...}` +commit; `src/config.ts:3161` is `SALVAGEABLE_CONFIG_SECTIONS`. + +## Verifier reality — PASS + +`bun install` then `bun x tsc --noEmit` exits 0 with no output; +`bun test tests/codex-quota-parser-parity.test.ts` reports 8 pass / 0 fail. +`bunfig.toml` pins `[test] root = "tests"` and preloads `./tests/preload.ts`, which is why +a bare `bun test` in a fresh worktree reports one spurious error until `bun install` runs. +That belongs in the plan and is now recorded there. + +## Field chain — PASS + +`rg -n "agentTaskRecovery" src/ gui/src` outside `src/config.ts` returns nothing, and +`tokenGuardian` has only its type declaration plus one comment. There is no config DTO, +no sanitize path, and no docs generator enumerating sections, so `config.ts` + +`types/config.ts` really is the whole chain for an optional section. No missed consumer. + +## Reachability — PASS with one correction + +- A percent DROP does land: `snapshotHasWeekly` (`src/codex/quota.ts:246`) tests + `weeklyPercent !== undefined`, so a lower value takes the `:294` branch and is written. + The merge only carries values FORWARD when the incoming snapshot omits a window. +- The poller keeps its own commit authority. `invalidationEpoch += 1` happens at `:2285`, + then `const epoch = invalidationEpoch;` at `:2286` captures the bumped value, so the + `epoch === invalidationEpoch` check at `:2338` passes for the forced probe itself. My + concern that a forced refresh would lose its own commit was wrong. +- `previous` is non-empty on a poller refresh as long as the cache key is unchanged; the + `:2309` comment only resets it when the provider SET changes, which is a config edit. + +## Blockers folded into the plan + +### 1. HIGH — the boundary claim was unverifiable + +`tests/core-lab-boundary.test.ts:63` tests `next.includes("/src/lab/")`. The guard is +hardcoded to Lab and says nothing about `src/quota/`, so wp5's "verify by hand" was the +only thing standing behind the claim — exactly the situation AGENTS.md describes as "this +paragraph was the only thing holding the guarantee". + +Fix, folded into `040`: wp5 adds a real guard asserting no static runtime edge reaches +`src/quota/reset-` from the four protected entrypoints, reusing the same walker. + +### 2. MEDIUM — `src/server/management-api.ts` is itself protected + +It is the fourth entry in `PROTECTED` (`tests/core-lab-boundary.test.ts:25`), added because +eagerly importing handlers put ~70 modules on every dashboard request. The wp4 route must +therefore be lazy for a second, independently sufficient reason. Recorded in `030`. + +Worth noting the walker deliberately does NOT propagate through `import()` +(`tests/core-lab-boundary.test.ts:76`: "a deferred edge, not a load-time one"), which is +what makes the wp3 lazy-import approach the sanctioned remedy rather than a loophole. + +### 3. MEDIUM — check-and-set was not atomic + +`hasSeenQuotaReset` followed by `markQuotaResetSeen` is two steps. Two observers racing +the same key — a poller tick and a live pooled response — can both read false and both +notify, defeating criterion c-4 under exactly the load that makes detection interesting. + +Fix, folded into `010`: replace both with one synchronous claim. + +### 4. MEDIUM — 30-day pruning could evict a live key + +A monthly window's key can legitimately be older than 30 days while still current, so +pruning by age alone can drop it and permit a duplicate notification. + +Fix, folded into `010`: never prune a key whose `resetAt` is still in the future, and +raise the age floor to 90 days. + +## Residuals accepted, not fixed + +- `sweepExpiredProviderAccountQuotaRows` (`src/providers/quota.ts:1485`) has no caller and + no registration. Wiring it would add a fourth silent row-removal path with the same + misread-as-reset hazard. Out of scope; noted for a separate unit. +- The two divergent `normalizeResetAt` implementations stay divergent. Unifying them + touches every provider parser and belongs in its own unit; the detector normalizes at its + own boundary instead, which is already in the plan. +- `LOCAL_MANAGEMENT_READ_PATHS` (`src/lib/local-management-capability.ts:10`) is an + allowlist for bound local reads used by `doctor`/`health`. The new route does not need + to join it; not adding it is a deliberate choice, not an oversight. + +VERDICT: GO-WITH-FIXES (blockers=4) — all four folded above. diff --git a/devlog/_plan/260828_quota_reset_detection/002_wp2_audit_response.md b/devlog/_plan/260828_quota_reset_detection/002_wp2_audit_response.md new file mode 100644 index 0000000000..9a6027164f --- /dev/null +++ b/devlog/_plan/260828_quota_reset_detection/002_wp2_audit_response.md @@ -0,0 +1,91 @@ +# wp2 A-gate audit response + +The retired plan auditor (grok-4.6, `audit-quota-reset-plan-2`) returned after its third +wait cycle with `GO-WITH-FIXES (blockers=9)` — after I had already audited directly. Both +audits are recorded; this one found things mine did not. I re-verified every blocker I acted +on rather than taking the verdict on trust. + +## Blocker 1 — Critical, and correct. The provider seam could never have fired. + +`src/providers/quota.ts:2290` binds `previous` only when `cache.key === key`. I had read +the `:2309` comment saying the key encodes the provider SET and stopped there. The key is +actually built by `cacheKeyWithAggregationState` (`:193`), which folds +`quotaSignatureValue` (`:155`) — `weeklyPercent`, `weeklyResetAt`, `monthlyResetAt`, +`customWindows`, and `updatedAt` — into a sha256 digest appended to the key. + +A reset changes exactly those values, so the key rotates, so `previous` is `[]`, so the +detector's no-prev rule returns null. On a pooled install the key rotates on every quota +write, since `updatedAt` is in the digest. + +Verified by reading `:2273-2290` and `:193-217`. The wp3 seam claim was wrong: `:2343` is +indeed the only place a newer report displaces an older one, but it displaces under a +DIFFERENT key, which makes the displacement invisible to a cache-key-equality diff. + +Fix folded into `020`: provider observation no longer reads `previous` at all. The detector +owns its own last-seen map keyed by `(provider, accountTag, window)`, which is immune to +cache-key rotation by construction. That map is the same store wp2 already persists. + +## Blocker 2 — High, and correct. Fixed in this B. + +`src/codex/quota.ts:323-329` carries the previous burst tuple forward verbatim when a +header write omits it. So a partial write reproduces the old deadline AND the old percent; +once wall-clock passes that copied deadline, my "an expired clock is sufficient evidence" +rule fired on a snapshot where upstream said nothing — on the once-per-pooled-response path. + +My reasoning for dropping the drop-requirement (catching low-usage rollovers) was sound; the +conclusion was too broad. `scheduled` now requires the expired deadline PLUS corroboration: +either usage fell, or upstream issued a new deadline. A byte-identical carried-forward window +supplies neither. Regression test: "a carried-forward window past its deadline is NOT a +reset". + +## Blocker 4 — High, and correct. Three missed consumers. + +My field-chain audit searched for `agentTaskRecovery` and concluded `config.ts` plus +`types/config.ts` was the whole chain. It missed: + +- `validFileConfigDiagnostics` (`src/config.ts:1957`) — a diagnostics warning surface + SEPARATE from the three `loadConfig` branches, feeding `ocx config show --source`. +- `SECRET_KEYS` (`src/cli/config-command.ts:18`) matches + `apiKey|key|accessToken|refreshToken|idToken|token|password|clientSecret`. `webhookUrl` + matches none of them, so a Slack or Discord webhook — whose secret IS the URL — would be + echoed in plaintext by `ocx config show` and written by `config export`. That is a real + credential-disclosure defect, not a style nit. +- `safeConfigDTO` (`src/server/auth-cors.ts:695`) is an explicit whitelist, so the section + is correctly invisible to the GUI. Right outcome, undocumented. + +All three added to the wp4 file map in `030`, with `webhookUrl` redaction as a named +requirement. + +## Blockers 3, 5, 7, 8 — accepted, folded into their phases + +- **3:** `loadConfig` (`src/config.ts:1805`) is a `readFileSync` plus a full + `safeParse` with no memoization. Calling it per pooled response to ask "is this feature + off" is absurd. The gate becomes generation-cached via `captureConfigGeneration`. +- **5:** `PROTECTED` has FOUR entries and all four reach `src/codex/quota.ts` statically, + so the lazy-import requirement is load-bearing and nothing enforced it. wp5's guard is + parameterized over a target set and gets a synthetic attack case. +- **7:** `Bun.spawn` rejects a string `stdin`; encoded bytes it is. +- **8:** two concurrent forced refreshes make the loser skip both the commit and the notify. + Once observation moves off `cache.key` (blocker 1) the loser still observes, so this + largely dissolves — but the residual window is stated in `020` rather than hidden. + +## Blocker 9 — Low, correct + +`QUOTA_PERSIST_DEBOUNCE_MS` is at `src/codex/quota.ts:43`, not `:493` (that line is the +function). And `000_plan.md` promised docs `010`–`050` for wp1 while `050` is a wp5 +deliverable. Both corrected. + +## Blocker 6 — already fixed before the verdict arrived + +The racy has/mark pair became one atomic `claimQuotaReset` during my own audit. The +reviewer noticed the shipped code already says "claim". + +## Found by me, not the reviewer + +`quotaResetKey` used `resetAt ?? "none"`. For the several provider parsers that never emit +a reset clock, every reset of one window collapsed onto a single key, so the first claim +would have permanently suppressed all later ones. Now falls back to the expired deadline +before "none", and a window with no deadline on either side is not evaluated at all. + +VERDICT ACCEPTED: GO-WITH-FIXES (blockers=9). Two fixed in wp2, seven folded forward, none +rebutted. diff --git a/devlog/_plan/260828_quota_reset_detection/003_wp3_audit_response.md b/devlog/_plan/260828_quota_reset_detection/003_wp3_audit_response.md new file mode 100644 index 0000000000..8b2ce8a4ab --- /dev/null +++ b/devlog/_plan/260828_quota_reset_detection/003_wp3_audit_response.md @@ -0,0 +1,88 @@ +# wp3 A-gate audit response + +A third grok-4.6 reviewer (`review-wp3-observation-wiring`) went silent through four bounded +wait cycles and was retired under DISPATCH-RETIRE-01. Two of three dispatched reviewers have +now failed this way — one on provider capacity, two on silence — so the audits below were run +directly. Recording that rather than implying a reviewer signed off. + +## 1. Does the provider seam actually fire? — PROVEN YES + +This is the question that killed the original design, so it gets a live probe rather than a +reading. Two consecutive committed reports for one anthropic account, driven through the same +calls `notifyProviderQuotaSnapshot` makes: + +``` +after report1 hits: 0 +after report2 hits: 1 kinds: scheduled:5h +payload: {"kind":"scheduled","scope":"anthropic","accountTag":"1aw4hwbh","window":"5h", + "percentBefore":94,"percentAfter":3,"previousResetAt":...,"resetAt":..., + "detectedAt":...,"key":"anthropic|1aw4hwbh|5h|..."} +after report3 hits (idempotent): 1 +``` + +First report is a baseline and fires nothing. The second is detected. A third identical +observation does not re-notify. The cache-key rotation that made the old design dead is now +irrelevant, because the baseline comes from the persisted swap map rather than +`cache.key === key`. + +The payload contains only closed-union labels and numbers — no account id, no email, no path. + +## 2. Lazy-import contract — VERIFIED + +`rg` for a static `import ... from ".../quota/reset-"` in `src/codex/quota.ts` and +`src/providers/quota.ts` returns nothing; only the two dynamic `import()` calls exist +(`src/codex/quota.ts:359`, `:362`; `src/providers/quota.ts:2281`, `:2284`). None of the +four protected entrypoints names `quota/reset-` at all. + +Residual, stated rather than fixed: because the seams do not await, observation order for two +writes in quick succession is promise-resolution order. Both compute the same idempotence key +for the same new deadline, so the claim ledger collapses them to one notification; the only +consequence is which one defines the baseline. wp5's guard will make the no-static-edge half +of this enforceable instead of grep-verified. + +## 3. Found by me: the generation-cached enable gate was stale by construction — FIXED + +The wp2 audit told me to cache the enable check against `captureConfigGeneration()`, and I +did. That was wrong, and I caught it while verifying the reviewer's fourth question myself. + +`configGeneration` is only assigned at `src/lib/state-store-sweeper.ts:149`, inside +`reconcileStateGeneration`, which runs from `reconcileLiveStateStores` on account and +provider changes. Editing `quotaResetNotify` alone never bumps it. So enabling the feature +would have had NO effect until some unrelated account edit happened to reconcile — the exact +"toggling enabled takes effect on the next tick" property the doc claimed. + +Now keyed on the config file's mtime and size, with a 5-second TTL bounding how often the hot +path stats. A config edit is picked up within 5 seconds; a quiet install pays one `statSync` +per 5 seconds rather than a full `safeParse` per request. + +Worth naming the pattern: a cache key that does not actually change when the cached input +changes is worse than no cache, because it converts a performance concern into a correctness +bug that only shows up as "the feature does nothing". + +## 4. Detector regressions since the last review — checked for missed REAL resets + +The tightened rules could in principle suppress a genuine reset. Cases checked: + +- rolling 5h window that genuinely resets: usage falls, so the drop carries it. Fires. +- weekly window at 0% on both sides past its deadline: no drop, but upstream issues a new + deadline, so the corroboration branch fires. +- account that resets while completely unused with NO new deadline: returns null. This is a + deliberate false negative — the snapshot is byte-identical to a carried-forward one, and + there is no way to tell them apart. Recorded as a known limitation. +- rollover immediately followed by heavy use (3% -> 24% past the deadline): returns null via + the rise check. Also deliberate; also recorded. + +## 5. Test honesty + +`settle()` in `tests/quota-reset-observation.test.ts` drains microtasks then waits 5 ms, +which is a race in principle. It is load-bearing only for the two seam tests, and the +observer-contract tests call `observeQuotaSnapshot` synchronously and assert its return +value, so the same behavior is covered without any timing dependency. If CI ever flakes here, +the fix is to assert the synchronous return rather than to raise the sleep. + +Two assertions were weak and are now real: the account-tag test asserts the salt actually +changes the tag across installs (it previously only checked length and the absence of "@", +which any digest satisfies), and the claim-durability test now spawns a real second process +instead of calling a test-only flush. + +VERDICT (direct audit): GO-WITH-FIXES (blockers=1) — the stale enable gate, fixed above. diff --git a/devlog/_plan/260828_quota_reset_detection/004_wp3_review_response.md b/devlog/_plan/260828_quota_reset_detection/004_wp3_review_response.md new file mode 100644 index 0000000000..2ab415ab6a --- /dev/null +++ b/devlog/_plan/260828_quota_reset_detection/004_wp3_review_response.md @@ -0,0 +1,144 @@ +# wp3 adversarial review — response + +Reviewer: independent subagent, dispatched against `f4fcbb547` (HEAD moved to `2e4b3be3e` +mid-review; the reviewer noted this and verified both). Verdict: GO-WITH-FIXES, 4 blockers. + +Every blocker was reproduced here before being accepted, and every fix was driven red +against the pre-fix code before being committed green. Two of the reviewer's proposed +remedies were rejected on evidence and replaced; both are recorded below, because a review +response that only records agreement is not evidence of independent judgement. + +## Blocker 1 (Critical) — out-of-order observations manufacture false resets + +Accepted, reproduced, fixed. + +The seam awaited two `import()` calls before swapping the baseline. Bun does not resolve +concurrent dynamic imports in call order, so a burst arrives reordered. Reproduced through +the real writer with 21 monotonically RISING writes (10% -> 90%, no reset anywhere): + +``` +write order: 10,14,18,...,90 +events: [{"k":"surprise","w":"5h","pb":82,"pa":10}] # 4/4 isolated runs +``` + +The compounding harm is the durable claim: the false event takes the idempotence key, so +the genuine reset on that window is then suppressed permanently. That is what makes this a +correctness defect rather than noise. + +**Fix.** Both seams now serialize observations through a module-level promise chain +reassigned SYNCHRONOUSLY at call time (`pendingObservation = pendingObservation.then(...)`), +so each link starts only after the previous one committed its baseline. The snapshot is also +copied before the boundary, because `next` is the live map value and the following write +mutates it. + +The reviewer's alternative — statically import `window-mapping` and observe synchronously — +was rejected: it adds a static edge from a file that `src/server/responses/core.ts` reaches, +and `tests/quota-reset-core-boundary.test.ts` (added this phase) forbids exactly that. The +promise chain achieves the same ordering guarantee without spending the boundary. + +Evidence, pre-fix vs post-fix, isolated `OPENCODEX_HOME` per run: + +``` +pre-fix: FALSE_EVENT_COUNT: 1 1 1 1 +post-fix: EVENTS: [] [] [] [] +``` + +## Blockers 2 and 3 (High) — the account key was wrong in two ways + +Accepted, fixed together, because both are the same mistake: identity was resolved +asynchronously from mutable global state, after the commit it describes. + +- **Key-auth pool collapse.** `getAccountSet()` reads the OAuth store, so every key in a + key-auth provider's `apiKeyPool` fell through to `"default"`. Rotating from a spent key to + a fresh one inherited the spent key's history and read as a reset. +- **Mid-flight failover.** `promoteAnthropicActiveAccount` rewrites `activeAccountId` during + request routing, so a 429 between the commit and a later async read attributes this + report to a different account. `fetchAnthropicQuota` already captures `probedAccountId` + before awaiting for precisely this reason. + +**Fix.** `providerObservationAccountKey` resolves identity synchronously at the commit site, +and mirrors the discriminator the report cache already uses (`apiKeyPoolEntryId`) instead of +inventing a second notion of identity. + +## Blocker 4 — the trap-3 regression test was vacuous + +Accepted; this was the most useful finding, because the test was green and wrong. + +`tests/quota-reset-observation.test.ts` called `resetQuotaResetStoreForTests()` between the +row clear and the fresh write. No production path does that: real reauth clears the quota +row only, and the observer's baseline lives in a separate file. Removing the line: + +``` +REAUTH_EVENTS: [{"k":"surprise","w":"5h","pb":91,"pa":0}] +``` + +So reauth of a used account fired a false reset on every occurrence, and the test that +existed to prevent it was simulating a state that never happens. + +**Fix.** `forgetLastObservedWindows` in the store, `forgetQuotaBaseline` in the observer +(which owns the salted tag), called from `clearAccountQuota` on the same serialized chain so +it cannot be overtaken by an in-flight observation. The claim ledger is deliberately NOT +released — a cleared row must not re-notify a reset it already reported. + +## Finding 6 (Medium) — fixed, but NOT by the proposed remedy + +The finding is correct: a rolling window's percent decays naturally, and the surprise branch +accepted a bare drop. Confirmed at 88% -> 61% one hour into a 5h window, no reset. + +The proposed remedy — bound the drop by `elapsed/windowLength * previousPercent` — was +implemented, measured, and **rejected**. Decay magnitude cannot be bounded from elapsed time: +the percent that ages out depends on WHEN the usage occurred, so an hour of idling can retire +a burst that all landed in one minute. Measured against the proportional bound, 88% -> 5% +one hour in (a 83-point drop) was suppressed as "explainable decay" while the genuine +27-point decay case it was written for still fired. It was wrong in both directions. + +**What shipped instead:** deadline MOVEMENT against elapsed time. While a window is merely +rolling, its deadline advances by roughly the elapsed gap; a genuine out-of-band reset issues +a deadline a full window into the future, hours beyond a gap measured in minutes. A deadline +that stands still while usage falls is the clearest surprise signature there is, and is +explicitly allowed through. Fails OPEN whenever the evidence is missing. + +## Finding 5 (Medium) — accepted + +The eviction comment described behavior the code did not have: re-setting a key does not move +it in a Map, so the EARLIEST-INSERTED row was evicted — on a real install the long-lived +codex account, while 63 transient rows survived. Fixed with delete-then-set, making it a true +LRU and making the existing comment true. The regression test fails against the old code. + +## Findings 4 and 7 — deferred to wp5, with reasons + +- **Finding 4 (debounce starvation).** Real: a write cadence under 250 ms defers the baseline + write indefinitely, so a SIGKILL loses the baseline. Not a correctness defect in the + detection contract (the trailing write lands once traffic quiesces, and a lost baseline + re-baselines rather than misfires), and the maximum-staleness cap belongs with the other + persistence hardening in wp5. Recorded in `040_phase5_hardening_delivery.md`. +- **Finding 7 (`updateAccountQuota` does not notify).** Has no in-repo caller, but is public + API through `src/codex/auth-api.ts`. wp5 will either notify or state why not. + +## Reviewer claim NOT accepted + +`settle()` flakiness: the reviewer measured it and concluded it is sound (0.51 ms against a +5 ms budget, 14 runs clean including under CPU load). Agreed, and the earlier plan to +rewrite it is dropped. The burst test does not rely on it — it spawns a child process, +because an in-process burst test PASSED against the unfixed seam: earlier tests in the file +leave the observer module cached, and a cached import resolves in call order. Only a cold +module registry reproduces the defect. A test that cannot fail is worth less than no test, +so this one was driven red 3/3 in a child process before being trusted. + +## Boundary guard (the wp3 deliverable itself) + +`tests/quota-reset-core-boundary.test.ts`. `tests/core-lab-boundary.test.ts:63` hardcodes +`/src/lab/`, so nothing enforced the same obligation for `src/quota/`. The walker was +EXTRACTED to `tests/helpers/import-graph.ts` and shared rather than copied, because the Lab +guard already records what a duplicated predicate costs: its own self-test re-declared a +private copy of the matcher and so proved a local literal behaved, not that the guard did. + +Guards: no load-time edge from the 4 protected entrypoints into `src/quota/` (whole +directory, not a `reset-` prefix — a prefix would let a future sibling through); both seams +reach the observer and reach it ONLY dynamically; the composition-root exemption is pinned to +an exact chain and the poller is asserted to pull in nothing at load time. + +Driven red three ways: a static import in `src/router.ts` (4 assertions fail, including the +two files that transitively reach it), a seam converted to a static import (1 fails), and the +observer wiring deleted entirely (the reachability assertion fails, proving the +dynamic-only check is not vacuously satisfiable by absent wiring). diff --git a/devlog/_plan/260828_quota_reset_detection/010_phase2_detection_core.md b/devlog/_plan/260828_quota_reset_detection/010_phase2_detection_core.md new file mode 100644 index 0000000000..423bffb377 --- /dev/null +++ b/devlog/_plan/260828_quota_reset_detection/010_phase2_detection_core.md @@ -0,0 +1,155 @@ +# wp2 — Detection core + +Pure detection plus the durable store that makes "exactly once" true across restarts. +Nothing in this phase touches an existing call path; it closes with its own tests green. + +## NEW `src/quota/reset-detector.ts` + +Pure functions only: no imports from `config`, no clock of its own, no I/O. `now` is a +parameter so tests drive time instead of waiting for it. + +```ts +/** One observed usage window, normalized away from provider-specific field names. */ +export type QuotaWindowObservation = { + /** Closed-union window identity. Custom provider windows arrive as "custom: