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
50 changes: 36 additions & 14 deletions entrypoints/claude-ai.content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
// Thin orchestrator: validates bridge messages, drives state transitions, renders overlay.
// All logic lives in imported modules; this file only wires them together.

import type { LcoBridgeMessage, StoreTokenBatchMessage, StoreMessageLimitMessage, StoreTokenBatchResponse, RecordTurnMessage, FinalizeConversationMessage, SetActiveConvMessage, StoreUsageLimitsMessage } from '../lib/message-types';
import type { LcoBridgeMessage, StoreTokenBatchMessage, StoreMessageLimitMessage, StoreTokenBatchResponse, RecordTurnMessage, FinalizeConversationMessage, SetActiveConvMessage, StoreUsageLimitsMessage, UsageLimitsData, UsageBudgetResult } from '../lib/message-types';
import { LCO_NAMESPACE } from '../lib/message-types';
import { isValidBridgeSchema } from '../lib/bridge-validation';
import { INITIAL_STATE, applyTokenBatch, applyStreamComplete, applyStorageResponse, applyHealthBroken, applyHealthRecovered, applyMessageLimit, applyRestoredConversation, applyDraftEstimate, clearDraftEstimate } from '../lib/overlay-state';
import { INITIAL_STATE, applyTokenBatch, applyStreamComplete, applyStorageResponse, applyHealthBroken, applyHealthRecovered, applyMessageLimit, applyRestoredConversation, applyDraftEstimate, clearDraftEstimate, applyUsageBudget } from '../lib/overlay-state';
import { computeUsageBudget } from '../lib/usage-budget';
import { computePreSubmitEstimate } from '../lib/pre-submit';
import { createOverlay } from '../ui/overlay';
import { showEnableBanner } from '../ui/enable-banner';
Expand Down Expand Up @@ -59,20 +60,22 @@ async function fetchStoredRecord(orgId: string | null, conversationId: string):

