Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ See `docs/llm-wiki/release.md`.
- **Account heatmap stats strip**: Codex-style totals — cumulative tokens, peak day, longest chat, current / longest streak — plus a **Cumulative** calendar view (running-total color).

### Fixed
- **Usage modal cache > input, then empty**: Session spend only sums `turn_completed` snapshots. Cache-heavy fragments (cache > input, no `modelCalls`) are dropped so cached cannot exceed input; a real turn without `modelCalls` still counts. Totals persist in sessionStorage, and an in-flight turn says usage updates when it finishes.
- **Chat image cards no longer die after the turn ends**: Leftover remote https thumbs (web-fetch charts, etc.) first-paint from the in-memory thumb cache on journal remount. Swapping `src` mid-load used to abort the original `<img>` and lock `broken_blob` (“preview failed”). Abort / stale-src errors are ignored; a working https original is not wiped when thumb resolve returns empty.

## [0.2.17] - 2026-08-14
Expand Down
4 changes: 4 additions & 0 deletions src/app/AppWorkbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21739,6 +21739,10 @@ export function AppWorkbench() {
spend={sessionSpend}
account={account}
customRoute={customRouteActive}
turnActive={
session.state === "streaming" ||
session.state === "awaiting_permission"
}
onClose={() => setShowUsageLimitModal(false)}
/>
{(agentDashboardOpen) ? (
Expand Down
9 changes: 8 additions & 1 deletion src/components/UsageLimitModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ type Props = {
spend: SessionSpend;
account: AccountStatus | null;
customRoute?: boolean;
/** True while a turn is in flight — usage lands on turn_completed. */
turnActive?: boolean;
onClose: () => void;
};

Expand All @@ -46,6 +48,7 @@ export function UsageLimitModal({
spend,
account,
customRoute = false,
turnActive = false,
onClose,
}: Props) {
const tr = useMemo(() => createT(locale), [locale]);
Expand Down Expand Up @@ -160,7 +163,11 @@ export function UsageLimitModal({
{!sessionId ? (
<p className="usage-limit-modal__note">{tr("usageModal.noSession")}</p>
) : !showSpend ? (
<p className="usage-limit-modal__note">{tr("usageModal.noCalls")}</p>
<p className="usage-limit-modal__note">
{turnActive
? tr("usageModal.pendingTurn")
: tr("usageModal.noCalls")}
</p>
) : (
<dl className="usage-limit-modal__dl">
<div className="usage-limit-modal__row">
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/en/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const enAccount = {
"usageModal.cost": "Cost",
"usageModal.noSession": "Session usage is unavailable until the session starts.",
"usageModal.noCalls": "No model calls yet in this session.",
"usageModal.pendingTurn": "Usage updates when this turn finishes.",
"usageModal.incomplete": "Usage is incomplete and may under-count.",
"usageModal.costPartial": "Cost is incomplete and may under-count.",
"usageModal.quotaUnknown": "Could not load the weekly limit.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/zh-TW/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const zhTWAccount = {
"usageModal.cost": "費用",
"usageModal.noSession": "對話尚未開始,暫無用量。",
"usageModal.noCalls": "本對話還沒有模型呼叫。",
"usageModal.pendingTurn": "用量在本回合結束後更新。",
"usageModal.incomplete": "用量追蹤不完整,數字可能偏低。",
"usageModal.costPartial": "費用追蹤不完整,數字可能偏低。",
"usageModal.quotaUnknown": "無法載入每週限額。",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/zh/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const zhAccount = {
"usageModal.cost": "费用",
"usageModal.noSession": "会话尚未开始,暂无用量。",
"usageModal.noCalls": "本会话还没有模型调用。",
"usageModal.pendingTurn": "用量在本回合结束后更新。",
"usageModal.incomplete": "用量跟踪不完整,数字可能偏低。",
"usageModal.costPartial": "费用跟踪不完整,数字可能偏低。",
"usageModal.quotaUnknown": "未能加载每周限额。",
Expand Down
69 changes: 68 additions & 1 deletion src/lib/sessionSpend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ afterEach(() => {
describe("isSessionSpendBillingSource", () => {
it("accepts turn_completed and rejects occupancy / prompt_result", () => {
expect(isSessionSpendBillingSource("turn_completed")).toBe(true);
expect(isSessionSpendBillingSource("response_completed")).toBe(true);
expect(isSessionSpendBillingSource("response_completed")).toBe(false);
expect(isSessionSpendBillingSource("turn_usage")).toBe(false);
expect(isSessionSpendBillingSource("prompt_result")).toBe(false);
expect(isSessionSpendBillingSource("context_size")).toBe(false);
expect(isSessionSpendBillingSource("compact")).toBe(false);
Expand Down Expand Up @@ -102,6 +103,72 @@ describe("applySessionSpendTurn", () => {
expect(again.modelCalls).toBe(6);
});

it("does not add cache-heavy fragments that lack modelCalls", () => {
const once = applySessionSpendTurn(
emptySessionSpend(),
{
source: "turn_completed",
inputTokens: 65_402,
outputTokens: 2_268,
totalTokens: 67_670,
cachedReadTokens: 46_848,
reasoningTokens: 762,
modelCalls: 3,
apiDurationMs: 44_692,
costUsdTicks: 741_400_000,
},
1_000,
);
const again = applySessionSpendTurn(
once,
{
source: "turn_completed",
inputTokens: 22_887,
outputTokens: 3_288,
cachedReadTokens: 97_152,
reasoningTokens: 1_419,
},
1_100,
);
expect(again.inputTokens).toBe(65_402);
expect(again.cachedReadTokens).toBe(46_848);
expect(again.modelCalls).toBe(3);
expect(sessionSpendCacheHitRate(again)).toBe(72);
});

it("accepts a turn_completed snapshot even when modelCalls is missing", () => {
const next = applySessionSpendTurn(
emptySessionSpend(),
{
source: "turn_completed",
inputTokens: 65_402,
outputTokens: 2_268,
totalTokens: 67_670,
cachedReadTokens: 46_848,
},
1_000,
);
expect(next.inputTokens).toBe(65_402);
expect(next.cachedReadTokens).toBe(46_848);
expect(sessionSpendCacheHitRate(next)).toBe(72);
});

it("clamps per-turn cache to input", () => {
const next = applySessionSpendTurn(
emptySessionSpend(),
{
source: "turn_completed",
inputTokens: 10_000,
outputTokens: 1,
totalTokens: 10_001,
cachedReadTokens: 48_000,
modelCalls: 1,
},
1_000,
);
expect(next.cachedReadTokens).toBe(10_000);
});

it("marks incomplete / partial flags", () => {
const next = applySessionSpendTurn(
emptySessionSpend(),
Expand Down
81 changes: 71 additions & 10 deletions src/lib/sessionSpend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,16 @@ export function emptySessionSpend(): SessionSpend {
return { ...EMPTY_SESSION_SPEND };
}

/**
* Only the CLI **user-turn** aggregate. Per-call `response_completed` /
* `turn_usage` packets use a different cache accounting (often cache-only
* or uncached+cache split) and must not be summed with `turn_completed`.
*/
export function isSessionSpendBillingSource(
source: string | null | undefined,
): boolean {
const s = (source ?? "").toLowerCase();
if (!s) return false;
if (s === "prompt_result") return false;
return (
s === "turn_completed" ||
s === "response_completed" ||
s.includes("turn_completed") ||
s === "turn_usage" ||
s === "turnusage"
);
return s === "turn_completed";
}

function finiteNonNeg(n: number | null | undefined): number | null {
Expand All @@ -96,6 +93,19 @@ export function spendTurnFingerprint(turn: SessionSpendTurn): string {
].join(":");
}

/**
* Per-call / split-accounting fragments: cache with no input, or cache > input
* and no modelCalls. Those packets must not be summed into the turn snapshot.
*/
export function isSpendFragment(turn: SessionSpendTurn): boolean {
const input = finiteNonNeg(turn.inputTokens) ?? 0;
const cached = finiteNonNeg(turn.cachedReadTokens) ?? 0;
const calls = finiteNonNeg(turn.modelCalls) ?? 0;
if (calls > 0) return false;
if (cached > 0 && (input <= 0 || cached > input)) return true;
return false;
}

export function hasSpendSignal(turn: SessionSpendTurn): boolean {
return (
finiteNonNeg(turn.inputTokens) != null ||
Expand Down Expand Up @@ -146,6 +156,7 @@ export function applySessionSpendTurn(
): SessionSpend {
if (!isSessionSpendBillingSource(turn.source)) return state;
if (!hasSpendSignal(turn)) return state;
if (isSpendFragment(turn)) return state;

const fp = spendTurnFingerprint(turn);
if (
Expand All @@ -158,7 +169,9 @@ export function applySessionSpendTurn(

const input = finiteNonNeg(turn.inputTokens) ?? 0;
const output = finiteNonNeg(turn.outputTokens) ?? 0;
const cached = finiteNonNeg(turn.cachedReadTokens) ?? 0;
let cached = finiteNonNeg(turn.cachedReadTokens) ?? 0;
// CLI `inputTokens` already includes cache reads; cache cannot exceed input.
if (input > 0 && cached > input) cached = input;
const reasoning = finiteNonNeg(turn.reasoningTokens) ?? 0;
const calls = finiteNonNeg(turn.modelCalls) ?? 0;
const duration = finiteNonNeg(turn.apiDurationMs) ?? 0;
Expand Down Expand Up @@ -268,8 +281,43 @@ export function formatUsageResetTime(

// ── In-memory per-session store (App process lifetime) ────────────────

const SPEND_STORE_KEY = "grok.sessionSpend.v1";

const spendBySession = new Map<string, SessionSpend>();
const listeners = new Set<(sessionId: string) => void>();
let storeHydrated = false;

function canUseSessionStorage(): boolean {
return typeof sessionStorage !== "undefined";
}

function persistSpendStore(): void {
if (!canUseSessionStorage()) return;
try {
const obj: Record<string, SessionSpend> = {};
for (const [id, spend] of spendBySession) obj[id] = spend;
sessionStorage.setItem(SPEND_STORE_KEY, JSON.stringify(obj));
} catch {
/* quota / private mode */
}
}

function hydrateSpendStore(): void {
if (storeHydrated) return;
storeHydrated = true;
if (!canUseSessionStorage()) return;
try {
const raw = sessionStorage.getItem(SPEND_STORE_KEY);
if (!raw) return;
const obj = JSON.parse(raw) as Record<string, SessionSpend>;
for (const [id, spend] of Object.entries(obj ?? {})) {
if (!id || !spend || typeof spend !== "object") continue;
spendBySession.set(id, { ...emptySessionSpend(), ...spend });
}
} catch {
/* ignore bad cache */
}
}

function notify(sessionId: string): void {
for (const fn of listeners) {
Expand All @@ -282,12 +330,15 @@ function notify(sessionId: string): void {
}

export function getSessionSpend(sessionId: string | null | undefined): SessionSpend {
hydrateSpendStore();
if (!sessionId) return emptySessionSpend();
return spendBySession.get(sessionId) ?? emptySessionSpend();
}

export function resetSessionSpend(sessionId: string): void {
hydrateSpendStore();
spendBySession.delete(sessionId);
persistSpendStore();
notify(sessionId);
}

Expand All @@ -296,10 +347,12 @@ export function ingestSessionSpend(
turn: SessionSpendTurn,
now = Date.now(),
): SessionSpend {
hydrateSpendStore();
const prev = spendBySession.get(sessionId) ?? emptySessionSpend();
const next = applySessionSpendTurn(prev, turn, now);
if (next === prev) return prev;
spendBySession.set(sessionId, next);
persistSpendStore();
notify(sessionId);
return next;
}
Expand All @@ -316,4 +369,12 @@ export function subscribeSessionSpend(
/** Test-only: wipe the process map. */
export function clearSessionSpendStore(): void {
spendBySession.clear();
storeHydrated = true;
if (canUseSessionStorage()) {
try {
sessionStorage.removeItem(SPEND_STORE_KEY);
} catch {
/* ignore */
}
}
}
Loading