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
22 changes: 0 additions & 22 deletions src/app/cloud-models/admin-only.tsx

This file was deleted.

102 changes: 32 additions & 70 deletions src/app/cloud-models/history-section.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
'use client';

/**
* Call History: the account's view of the cloud ledger, one row per cloud
* model call — when, which model, who served it, tokens in, out and spent
* reasoning, and the credits it cost.
* Session History: the account's view of the cloud ledger, one row per cloud
* session — when it was settled and the credits it cost.
*
* No project column: projects are local to the desktop app, and the cloud
* only ever sees an opaque project id it cannot name. Per-project cloud usage
* belongs in the desktop app, which can resolve those ids itself.
*
* The ledger is a billing record, so this page is itemized rather than
* summarized and can be exported. The API decides what each account may see
* (provider, cost, …) by leaving fields out, so the page shows whatever it
* sends: the Provider, Reasoning and Credits columns appear when any row has
* them, a row opens to list every field it came with, and the export has a
* column for every field. A call whose usage the provider never reported has
* estimated tokens (and so estimated credits), marked as such.
* by leaving fields out, so the page shows whatever it sends: the Credits and
* USD columns appear when any row has them, a row
* opens to list every field it came with, and the export has a column for
* every field.
*
* GET /v1/chat/ledger returns the whole range in one response, so rows are
* paged here, in the browser.
Expand All @@ -34,8 +32,6 @@ import { type LedgerEntry, count, credits, fetchLedger } from './shared';
const RANGES = [7, 30, 90] as const;
const PAGE = 50;

const ESTIMATED = 'Estimated: the provider did not report usage for this call';

const when = (iso: string) =>
new Date(iso).toLocaleString(undefined, {
month: 'short',
Expand All @@ -47,24 +43,14 @@ const when = (iso: string) =>
/** Labels for the fields a row's details list; any other field gets its key,
* de-snaked. Listed in this order, then the rest as the API sent them. */
const FIELD_LABEL: Record<string, string> = {
created_at: 'Time',
model: 'Model',
upstream_provider: 'Served by',
provider: 'Provider',
provider_model: 'Provider model',
streamed: 'Streamed',
prompt_tokens: 'Input tokens',
completion_tokens: 'Output tokens',
reasoning_tokens: 'Reasoning tokens',
reasoning_effort: 'Reasoning effort',
created_at: 'Settled',
cost: 'Credits',
input_credits_per_mtok: 'Input rate (credits / 1M)',
output_credits_per_mtok: 'Output rate (credits / 1M)',
estimated: 'Estimated',
upstream_cost_micros: 'Upstream cost',
provider: 'Provider',
plan: 'Plan',
period: 'Period',
project_id: 'Project id',
id: 'Call id',
id: 'Session id',
};

const label = (key: string) =>
Expand Down Expand Up @@ -110,6 +96,8 @@ const has = (entries: LedgerEntry[], key: string) =>
const TH = 'font-medium py-2 px-4 first:pl-0 last:pr-0 whitespace-nowrap';
const TD = 'py-2.5 px-4 first:pl-0 last:pr-0';

const usd = (micros: number) => `$${(micros / 1_000_000).toFixed(micros < 10_000 ? 4 : 2)}`;

