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
2 changes: 2 additions & 0 deletions .claude/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ Quick reference for anyone starting with Claude on this project. Updated by the
- **Banner dismiss state uses localStorage** (prefix `openhuman:upsell:`), not Redux — consistent with CLAUDE.md exception for ephemeral UI state.
- **Phased rollout** — Phase 1 = banners + limit modal + hook. Phase 2 = onboarding upsell + analytics. Phase 3 = remote config + A/B testing.
- **"5-hour" label stragglers in Conversations.tsx** — `LimitPill` label and its hover tooltip still say "5h" / "5-hour". Commit 8c52236's "10-hour" terminology refactor missed those two spots.
- **`getTeamUsage()` now normalizes via `normalizeTeamUsage()`** — Added in issue #482. The Rust sidecar passes backend JSON through opaquely (`src/openhuman/team/ops.rs`), so the TS client must normalize field names and types. Pattern matches existing `normalizeCreditBalance()` in the same file. Any new billing API that returns raw backend data should follow the same normalize-at-the-client pattern.
- **Two separate `TeamUsage` types exist** — `creditsApi.ts:24` (billing: cycle budget, limits) and `types/team.ts:11` (team model: daily token limit). Different import paths, no collision, but confusing.

## Settings & Skills Reorganization (Issue #396)