/**
* Fetch the Anthropic usage limits for this account, forward to background for
* storage, and return the 5-hour session utilization percentage.
* storage, and return a UsageBudgetResult (session + weekly utilization with
* reset metadata), or null on any failure.
*
* The usage endpoint returns exact session and weekly utilization with reset
* timestamps — the same data shown on claude.ai/settings/limits.
*
* Called on ORGANIZATION_DETECTED (page load) and after each STREAM_COMPLETE.
* The returned utilization value is used for delta tracking: the caller snapshots
* the before-value, calls this, and subtracts to get the exact session cost of
* the last message.
* Callers use result.sessionPct for delta tracking: snapshot the before-value,
* call this, and subtract to get the exact session cost of the last message.
* result.weeklyPct drives the overlay weekly-cap bar and the side panel
* Usage Budget card.
*
* Returns null on any failure (network error, malformed response). The caller
* treats null as "delta uncomputable" and records the turn without a delta.
*/
async function fetchAndStoreUsageLimits(orgId: string): Promise<number | null> {
async function fetchAndStoreUsageLimits(orgId: string): Promise<UsageBudgetResult | null> {
try {
const response = await fetch(`/api/organizations/${orgId}/usage`, { credentials: 'same-origin' });
if (!response.ok) return null;
Expand All @@ -94,7 +97,13 @@ async function fetchAndStoreUsageLimits(orgId: string): Promise<number | null> {
sevenDayUtilization: sevenDay.utilization,
sevenDayResetsAt: sevenDay.resets_at,
} satisfies StoreUsageLimitsMessage).catch(() => { /* non-critical */ });
return fiveHour.utilization;
const capturedAt = Date.now();
const limits: UsageLimitsData = {
fiveHour: { utilization: fiveHour.utilization, resetsAt: fiveHour.resets_at },
sevenDay: { utilization: sevenDay.utilization, resetsAt: sevenDay.resets_at },
capturedAt,
};
return computeUsageBudget(limits, capturedAt);
} catch {
// Network errors are silently ignored; the dashboard shows stale data.
return null;
Expand Down Expand Up @@ -281,11 +290,15 @@ async function initializeMonitoring(): Promise<void> {
}

// Fetch usage limits now that we have the org ID. Populates the
// Usage Budget card in the side panel immediately on load.
// Capture the returned utilization as the initial before-snapshot
// so the first STREAM_COMPLETE can compute a delta.
fetchAndStoreUsageLimits(currentOrgId).then(u => {
if (u !== null) lastKnownUtilization = u;
// Usage Budget card in the side panel and weekly bar on the overlay.
// Capture sessionPct as the initial before-snapshot so the first
// STREAM_COMPLETE can compute a delta.
fetchAndStoreUsageLimits(currentOrgId).then(budget => {
if (budget !== null) {
lastKnownUtilization = budget.sessionPct;
state = applyUsageBudget(state, budget);
overlay.render(state);
}
});

// Pre-fetch token economics for the Delta Coach and Prompt Agent.
Expand Down Expand Up @@ -453,7 +466,8 @@ async function initializeMonitoring(): Promise<void> {

// fetchAndStoreUsageLimits catches all errors internally; it never
// throws. The .then() always runs.
fetchAndStoreUsageLimits(orgId).then(utilizationAfter => {
fetchAndStoreUsageLimits(orgId).then(budgetAfter => {
const utilizationAfter = budgetAfter?.sessionPct ?? null;
// Update the cached value so the next STREAM_COMPLETE has a
// fresh before-snapshot. Only update on valid (non-null) reads.
if (utilizationAfter !== null) {
Expand All @@ -472,11 +486,19 @@ async function initializeMonitoring(): Promise<void> {
deltaUtilization = utilizationAfter - utilizationBefore;
}

// Apply fresh budget to overlay state. Combine with delta update in
// one state object so the render below covers both changes.
if (budgetAfter !== null) {
state = applyUsageBudget(state, budgetAfter);
}

// Update overlay immediately with the exact session cost for
// this reply. Re-render so the user sees "X.X% of session" the
// moment the usage endpoint responds (typically < 200ms post-stream).
if (deltaUtilization !== null) {
state = { ...state, lastDeltaUtilization: deltaUtilization };
}
if (budgetAfter !== null || deltaUtilization !== null) {
overlay.render(state);
}

Expand Down
14 changes: 13 additions & 1 deletion lib/overlay-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// The content script calls these and passes the result to overlay.render().

import { calculateCost, getContextWindowSize } from './pricing';
import type { TabState } from './message-types';
import type { TabState, UsageBudgetResult } from './message-types';
import type { HealthScore } from './health-score';
import type { ConversationRecord } from './conversation-store';
import type { PreSubmitEstimate } from './pre-submit';
Expand Down Expand Up @@ -41,6 +41,12 @@ export interface OverlayState {
* Set by the compose box observer or the pre-send fallback in inject.ts.
*/
draftEstimate: PreSubmitEstimate | null;
/**
* Weekly cap utilization derived from /api/organizations/{orgId}/usage.
* Null until the first successful fetchAndStoreUsageLimits call, or when
* the extension is running outside of claude.ai (no usage endpoint available).
*/
usageBudget: UsageBudgetResult | null;
}

export const INITIAL_STATE: Readonly<OverlayState> = {
Expand All @@ -53,6 +59,7 @@ export const INITIAL_STATE: Readonly<OverlayState> = {
health: null,
lastDeltaUtilization: null,
draftEstimate: null,
usageBudget: null,
};


Expand Down Expand Up @@ -168,3 +175,8 @@ export function applyDraftEstimate(state: OverlayState, estimate: PreSubmitEstim
export function clearDraftEstimate(state: OverlayState): OverlayState {
return { ...state, draftEstimate: null };
}

/** Apply a fresh UsageBudgetResult. Called after every fetchAndStoreUsageLimits call. */
export function applyUsageBudget(state: OverlayState, budget: UsageBudgetResult): OverlayState {
return { ...state, usageBudget: budget };
}
58 changes: 57 additions & 1 deletion tests/unit/overlay-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import {
applyHealthBroken,
applyHealthRecovered,
applyMessageLimit,
applyUsageBudget,
} from '../../lib/overlay-state';
import type { OverlayState } from '../../lib/overlay-state';
import type { TabState } from '../../lib/message-types';
import type { TabState, UsageBudgetResult } from '../../lib/message-types';

const MODEL = 'claude-haiku-4-5';
const TOKEN_PAYLOAD = { inputTokens: 1000, outputTokens: 200, model: MODEL };
Expand Down Expand Up @@ -265,6 +266,61 @@ describe('lastDeltaUtilization spread semantics', () => {
});
});

// ── applyUsageBudget ──────────────────────────────────────────────────────────

function makeBudget(weeklyPct: number, zone: UsageBudgetResult['zone'] = 'comfortable'): UsageBudgetResult {
return {
sessionPct: 10,
weeklyPct,
sessionMinutesUntilReset: 120,
weeklyResetLabel: 'Wed 9:00 AM',
zone,
statusLabel: `10% used; resets in 2h`,
};
}

describe('applyUsageBudget', () => {
it('sets usageBudget on state', () => {
const budget = makeBudget(71, 'moderate');
const next = applyUsageBudget(INITIAL_STATE, budget);
expect(next.usageBudget).toBe(budget);
});

it('overwrites a previous usageBudget value', () => {
const first = makeBudget(30, 'comfortable');
const second = makeBudget(85, 'tight');
const state = applyUsageBudget(INITIAL_STATE, first);
const next = applyUsageBudget(state, second);
expect(next.usageBudget?.weeklyPct).toBe(85);
});

it('does not mutate other fields', () => {
const budget = makeBudget(50);
const next = applyUsageBudget(INITIAL_STATE, budget);
expect(next.streaming).toBe(INITIAL_STATE.streaming);
expect(next.lastRequest).toBe(INITIAL_STATE.lastRequest);
expect(next.messageLimitUtilization).toBe(INITIAL_STATE.messageLimitUtilization);
});

it('INITIAL_STATE has null usageBudget', () => {
expect(INITIAL_STATE.usageBudget).toBeNull();
});

it('is preserved through applyTokenBatch', () => {
const budget = makeBudget(71, 'moderate');
const state: OverlayState = { ...INITIAL_STATE, usageBudget: budget };
const next = applyTokenBatch(state, TOKEN_PAYLOAD);
expect(next.usageBudget).toBe(budget);
});

it('is preserved through applyStreamComplete', () => {
const budget = makeBudget(91, 'critical');
const state: OverlayState = { ...INITIAL_STATE, usageBudget: budget };
const next = applyStreamComplete(state, TOKEN_PAYLOAD);
expect(next.usageBudget).toBe(budget);
});
});

// ── state immutability (cross-function) ───────────────────────────────────────

describe('immutability', () => {
Expand Down
Loading
Loading