function Details({ entry }: { entry: LedgerEntry }) {
return (
<dl className="grid grid-cols-[max-content_1fr] gap-x-6 gap-y-1 text-xs py-3">
Expand Down Expand Up @@ -146,12 +134,12 @@ export default function CloudHistorySection({ user }: { user: any }) {
setLoadedDays(days);
setShown(PAGE);
} else {
setError('Could not load your call history.');
setError('Could not load your session history.');
}
})
.catch((err) => {
console.error('Failed to load cloud ledger:', err);
if (!cancelled) setError('Could not load your call history.');
if (!cancelled) setError('Could not load your session history.');
});
return () => {
cancelled = true;
Expand All @@ -162,37 +150,34 @@ export default function CloudHistorySection({ user }: { user: any }) {
() =>
(entries ?? []).reduce(
(t, e) => ({
input: t.input + e.prompt_tokens,
output: t.output + e.completion_tokens,
reasoning: t.reasoning + (e.reasoning_tokens ?? 0),
cost: t.cost + (e.cost ?? 0),
usd: t.usd + (e.upstream_cost_micros ?? 0),
}),
{ input: 0, output: 0, reasoning: 0, cost: 0 }
{ cost: 0, usd: 0 }
),
[entries]
);

const rows = entries ?? [];
const showProvider = has(rows, 'upstream_provider') || has(rows, 'provider');
const showReasoning = has(rows, 'reasoning_tokens') || has(rows, 'reasoning_effort');
const showCost = has(rows, 'cost');
const columns = 5 + Number(showProvider) + Number(showReasoning) + Number(showCost);
const showUsd = has(rows, 'upstream_cost_micros');
const columns = 2 + Number(showCost) + Number(showUsd);

const exportCsv = () => {
if (!entries?.length) return;
const blob = new Blob([toCsv(entries)], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `smartloop-cloud-calls-${loadedDays}d.csv`;
a.download = `smartloop-cloud-sessions-${loadedDays}d.csv`;
a.click();
URL.revokeObjectURL(url);
};

return (
<Section
title="Call History"
description="Every call your account made to a cloud model, newest first, with the tokens it used and the credits it cost. Open a call to see everything recorded for it."
title="Session History"
description="Every cloud model session your account used, newest first, with the credits it cost. A session is charged for what it used once it ends. Open one to see everything recorded for it."
>
<Row>
<Box className="flex items-center gap-2 flex-wrap">
Expand Down Expand Up @@ -234,7 +219,7 @@ export default function CloudHistorySection({ user }: { user: any }) {
<Row
description={
<span className={loading ? 'opacity-60' : ''}>
No cloud model calls in the last {loadedDays} days.
No cloud sessions in the last {loadedDays} days.
</span>
}
/>
Expand All @@ -245,19 +230,14 @@ export default function CloudHistorySection({ user }: { user: any }) {
<thead>
<tr className="text-faint">
<th className={`${TH} w-5`} aria-label="Details" />
<th className={`${TH} text-left`}>Time</th>
<th className={`${TH} text-left`}>Model</th>
{showProvider && <th className={`${TH} text-left`}>Provider</th>}
<th className={`${TH} text-right`}>In</th>
<th className={`${TH} text-right`}>Out</th>
{showReasoning && <th className={`${TH} text-right`}>Reasoning</th>}
<th className={`${TH} text-left`}>Settled</th>
{showCost && <th className={`${TH} text-right`}>Credits</th>}
{showUsd && <th className={`${TH} text-right`}>Upstream</th>}
</tr>
</thead>
<tbody>
{entries.slice(0, shown).map((e) => {
const isOpen = open === e.id;
const estimated = e.estimated ? ESTIMATED : undefined;
return (
<Fragment key={e.id}>
<tr
Expand All @@ -270,30 +250,14 @@ export default function CloudHistorySection({ user }: { user: any }) {
/>
</td>
<td className={`${TD} text-left text-muted whitespace-nowrap`}>{when(e.created_at)}</td>
<td className={`${TD} text-left text-content whitespace-nowrap font-mono text-xs`}>{e.model}</td>
{showProvider && (
<td
className={`${TD} text-left text-muted whitespace-nowrap`}
title={[e.provider, e.provider_model].filter(Boolean).join(' · ') || undefined}
>
{e.upstream_provider ?? e.provider ?? '—'}
{showCost && (
<td className={`${TD} text-right text-content tabular-nums whitespace-nowrap`}>
{e.cost == null ? '—' : credits(e.cost)}
</td>
)}
<td className={`${TD} text-right text-muted tabular-nums`} title={estimated}>
{e.estimated && '~'}{count(e.prompt_tokens)}
</td>
<td className={`${TD} text-right text-muted tabular-nums`} title={estimated}>
{e.estimated && '~'}{count(e.completion_tokens)}
</td>
{showReasoning && (
{showUsd && (
<td className={`${TD} text-right text-muted tabular-nums whitespace-nowrap`}>
{e.reasoning_tokens != null ? count(e.reasoning_tokens) : '—'}
{e.reasoning_effort && <span className="text-faint"> · {e.reasoning_effort}</span>}
</td>
)}
{showCost && (
<td className={`${TD} text-right text-content tabular-nums whitespace-nowrap`} title={estimated}>
{e.cost === 0 ? <span className="text-faint">Free</span> : e.cost == null ? '—' : `${e.estimated ? '~' : ''}${credits(e.cost)}`}
{e.upstream_cost_micros == null ? '—' : usd(e.upstream_cost_micros)}
</td>
)}
</tr>
Expand All @@ -312,13 +276,11 @@ export default function CloudHistorySection({ user }: { user: any }) {
<tfoot>
<tr className="border-t border-border-2 font-medium">
<td className={TD} />
<td className={`${TD} text-left text-content`} colSpan={showProvider ? 3 : 2}>
{count(entries.length)} calls
<td className={`${TD} text-left text-content`}>
{count(entries.length)} {entries.length === 1 ? 'session' : 'sessions'}
</td>
<td className={`${TD} text-right text-content tabular-nums`}>{count(totals.input)}</td>
<td className={`${TD} text-right text-content tabular-nums`}>{count(totals.output)}</td>
{showReasoning && <td className={`${TD} text-right text-content tabular-nums`}>{count(totals.reasoning)}</td>}
{showCost && <td className={`${TD} text-right text-content tabular-nums`}>{credits(totals.cost)}</td>}
{showUsd && <td className={`${TD} text-right text-content tabular-nums`}>{usd(totals.usd)}</td>}
</tr>
</tfoot>
</table>
Expand Down
10 changes: 4 additions & 6 deletions src/app/cloud-models/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

/**
* The frame for every Cloud Models page. A layout stays mounted while you move
* between its child routes, so going from Usage to Call History swaps only the
* section: the sidebar and header stay put and /users/me is not fetched again.
* between its child routes, so going from Usage to Session History swaps only
* the section: the sidebar and header stay put and /users/me is not fetched
* again.
*
* The frame and the pages render before the user has loaded. Each section
* shows its headings straight away and fills in its numbers as they arrive,
Expand All @@ -13,16 +14,13 @@
import type { ReactNode } from 'react';
import { useAuth } from '@/app/hooks/useAuth';
import SettingsShell from '@/app/settings/settings-shell';
import AdminOnly from '@/app/cloud-models/admin-only';
import { CloudUserContext } from '@/app/cloud-models/user-context';

export default function CloudModelsLayout({ children }: { children: ReactNode }) {
const [user] = useAuth();
return (
<SettingsShell user={user}>
<AdminOnly user={user}>
<CloudUserContext.Provider value={user}>{children}</CloudUserContext.Provider>
</AdminOnly>
<CloudUserContext.Provider value={user}>{children}</CloudUserContext.Provider>
</SettingsShell>
);
}
66 changes: 26 additions & 40 deletions src/app/cloud-models/shared.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
/**
* What the Cloud Models pages read from the API, and how they format it.
*
* Cloud (remote) models run on Smartloop's provider accounts behind
* POST /v1/chat/completions, so every call is metered against a monthly,
* per-plan allowance of credits and recorded in the cloud ledger
* (GET /v1/chat/ledger). Each model has a rate card in credits per million
* tokens; what that costs us upstream stays server-side.
* Cloud model use is metered in sessions. Each session holds part of the
* account's monthly allowance of credits while it is open; once it ends, what
* it used is charged and recorded as one row in the cloud ledger
* (GET /v1/chat/ledger). The ledger has no per-model or token detail: one
* charge per session.
*
* Users see credits: what is left of the month, what each call cost, and each
* model's rate. The API sends the month's credit counts to superusers only for
* now, so for everyone else they are null and the usage row falls back to
* percentages. Which upstream served a call and what it cost in USD are
* superuser-only too: the API sends them as null to everyone else, and the
* pages show a field only when it has a value.
* Users see credits: what is left of the month and what each session cost.
* Some accounts don't get the month's credit counts, so for them they are null
* and the usage row falls back to percentages. Other fields are withheld the
* same way: the API sends them as null, and the pages show a field only when
* it has a value.
*/

import { userService } from '@/app/services/user.service';
Expand All @@ -21,22 +20,21 @@ import { API_ENDPOINT } from '@/app/constants';
export interface CloudQuota {
/** Credits this month. null when unmetered (admin accounts) or withheld. */
limit: number | null;
/** Credits spent on settled calls. null when withheld. */
/** Credits charged for settled sessions. null when withheld. */
used: number | null;
/** Credits left. null when unmetered or withheld. */
remaining: number | null;
/** Credits held by calls in flight. null when withheld. */
/** Credits held by open sessions. null when withheld. */
reserved: number | null;
/** 0–100 of the month's credits spent on settled calls. null means
/** 0–100 of the month's credits charged for settled sessions. null means
* unmetered (admin accounts). */
percent_used: number | null;
/** 0–100 a new call can still use, after settled spend and what calls in
* flight are holding. null when unmetered. */
/** 0–100 a new session can still hold, after settled spend and what open
* sessions are holding. null when unmetered. */
percent_available: number | null;
/** Calls still running, each holding its worst case until it settles. */
/** Open sessions, each holding its budget until it is settled. */
in_flight: number;
prompt_tokens: number;
completion_tokens: number;
/** Sessions settled this month. */
requests: number;
/** YYYY-MM. */
period: string;
Expand All @@ -53,32 +51,20 @@ export interface CloudStatus {
quota: CloudQuota;
}

/** One settled session: when it was charged and what it cost. */
export interface LedgerEntry {
id: string;
/** When the session was settled, after it ended. */
created_at: string;
/** Catalog name, e.g. gpt-oss-20b. */
model: string;
project_id: string | null;
streamed: boolean;
prompt_tokens: number;
completion_tokens: number;
/** The provider never reported usage for this call, so its tokens are our
* estimate from the text in and out. */
estimated: boolean;
/** Credits the call cost. */
/** Credits the session cost. */
cost: number | null;
period: string;
// Sent with a value only to accounts allowed to see them (the upstream and
// USD fields: superusers): shown when present, never assumed.
/** Who served the call through OpenRouter, e.g. "CoreWeave". */
upstream_provider?: string | null;
/** Our upstream (e.g. openrouter) and the model's id there. */
// Sent with a value only to accounts allowed to see them: shown when
// present, never assumed.
provider?: string | null;
provider_model?: string | null;
/** How many of completion_tokens went to reasoning, when reported. */
reasoning_tokens?: number | null;
/** The reasoning effort sent upstream. */
reasoning_effort?: string | null;
/** In micro-USD. */
upstream_cost_micros?: number | null;
/** Anything else the API adds to a row: listed in its details as is. */
[field: string]: unknown;
}
Expand All @@ -91,8 +77,8 @@ export const PLAN_LABEL: Record<CloudQuota['plan'], string> = {

export const count = (n: number) => n.toLocaleString();

/** A credit amount that may be a fraction: a small call costs well under one
* credit, so a fixed number of decimals would show most rows as 0. */
/** A credit amount that may be a fraction: a short session costs well under
* one credit, so a fixed number of decimals would show most rows as 0. */
export function credits(n: number): string {
if (n === 0) return '0';
if (n < 0.01) return '<0.01';
Expand Down
Loading
Loading