Expand Down
43 changes: 28 additions & 15 deletions app/src/components/settings/panels/BillingPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,21 +88,34 @@ const BillingPanel = () => {
// Fetch current plan, credits balance, and team usage on mount
useEffect(() => {
setIsLoadingCredits(true);
Promise.all([billingApi.getCurrentPlan(), creditsApi.getBalance(), creditsApi.getTeamUsage()])
.then(([plan, balance, usage]) => {
log(
'[load] plan=%s active=%s weeklyBudget=%s',
plan.plan,
plan.hasActiveSubscription,
plan.weeklyBudgetUsd
);
setCurrentPlan(plan);
setCreditBalance(balance);
setTeamUsage(usage);
})
.catch(error => {
log('[load] failed: %O', error);
console.error(error);
Promise.allSettled([
billingApi.getCurrentPlan(),
creditsApi.getBalance(),
creditsApi.getTeamUsage(),
])
.then(([planResult, balanceResult, usageResult]) => {
if (planResult.status === 'fulfilled') {
const plan = planResult.value;
log(
'[load] plan=%s active=%s weeklyBudget=%s',
plan.plan,
plan.hasActiveSubscription,
plan.weeklyBudgetUsd
);
setCurrentPlan(plan);
} else {
log('[load] getCurrentPlan failed: %O', planResult.reason);
}
if (balanceResult.status === 'fulfilled') {
setCreditBalance(balanceResult.value);
} else {
log('[load] getBalance failed: %O', balanceResult.reason);
}
if (usageResult.status === 'fulfilled') {
setTeamUsage(usageResult.value);
} else {
log('[load] getTeamUsage failed: %O', usageResult.reason);
}
})
.finally(() => setIsLoadingCredits(false));
}, []);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const InferenceBudget = ({ teamUsage, isLoadingCredits }: InferenceBudgetProps)
{teamUsage && !isLoadingCredits && (
<span className="text-xs text-stone-400">
{teamUsage.cycleBudgetUsd > 0
? `$${teamUsage.remainingUsd.toFixed(2)} / $${teamUsage.cycleBudgetUsd.toFixed(2)} remaining`
? `$${(teamUsage.remainingUsd ?? 0).toFixed(2)} / $${(teamUsage.cycleBudgetUsd ?? 0).toFixed(2)} remaining`
: 'No recurring plan budget'}
</span>
)}
Expand All @@ -40,8 +40,8 @@ const InferenceBudget = ({ teamUsage, isLoadingCredits }: InferenceBudgetProps)
</div>
<div className="mt-1 flex items-center justify-between">
<span className="text-[11px] text-stone-500">
5-hour cap: ${teamUsage.cycleLimit5hr.toFixed(2)} / $
{teamUsage.fiveHourCapUsd.toFixed(2)}
5-hour cap: ${(teamUsage.cycleLimit5hr ?? 0).toFixed(2)} / $
{(teamUsage.fiveHourCapUsd ?? 0).toFixed(2)}
</span>
<span className="text-[11px] text-stone-500">
Cycle ends {new Date(teamUsage.cycleEndsAt).toLocaleDateString('en-US')}
Expand Down
8 changes: 4 additions & 4 deletions app/src/pages/Conversations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1290,8 +1290,8 @@ const Conversations = () => {
<div className="flex items-center justify-between gap-4">
<span className="text-stone-400">5-hour limit</span>
<span>
${teamUsage.cycleLimit5hr.toFixed(2)} / $
{teamUsage.fiveHourCapUsd.toFixed(2)}
${(teamUsage.cycleLimit5hr ?? 0).toFixed(2)} / $
{(teamUsage.fiveHourCapUsd ?? 0).toFixed(2)}
{teamUsage.fiveHourResetsAt && (
<span className="text-stone-400 ml-1">
— resets {formatResetTime(teamUsage.fiveHourResetsAt)}
Expand All @@ -1303,8 +1303,8 @@ const Conversations = () => {
<div className="flex items-center justify-between gap-4">
<span className="text-stone-400">Weekly limit</span>
<span>
${teamUsage.remainingUsd.toFixed(2)} / $
{teamUsage.cycleBudgetUsd.toFixed(2)} left
${(teamUsage.remainingUsd ?? 0).toFixed(2)} / $
{(teamUsage.cycleBudgetUsd ?? 0).toFixed(2)} left
{teamUsage.cycleEndsAt && (
<span className="text-stone-400 ml-1">
— resets {formatResetTime(teamUsage.cycleEndsAt)}
Expand Down
76 changes: 75 additions & 1 deletion app/src/services/api/__tests__/creditsApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ vi.mock('../../coreCommandClient', () => ({
callCoreCommand: (...args: unknown[]) => mockCallCoreCommand(...args),
}));

const { creditsApi, normalizeCouponRedeemResult, normalizeRedeemedCoupon } =
const { creditsApi, normalizeCouponRedeemResult, normalizeRedeemedCoupon, normalizeTeamUsage } =
await import('../creditsApi');

describe('creditsApi coupon helpers', () => {
Expand Down Expand Up @@ -103,3 +103,77 @@ describe('creditsApi coupon helpers', () => {
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.billing_get_coupons');
});
});

describe('normalizeTeamUsage', () => {
it('passes through well-formed camelCase fields', () => {
const input = {
remainingUsd: 12.5,
cycleBudgetUsd: 25,
cycleLimit5hr: 3.2,
cycleLimit7day: 18,
fiveHourCapUsd: 5,
fiveHourResetsAt: '2026-04-09T18:00:00Z',
cycleStartDate: '2026-04-07T00:00:00Z',
cycleEndsAt: '2026-04-14T00:00:00Z',
bypassCycleLimit: false,
};
expect(normalizeTeamUsage(input)).toEqual(input);
});

it('maps snake_case backend fields to camelCase', () => {
const result = normalizeTeamUsage({
remaining_usd: 10,
cycle_budget_usd: 20,
five_hour_spend_usd: 2.5,
cycle_limit_7day: 15,
five_hour_cap_usd: 5,
five_hour_resets_at: '2026-04-09T18:00:00Z',
cycle_start_date: '2026-04-07T00:00:00Z',
cycle_ends_at: '2026-04-14T00:00:00Z',
bypass_cycle_limit: true,
});
expect(result.remainingUsd).toBe(10);
expect(result.cycleBudgetUsd).toBe(20);
expect(result.cycleLimit5hr).toBe(2.5);
expect(result.cycleLimit7day).toBe(15);
expect(result.fiveHourCapUsd).toBe(5);
expect(result.fiveHourResetsAt).toBe('2026-04-09T18:00:00Z');
expect(result.bypassCycleLimit).toBe(true);
});

it('maps legacy fiveHourSpendUsd to cycleLimit5hr', () => {
const result = normalizeTeamUsage({ fiveHourSpendUsd: 4.0 });
expect(result.cycleLimit5hr).toBe(4.0);
});

it('returns safe defaults for empty object', () => {
const result = normalizeTeamUsage({});
expect(result.remainingUsd).toBe(0);
expect(result.cycleBudgetUsd).toBe(0);
expect(result.cycleLimit5hr).toBe(0);
expect(result.cycleLimit7day).toBe(0);
expect(result.fiveHourCapUsd).toBe(0);
expect(result.fiveHourResetsAt).toBeNull();
expect(result.bypassCycleLimit).toBe(false);
expect(typeof result.cycleStartDate).toBe('string');
expect(typeof result.cycleEndsAt).toBe('string');
});

it('does not crash on null or undefined input', () => {
expect(() => normalizeTeamUsage(null)).not.toThrow();
expect(() => normalizeTeamUsage(undefined)).not.toThrow();
const result = normalizeTeamUsage(null);
expect(result.remainingUsd).toBe(0);
expect(result.cycleLimit5hr).toBe(0);
});

it('getTeamUsage normalizes the RPC response', async () => {
mockCallCoreCommand.mockResolvedValueOnce({ remaining_usd: 8, cycle_budget_usd: 25 });

const result = await creditsApi.getTeamUsage();
expect(result.remainingUsd).toBe(8);
expect(result.cycleBudgetUsd).toBe(25);
expect(result.cycleLimit5hr).toBe(0);
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.team_get_usage');
});
});
32 changes: 31 additions & 1 deletion app/src/services/api/creditsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,35 @@ function normalizeCreditBalance(payload: unknown): CreditBalance {
};
}

export function normalizeTeamUsage(payload: unknown): TeamUsage {
const raw = (payload && typeof payload === 'object' ? payload : {}) as Record<string, unknown>;
return {
remainingUsd: normalizeUsd(raw.remainingUsd ?? raw.remaining_usd),
cycleBudgetUsd: normalizeUsd(raw.cycleBudgetUsd ?? raw.cycle_budget_usd),
cycleLimit5hr: normalizeUsd(
raw.cycleLimit5hr ?? raw.fiveHourSpendUsd ?? raw.five_hour_spend_usd
),
cycleLimit7day: normalizeUsd(raw.cycleLimit7day ?? raw.cycle_limit_7day),
fiveHourCapUsd: normalizeUsd(raw.fiveHourCapUsd ?? raw.five_hour_cap_usd),
fiveHourResetsAt: asStringOrNull(raw.fiveHourResetsAt ?? raw.five_hour_resets_at),
cycleStartDate:
typeof raw.cycleStartDate === 'string'
? raw.cycleStartDate
: typeof raw.cycle_start_date === 'string'
? raw.cycle_start_date
: new Date().toISOString(),
cycleEndsAt:
typeof raw.cycleEndsAt === 'string'
? raw.cycleEndsAt
: typeof raw.cycle_ends_at === 'string'
? raw.cycle_ends_at
: new Date().toISOString(),
bypassCycleLimit: Boolean(
raw.bypassCycleLimit ?? raw.bypassRateLimit ?? raw.bypass_cycle_limit
),
};
}

/**
* Credits API endpoints
*/
Expand All @@ -227,7 +256,8 @@ export const creditsApi = {
* GET /teams/me/usage
*/
getTeamUsage: async (): Promise<TeamUsage> => {
return await callCoreCommand<TeamUsage>('openhuman.team_get_usage');
const result = await callCoreCommand<TeamUsage>('openhuman.team_get_usage');
return normalizeTeamUsage(result);
},

/**
Expand Down
Loading