diff --git a/src/chatgpt/app-server-shim/gate-rewrite.ts b/src/chatgpt/app-server-shim/gate-rewrite.ts index 0ae8dcfe8c..4864a7bece 100644 --- a/src/chatgpt/app-server-shim/gate-rewrite.ts +++ b/src/chatgpt/app-server-shim/gate-rewrite.ts @@ -38,7 +38,10 @@ export function unlockRateLimitGate(value: unknown): boolean { return { cleared, exhausted, blocked }; } if (!isRecord(node)) return { cleared, exhausted, blocked }; + // A usage window at 100%, in either spelling the gate fields come in: `usedPercent` in the + // app-server's JSON-RPC, `used_percent` in the web usage snapshot. if (typeof node.usedPercent === "number" && node.usedPercent >= 100) exhausted = true; + if (typeof node.used_percent === "number" && node.used_percent >= 100) exhausted = true; if (node.spendControlReached !== undefined && node.spendControlReached !== null && node.spendControlReached !== false) blocked = true; if (isRecord(node.spend_control) && node.spend_control.reached === true) blocked = true; const reachedType = node.rate_limit_reached_type; diff --git a/structure/clients/chatgpt-desktop.md b/structure/clients/chatgpt-desktop.md index 9f3f1328e5..deae2e8b88 100644 --- a/structure/clients/chatgpt-desktop.md +++ b/structure/clients/chatgpt-desktop.md @@ -27,7 +27,8 @@ The pure gate rewrite changes known plain-quota fields only in eligible JSON-RPC rate-limit notifications and top-level rate-limit results. Workspace, credit, unknown reached-type and spend-control restrictions preserve closed gate flags. Both the rate-limit flags and `ordinaryUsageAllowed` open only where the subtree shows -plain-quota evidence: a cleared plain reached type or a usage window at 100%. +plain-quota evidence: a cleared plain reached type or a usage window at 100% (`usedPercent` in the +RPC, `used_percent` in the web usage snapshot; every gate field is read in both spellings). Usage percentages, resets and window durations remain accurate. Unrelated messages and malformed lines remain byte-identical; changed lines are reserialized. A per-line rewrite exception preserves that line. A failure in the framing/rewrite diff --git a/tests/clients/desktop-app-server-shim.test.ts b/tests/clients/desktop-app-server-shim.test.ts index d0974969cb..b801884fba 100644 --- a/tests/clients/desktop-app-server-shim.test.ts +++ b/tests/clients/desktop-app-server-shim.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { rewriteAppServerLine } from "../../src/chatgpt/app-server-shim/app-server-rewrite"; +import { unlockRateLimitGate } from "../../src/chatgpt/app-server-shim/gate-rewrite"; import { createRpcLineFilter, runStdoutFilter, runChatgptAppServerFilter } from "../../src/chatgpt/app-server-shim/filter"; /** @@ -126,6 +127,43 @@ describe("app-server line rewrite", () => { }); }); +describe("gate rewrite on the web usage snapshot's spelling", () => { + // The rewrite reads every gate field in both spellings (rate_limit/rateLimit, + // limit_reached/limitReached, spend_control/spendControlReached, ...). The usage window must be + // read in both too, or a snake_case snapshot never shows the plain quota as the reason. + const snapshot = (usedPercent: number, spendReached = false) => ({ + usage: { + plan_type: "pro", + rate_limit: { + allowed: false, + limit_reached: true, + primary_window: { used_percent: usedPercent, limit_window_seconds: 604800, reset_after_seconds: 205162 }, + }, + spend_control: { reached: spendReached }, + }, + }); + + test("a window at used_percent 100 is plain-quota evidence: the flags open, the window stays as sent", () => { + const value = snapshot(100); + expect(unlockRateLimitGate(value)).toBe(true); + expect(value.usage.rate_limit.allowed).toBe(true); + expect(value.usage.rate_limit.limit_reached).toBe(false); + expect(value.usage.rate_limit.primary_window).toEqual(snapshot(100).usage.rate_limit.primary_window); + }); + + test("below 100% the payload shows no quota reason, so the flags stay closed", () => { + const value = snapshot(42); + expect(unlockRateLimitGate(value)).toBe(false); + expect(value).toEqual(snapshot(42)); + }); + + test("a reached spend control keeps the flags closed even with the window at 100%", () => { + const value = snapshot(100, true); + expect(unlockRateLimitGate(value)).toBe(false); + expect(value).toEqual(snapshot(100, true)); + }); +}); + describe("app-server line filter", () => { const collect = (chunks: Uint8Array[], filter = createRpcLineFilter()) => { const parts: Uint8Array[] = [];