diff --git a/src/app/cloud-models/admin-only.tsx b/src/app/cloud-models/admin-only.tsx deleted file mode 100644 index d461457..0000000 --- a/src/app/cloud-models/admin-only.tsx +++ /dev/null @@ -1,22 +0,0 @@ -'use client'; - -/** - * TEMP: cloud models are superuser-only in the API until launch - * (REMOTE_MODELS_SUPERUSER_ONLY), so these pages are too. Anyone else who - * lands on one by URL goes back to the dashboard. Remove at launch. - * - * While the user is still loading the children render, so the page can show - * its headings. They carry no data: the API refuses non-admins anyway. - */ - -import { useEffect, type ReactNode } from 'react'; -import { useRouter } from 'next/navigation'; - -export default function AdminOnly({ user, children }: { user: any; children: ReactNode }) { - const router = useRouter(); - const denied = !!user && !user.admin; - useEffect(() => { - if (denied) router.replace('/dashboard'); - }, [denied, router]); - return denied ? null : <>{children}; -} diff --git a/src/app/cloud-models/history-section.tsx b/src/app/cloud-models/history-section.tsx index fcf3f16..dde39ff 100644 --- a/src/app/cloud-models/history-section.tsx +++ b/src/app/cloud-models/history-section.tsx @@ -1,9 +1,8 @@ '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 @@ -11,11 +10,10 @@ * * 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. @@ -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', @@ -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 = { - 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) => @@ -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 (
@@ -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; @@ -162,21 +150,18 @@ 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; @@ -184,15 +169,15 @@ export default function CloudHistorySection({ user }: { user: any }) { 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 (
@@ -234,7 +219,7 @@ export default function CloudHistorySection({ user }: { user: any }) { - No cloud model calls in the last {loadedDays} days. + No cloud sessions in the last {loadedDays} days. } /> @@ -245,19 +230,14 @@ export default function CloudHistorySection({ user }: { user: any }) { - Time - Model - {showProvider && Provider} - In - Out - {showReasoning && Reasoning} + Settled {showCost && Credits} + {showUsd && Upstream} {entries.slice(0, shown).map((e) => { const isOpen = open === e.id; - const estimated = e.estimated ? ESTIMATED : undefined; return ( {when(e.created_at)} - {e.model} - {showProvider && ( - - {e.upstream_provider ?? e.provider ?? '—'} + {showCost && ( + + {e.cost == null ? '—' : credits(e.cost)} )} - - {e.estimated && '~'}{count(e.prompt_tokens)} - - - {e.estimated && '~'}{count(e.completion_tokens)} - - {showReasoning && ( + {showUsd && ( - {e.reasoning_tokens != null ? count(e.reasoning_tokens) : '—'} - {e.reasoning_effort && · {e.reasoning_effort}} - - )} - {showCost && ( - - {e.cost === 0 ? Free : e.cost == null ? '—' : `${e.estimated ? '~' : ''}${credits(e.cost)}`} + {e.upstream_cost_micros == null ? '—' : usd(e.upstream_cost_micros)} )} @@ -312,13 +276,11 @@ export default function CloudHistorySection({ user }: { user: any }) { - - {count(entries.length)} calls + + {count(entries.length)} {entries.length === 1 ? 'session' : 'sessions'} - {count(totals.input)} - {count(totals.output)} - {showReasoning && {count(totals.reasoning)}} {showCost && {credits(totals.cost)}} + {showUsd && {usd(totals.usd)}} diff --git a/src/app/cloud-models/layout.tsx b/src/app/cloud-models/layout.tsx index 9e5f8d8..aa414a5 100644 --- a/src/app/cloud-models/layout.tsx +++ b/src/app/cloud-models/layout.tsx @@ -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, @@ -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 ( - - {children} - + {children} ); } diff --git a/src/app/cloud-models/shared.ts b/src/app/cloud-models/shared.ts index 6928c4b..2cf7137 100644 --- a/src/app/cloud-models/shared.ts +++ b/src/app/cloud-models/shared.ts @@ -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'; @@ -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; @@ -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; } @@ -91,8 +77,8 @@ export const PLAN_LABEL: Record = { 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'; diff --git a/src/app/cloud-models/usage-section.tsx b/src/app/cloud-models/usage-section.tsx index 34cc5a2..c29552f 100644 --- a/src/app/cloud-models/usage-section.tsx +++ b/src/app/cloud-models/usage-section.tsx @@ -1,20 +1,19 @@ 'use client'; /** - * Cloud model usage: how much of this month's credits are spent, what they - * were spent on, and which cloud models the account can run. + * Cloud model usage: how much of this month's credits are spent, how many + * sessions spent them, and which cloud models the account can run. * * Laid out like the credits menu in ScreenForge's agent panel: the balance - * first with the plan beside it, then what it is out of, then a bar. Each - * model shows its rate, so the cheaper ones are easy to pick. An unmetered - * (admin) account gets the same row with what it used and "Unlimited". Until - * the API sends credit counts to every account, the row falls back to - * percentages. + * first with the plan beside it, then what it is out of, then a bar. An + * unmetered (admin) account gets the same row with what it used and + * "Unlimited". Until the API sends credit counts to every account, the row + * falls back to percentages. * * GET /v1/chat/status carries the quota and, when cloud models can't run, why * not; the copy follows that reason rather than re-deriving it from numbers. - * Models rated at 0 credits are never blocked, so "out of credits" does not mean - * cloud models stop entirely; the copy says so. + * A session can't start without credits to hold, so "out of credits" stops + * every cloud model until the reset. * * Cloud models are a Pro feature: there are no free credits, so a free * account gets the case for upgrading and the catalog, with nothing to meter. @@ -59,15 +58,6 @@ function Pending() { return ; } -function Stat({ label, value }: { label: string; value: string }) { - return ( - -
{label}
-
{value}
-
- ); -} - /** The plan beside the balance, as in the desktop app's credits menu. */ function PlanBadge({ label, accent }: { label: string; accent?: boolean }) { return ( @@ -123,7 +113,7 @@ function CreditsRow({ status }: { status: CloudStatus }) { return ( } /> @@ -132,7 +122,7 @@ function CreditsRow({ status }: { status: CloudStatus }) { } const used = quota.percent_used; - // What calls in flight hold sits between settled spend and what's left. + // What open sessions hold sits between settled spend and what's left. const held = Math.max(0, 100 - quota.percent_available - used); const spent = reason === 'quota_exceeded'; const low = reason === 'quota_low'; @@ -175,17 +165,18 @@ function CreditsRow({ status }: { status: CloudStatus }) {
{quota.in_flight > 0 && (

- {quota.in_flight === 1 ? 'A call is still running. ' : `${count(quota.in_flight)} calls are still running. `} + {quota.in_flight === 1 ? 'A cloud session is open. ' : `${count(quota.in_flight)} cloud sessions are open. `} The lighter part of the bar{quota.reserved ? ` (${credits(quota.reserved)} credits)` : ''} is held for{' '} - {quota.in_flight === 1 ? 'it until it finishes' : 'them until they finish'}. + {quota.in_flight === 1 ? 'it' : 'them'} until {quota.in_flight === 1 ? 'it ends' : 'they end'}; only what + was used is charged, and the rest comes back.

)} {(spent || low) && (

{spent - ? 'Paid cloud models are paused until the reset.' - : 'There are too few credits left for another call to a paid cloud model, so they are paused until the reset.'}{' '} - Free cloud models and everything that runs on your device keep working. + ? 'Cloud models are paused until the reset.' + : 'There are too few credits left for another cloud session, so cloud models are paused until the reset.'}{' '} + Everything that runs on your device keeps working.

)} @@ -193,16 +184,6 @@ function CreditsRow({ status }: { status: CloudStatus }) { ); } -/** "12 / 210 credits per 1M tokens in / out". Absolute, not relative to a - * default model: input and output rates rank the models differently. */ -function rateLabel(m: ModelEntry): string | null { - const input = m.input_credits_per_mtok; - const output = m.output_credits_per_mtok; - if (input == null || output == null) return null; - if (input === 0 && output === 0) return 'Free: uses no credits'; - return `${count(input)} / ${count(output)} credits per 1M tokens in / out`; -} - /** "131K" / "900K": token limits read at a glance. */ const tokensShort = (n: number) => n >= 1000 ? `${Math.round(n / 1000).toLocaleString()}K` : n.toLocaleString(); @@ -252,7 +233,7 @@ function ModelList({ models, locked }: { models: ModelEntry[]; locked: boolean } {m.context_length ? ` · ${contextLabel(m.context_length)}` : ''} {m.access_level === 'internal' ? ' · Internal' : ''} - {[rateLabel(m), limitsLabel(m), reasoningLabel(m)].filter(Boolean).map((line) => ( + {[limitsLabel(m), reasoningLabel(m)].filter(Boolean).map((line) => (
{line}
))} {(m.capabilities?.length ?? 0) > 0 && ( @@ -322,7 +303,6 @@ export default function CloudUsageSection({ user }: { user: any }) { const ledger = useLoad(user, () => fetchLedger(14)); const models = useLoad(user, fetchModels); - const quota = status?.quota; const failed = status === null || ledger === null || models === null; // Free accounts get no credits: cloud models are a Pro feature. Until status @@ -335,7 +315,7 @@ export default function CloudUsageSection({ user }: { user: any }) { return (
{needsPro && ( : status === undefined && )} - {!needsPro && status !== null && ( - - - - - - - - )} - {!needsPro && ( - Last 14 days, by model. Every call is itemized in{' '} + Last 14 days. Every session and what it cost is itemized in{' '} - Call History + Session History . @@ -378,9 +348,9 @@ export default function CloudUsageSection({ user }: { user: any }) { stacked > ({ timestamp: e.created_at, action: e.model }))} - labelFor={(name) => models?.find((m) => m.name === name)?.display_name || name} - emptyText="No cloud model calls in the last 14 days." + entries={(ledger ?? []).map((e) => ({ timestamp: e.created_at, action: 'session' }))} + labelFor={() => 'Sessions'} + emptyText="No cloud sessions in the last 14 days." loading={ledger === undefined} /> diff --git a/src/app/components/activity-chart.tsx b/src/app/components/activity-chart.tsx index 8ea3fb8..8922753 100644 --- a/src/app/components/activity-chart.tsx +++ b/src/app/components/activity-chart.tsx @@ -50,8 +50,8 @@ const defaultLabel = (action: string) => export default function ActivityChart({ entries, - // The Cloud Models page charts calls per model, where the "action" is a - // catalog name that must be shown as written. + // Callers whose "action" isn't an access-log category name it themselves + // (the Cloud Models page charts sessions). labelFor = defaultLabel, emptyText = `No recorded activity in the last ${DAYS} days.`, // Draw the empty frame instead of the empty sentence, so the chart holds its diff --git a/src/app/components/app-shell.tsx b/src/app/components/app-shell.tsx index 88dc6a2..4619612 100644 --- a/src/app/components/app-shell.tsx +++ b/src/app/components/app-shell.tsx @@ -33,7 +33,6 @@ const SIDEBAR_WIDTH = 248; // it does not need a header of its own. const NAV_GROUPS: { heading: string | null; - adminOnly?: boolean; items: { label: string; path: string; icon: typeof TravelExploreIcon }[]; }[] = [ { @@ -45,11 +44,9 @@ const NAV_GROUPS: { }, { heading: 'Cloud Models', - // TEMP: cloud models are superuser-only in the API until launch. - adminOnly: true, items: [ { label: 'Usage', path: '/cloud-models/usage', icon: CloudOutlinedIcon }, - { label: 'Call History', path: '/cloud-models/history', icon: HistoryIcon }, + { label: 'Session History', path: '/cloud-models/history', icon: HistoryIcon }, ], }, { @@ -66,7 +63,7 @@ const NAV_GROUPS: { }, ]; -function SidebarContent({ isAdmin, onNavigate }: { isAdmin: boolean; onNavigate?: () => void }) { +function SidebarContent({ onNavigate }: { onNavigate?: () => void }) { const router = useRouter(); const pathname = usePathname(); const { theme, toggleTheme } = useTheme(); @@ -83,7 +80,7 @@ function SidebarContent({ isAdmin, onNavigate }: { isAdmin: boolean; onNavigate? - {NAV_GROUPS.filter((g) => isAdmin || !g.adminOnly).map((group, gi) => ( + {NAV_GROUPS.map((group, gi) => ( {group.heading && (
@@ -145,7 +142,7 @@ export default function AppShell({ user, children }: { user?: any; children: Rea className="hidden md:block fixed inset-y-0 left-0 z-30" sx={{ width: SIDEBAR_WIDTH, borderRight: '1px solid var(--border)', bgcolor: 'var(--surface)' }} > - + {/* Mobile drawer */} @@ -161,7 +158,7 @@ export default function AppShell({ user, children }: { user?: any; children: Rea className="md:hidden fixed inset-y-0 left-0 z-50" sx={{ width: SIDEBAR_WIDTH, borderRight: '1px solid var(--border)', bgcolor: 'var(--surface)' }} > - setDrawerOpen(false)} /> + setDrawerOpen(false)} /> )}