feat(agent): track session token costs (#4136)

This commit is contained in:
Steven Enamakel
2026-06-25 15:04:39 -07:00
committed by GitHub
parent 844fd60a7f
commit dbd1c0cd6f
49 changed files with 2255 additions and 155 deletions
@@ -0,0 +1,146 @@
import { configureStore } from '@reduxjs/toolkit';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { Provider } from 'react-redux';
import { describe, expect, it, vi } from 'vitest';
import chatRuntimeReducer, { recordChatTurnUsage } from '../../store/chatRuntimeSlice';
import ComposerTokenStats from './ComposerTokenStats';
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
function renderWithUsage(
payloads: Array<Parameters<typeof recordChatTurnUsage>[0]>,
props?: { model?: string | null; threadId?: string | null }
) {
const store = configureStore({ reducer: { chatRuntime: chatRuntimeReducer } });
for (const p of payloads) store.dispatch(recordChatTurnUsage(p));
return render(
<Provider store={store}>
<ComposerTokenStats {...props} />
</Provider>
);
}
const oneTurn = [
{
inputTokens: 1200,
outputTokens: 300,
cachedTokens: 50,
costUsd: 0.0123,
contextWindow: 200_000,
},
];
describe('<ComposerTokenStats />', () => {
it('renders nothing before any turn when no model is known', () => {
const { container } = renderWithUsage([]);
expect(container).toBeEmptyDOMElement();
});
it('renders the clickable context row before the first turn when a model is known', () => {
renderWithUsage([], { model: 'reasoning-v1' });
const row = screen.getByRole('button');
expect(row).toHaveTextContent('token.ctxLabel');
});
it('keeps the inline row minimal: context window and cost only', () => {
renderWithUsage(oneTurn, { model: 'reasoning-v1' });
const row = screen.getByRole('button');
// Context window uses the real reported window (200K), not just a default.
expect(row).toHaveTextContent('token.ctxLabel');
expect(row).toHaveTextContent('200K');
// Cost inline.
expect(row).toHaveTextContent('$0.012');
// Tokens (in/out) are NOT inline — they live in the popover.
expect(row).not.toHaveTextContent('token.inLabel');
expect(row).not.toHaveTextContent('token.outLabel');
// The model id is NOT inline (it lives in the popover).
expect(row).not.toHaveTextContent('reasoning-v1');
});
it('toggles the breakdown on click and shows explicit labelled rows + tooltips + model', () => {
renderWithUsage(oneTurn, { model: 'reasoning-v1' });
expect(screen.queryByTestId('composer-token-breakdown')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button'));
const bd = screen.getByTestId('composer-token-breakdown');
// Explicit, spelled-out labels with explanatory tooltips.
expect(within(bd).getByText('token.popInput')).toHaveAttribute('title', 'token.tipInput');
expect(within(bd).getByText('token.popOutput')).toHaveAttribute('title', 'token.tipOutput');
expect(within(bd).getByText('token.popCacheHit')).toHaveAttribute('title', 'token.tipCacheHit');
// Cache hit shows a hit-rate percentage: 50 cached / 1200 input ≈ 4%.
expect(within(bd).getByText(/50 \(4%\)/)).toBeInTheDocument();
// Model id surfaced inside the popover.
expect(within(bd).getByText('reasoning-v1')).toBeInTheDocument();
// Clicking again closes it.
fireEvent.click(screen.getByRole('button'));
expect(screen.queryByTestId('composer-token-breakdown')).not.toBeInTheDocument();
});
it('highlights the context segment while the breakdown is open', () => {
renderWithUsage(oneTurn);
const ctx = screen.getByText(/token\.ctxLabel/);
expect(ctx.className).not.toMatch(/bg-primary/);
fireEvent.click(screen.getByRole('button'));
expect(ctx.className).toMatch(/bg-primary/);
});
it('closes on Escape and on an outside click', () => {
renderWithUsage(oneTurn);
fireEvent.click(screen.getByRole('button'));
expect(screen.getByTestId('composer-token-breakdown')).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByTestId('composer-token-breakdown')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button'));
expect(screen.getByTestId('composer-token-breakdown')).toBeInTheDocument();
fireEvent.mouseDown(document.body);
expect(screen.queryByTestId('composer-token-breakdown')).not.toBeInTheDocument();
});
it('breaks down spend per agent: orchestrator (derived) + sub-agents', () => {
renderWithUsage([
{
inputTokens: 500,
outputTokens: 100,
costUsd: 0.01,
subAgents: [{ agentId: 'researcher', inputTokens: 200, outputTokens: 40, costUsd: 0.004 }],
},
]);
fireEvent.click(screen.getByRole('button'));
const bd = screen.getByTestId('composer-token-breakdown');
// Orchestrator = totals sub-agents: tokens 600240=360, cost 0.010.004=0.006.
expect(within(bd).getByText('token.orchestrator')).toBeInTheDocument();
expect(within(bd).getByText(/360/)).toBeInTheDocument();
expect(within(bd).getByText(/\$0\.006/)).toBeInTheDocument();
// Sub-agent row: 200 + 40 = 240 combined tokens, its own cost.
expect(within(bd).getByText('researcher')).toBeInTheDocument();
expect(within(bd).getByText(/240/)).toBeInTheDocument();
expect(within(bd).getByText(/\$0\.004/)).toBeInTheDocument();
});
it('reads the active thread bucket when a threadId is provided', () => {
// Two threads with different usage; the footer must reflect the selected one.
renderWithUsage(
[
{ inputTokens: 999, outputTokens: 999, costUsd: 0.5, threadId: 'thr-other' },
{ inputTokens: 1200, outputTokens: 300, costUsd: 0.0123, threadId: 'thr-active' },
],
{ threadId: 'thr-active' }
);
fireEvent.click(screen.getByRole('button'));
const bd = screen.getByTestId('composer-token-breakdown');
// Active thread's input tokens (1.2K), not the other thread's 999.
expect(within(bd).getByText('1.2K')).toBeInTheDocument();
expect(within(bd).queryByText('999')).not.toBeInTheDocument();
});
it('shows the orchestrator row and a no-sub-agents note when none ran', () => {
renderWithUsage([{ inputTokens: 100, outputTokens: 20, costUsd: 0.001 }]);
fireEvent.click(screen.getByRole('button'));
const bd = screen.getByTestId('composer-token-breakdown');
expect(within(bd).getByText('token.orchestrator')).toBeInTheDocument();
expect(within(bd).getByText('token.noSubAgents')).toBeInTheDocument();
});
});
+207 -56
View File
@@ -1,6 +1,11 @@
import { useT } from '../../lib/i18n/I18nContext';
import { useAppSelector } from '../../store/hooks';
import { useEffect, useRef, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { emptySessionTokenUsage, type SubAgentUsage } from '../../store/chatRuntimeSlice';
import { useAppSelector } from '../../store/hooks';
import Tooltip from '../ui/Tooltip';
/** Fallback context window when the core hasn't reported a real one yet. */
const DEFAULT_CONTEXT_WINDOW = 200_000;
function fmt(n: number): string {
@@ -10,75 +15,150 @@ function fmt(n: number): string {
return `${(n / 1_000_000).toFixed(1)}M`;
}
/** Format a USD cost compactly: sub-cent values keep more precision. */
function fmtUsd(n: number): string {
if (!Number.isFinite(n) || n <= 0) return '$0.00';
if (n < 0.01) return `$${n.toFixed(4)}`;
if (n < 1) return `$${n.toFixed(3)}`;
return `$${n.toFixed(2)}`;
}
function ok(n: number): boolean {
return Number.isFinite(n) && n > 0;
}
function dot() {
return <span className="text-content-faint dark:text-neutral-700">·</span>;
/**
* One labelled row in the hover breakdown. The label carries a `title` tooltip
* explaining the metric, hinted with a dotted underline + help cursor.
*/
function UsageRow({ label, tip, value }: { label: string; tip: string; value: string }) {
return (
<div className="flex justify-between gap-3">
<dt
title={tip}
className="cursor-help underline decoration-dotted decoration-content-faint underline-offset-2">
{label}
</dt>
<dd className="font-mono text-content">{value}</dd>
</div>
);
}
/** One agent's line in the per-agent breakdown: combined tokens · cost · runs. */
function AgentLine({
name,
tokens,
costUsd,
runs,
}: {
name: string;
tokens: number;
costUsd: number;
runs: number;
}) {
return (
<li className="flex items-center justify-between gap-3">
<span className="truncate text-content-secondary" title={name}>
{name}
</span>
<span className="whitespace-nowrap font-mono text-content">
{fmt(tokens)} · {fmtUsd(costUsd)} · {runs}×
</span>
</li>
);
}
interface ComposerTokenStatsProps {
/** Resolved model id, shown as the leading stat when present. */
/** Resolved model id, surfaced inside the breakdown popover. */
model?: string | null;
/**
* Active thread id. When set, the footer shows that thread's usage bucket
* (seeded from persisted transcripts + live turns); otherwise it falls back
* to the global app-session aggregate.
*/
threadId?: string | null;
}
export default function ComposerTokenStats({ model }: ComposerTokenStatsProps = {}) {
const EMPTY_USAGE = emptySessionTokenUsage();
export default function ComposerTokenStats({ model, threadId }: ComposerTokenStatsProps = {}) {
const { t } = useT();
const usage = useAppSelector(state => state.chatRuntime.sessionTokenUsage);
const usage = useAppSelector(state =>
threadId
? (state.chatRuntime.usageByThread[threadId] ?? EMPTY_USAGE)
: state.chatRuntime.sessionTokenUsage
);
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
// The breakdown is click-toggled (not hover). Dismiss on an outside click or
// Escape so it behaves like a popover rather than a sticky panel.
useEffect(() => {
if (!open) return;
const onPointerDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const inTok = usage.inputTokens || 0;
const outTok = usage.outputTokens || 0;
const cachedTok = usage.cachedTokens || 0;
const turns = usage.turns || 0;
const lastIn = usage.lastTurnInputTokens || 0;
const lastOut = usage.lastTurnOutputTokens || 0;
const costUsd = usage.costUsd || 0;
const subAgents: SubAgentUsage[] = Object.values(usage.subAgents ?? {});
// Still render when only the model is known (no turns yet) so the resolved
// model stays visible in the composer footer.
// Render as soon as a model is resolved (even before the first turn) so the
// clickable usage row is always present in the footer; the context segment
// shows 0% until the first turn reports usage.
if (turns === 0 && !model) return null;
const showIn = ok(inTok);
const showOut = ok(outTok);
const contextUsed = lastIn + lastOut;
const showCtx = ok(contextUsed);
const contextPct = showCtx
? Math.min(100, Math.round((contextUsed / DEFAULT_CONTEXT_WINDOW) * 100))
: 0;
const contextWindow = ok(usage.contextWindow) ? usage.contextWindow : DEFAULT_CONTEXT_WINDOW;
const contextUsed = usage.lastTurnContextUsed || 0;
const contextPct = Math.min(100, Math.round((contextUsed / contextWindow) * 100));
const showCost = ok(costUsd);
// Orchestrator (the parent/main agent) spend = session totals minus everything
// attributed to sub-agents. Derived here so no extra backend data is needed.
const subTotals = subAgents.reduce(
(acc, s) => ({
tokens: acc.tokens + s.inputTokens + s.outputTokens,
cost: acc.cost + s.costUsd,
}),
{ tokens: 0, cost: 0 }
);
const orchestratorTokens = Math.max(0, inTok + outTok - subTotals.tokens);
const orchestratorCost = Math.max(0, costUsd - subTotals.cost);
const parts: React.ReactNode[] = [];
if (model) {
// Inline footer is intentionally minimal: just context usage · cost. The full
// token breakdown (in/out/cached, per-agent) lives in the click-open popover.
// The context counter is always shown (primary metric + toggle hint) and is
// highlighted while the breakdown is open.
parts.push(
<span
key="ctx"
title={t('token.contextWindow')}
className={
open ? 'rounded bg-primary-500/15 px-1 text-primary-700 dark:text-primary-300' : undefined
}>
{t('token.ctxLabel')} {contextPct}% ({fmt(contextUsed)}/{fmt(contextWindow)})
</span>
);
if (showCost) {
parts.push(
<span key="model" className="truncate" title={model}>
{model}
</span>
);
}
if (showIn) {
parts.push(
<span key="in" title={t('token.inputTokens')}>
{t('token.inLabel')} {fmt(inTok)}
</span>
);
}
if (showOut) {
parts.push(
<span key="out" title={t('token.outputTokens')}>
{t('token.outLabel')} {fmt(outTok)}
</span>
);
}
if (turns > 0) {
parts.push(
<span key="turns" title={t('token.turnsCount')}>
{turns} {turns === 1 ? t('token.turn') : t('token.turns')}
</span>
);
}
if (showCtx) {
parts.push(
<span key="ctx" title={t('token.contextWindow')}>
{t('token.ctxLabel')} {contextPct}% ({fmt(contextUsed)}/{fmt(DEFAULT_CONTEXT_WINDOW)})
<span key="cost" title={t('token.costTitle')}>
{fmtUsd(costUsd)}
</span>
);
}
@@ -86,13 +166,84 @@ export default function ComposerTokenStats({ model }: ComposerTokenStatsProps =
if (parts.length === 0) return null;
return (
<div className="flex min-w-0 flex-wrap items-center gap-2.5 text-[10px] font-mono text-content-faint select-none">
{parts.map((part, i) => (
<span key={i} className="contents">
{i > 0 && dot()}
{part}
</span>
))}
<div ref={rootRef} className="relative flex min-w-0 items-center">
{/* Hover hint that the compact row is interactive; click opens the full
breakdown. The hint is suppressed while the popover is already open. */}
<Tooltip label={open ? '' : t('token.clickForDetails')} side="top">
<button
type="button"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
aria-label={t('token.sessionUsageTitle')}
className="flex min-w-0 cursor-pointer flex-wrap items-center gap-1.5 border-0 bg-transparent p-0 text-[10px] font-mono text-content-faint select-none">
{parts.map((part, i) => (
<span key={i} className="contents">
{part}
</span>
))}
</button>
</Tooltip>
{open && (
<div
data-testid="composer-token-breakdown"
role="dialog"
aria-label={t('token.sessionUsageTitle')}
className="absolute bottom-full left-0 z-50 mb-1.5 w-64 rounded-md border border-line-strong bg-surface p-2.5 text-[11px] shadow-lg">
<div className="mb-1.5 font-semibold text-content">{t('token.sessionUsageTitle')}</div>
{model && (
<div className="mb-1.5 truncate font-mono text-content-faint" title={model}>
{model}
</div>
)}
<dl className="space-y-0.5 text-content-secondary">
<UsageRow label={t('token.popInput')} tip={t('token.tipInput')} value={fmt(inTok)} />
<UsageRow label={t('token.popOutput')} tip={t('token.tipOutput')} value={fmt(outTok)} />
{ok(cachedTok) && (
<UsageRow
label={t('token.popCacheHit')}
tip={t('token.tipCacheHit')}
value={`${fmt(cachedTok)} (${
inTok > 0 ? Math.min(100, Math.round((cachedTok / inTok) * 100)) : 0
}%)`}
/>
)}
<UsageRow
label={t('token.popContext')}
tip={t('token.contextWindow')}
value={`${contextPct}% (${fmt(contextUsed)}/${fmt(contextWindow)})`}
/>
<UsageRow
label={t('token.costLabel')}
tip={t('token.costTitle')}
value={fmtUsd(costUsd)}
/>
</dl>
<div className="mt-2 border-t border-line-subtle pt-1.5">
<div className="mb-1 font-semibold text-content">{t('token.byAgentHeading')}</div>
<ul className="space-y-0.5 text-content-secondary">
{/* Orchestrator first, then each sub-agent archetype. */}
<AgentLine
name={t('token.orchestrator')}
tokens={orchestratorTokens}
costUsd={orchestratorCost}
runs={turns}
/>
{subAgents.map(sub => (
<AgentLine
key={sub.agentId}
name={sub.agentId}
tokens={sub.inputTokens + sub.outputTokens}
costUsd={sub.costUsd}
runs={sub.runs}
/>
))}
</ul>
{subAgents.length === 0 && (
<div className="mt-0.5 text-content-faint">{t('token.noSubAgents')}</div>
)}
</div>
</div>
)}
</div>
);
}
+16
View File
@@ -2268,6 +2268,22 @@ const messages: TranslationMap = {
'token.turns': 'دورات',
'token.ctxLabel': 'سياق',
'token.contextWindow': 'استخدام نافذة السياق (آخر دورة)',
'token.cachedLabel': 'مخزّن مؤقتًا',
'token.costLabel': 'التكلفة',
'token.costTitle': 'التكلفة التقديرية لهذه الجلسة (دولار أمريكي)',
'token.sessionUsageTitle': 'استخدام الجلسة',
'token.subAgentsHeading': 'استخدام الوكلاء الفرعيين',
'token.byAgentHeading': 'حسب الوكيل',
'token.orchestrator': 'المنسّق',
'token.noSubAgents': 'لا يوجد استخدام للوكلاء الفرعيين بعد',
'token.popInput': 'رموز الإدخال',
'token.popOutput': 'رموز الإخراج',
'token.popCacheHit': 'إصابة ذاكرة التخزين المؤقت',
'token.popContext': 'نافذة السياق',
'token.tipInput': 'الرموز المُرسلة إلى النموذج في هذه الجلسة — مطالباتك إضافةً إلى سجل المحادثة.',
'token.tipOutput': 'الرموز التي ولّدها النموذج في هذه الجلسة.',
'token.tipCacheHit': 'رموز إدخال تُقدَّم من بادئة مطالبة مخزّنة مؤقتًا، وتُحتسب بسعر مخفّض.',
'token.clickForDetails': 'انقر لعرض تفاصيل استخدام الجلسة',
'catalog.noCapabilityBinding': 'لا يوجد ربط بالإمكانات',
'catalog.downloadFailed': 'فشل التنزيل',
'catalog.active': 'نشط',
+16
View File
@@ -2319,6 +2319,22 @@ const messages: TranslationMap = {
'token.turns': 'টার্ন',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'কনটেক্সট উইন্ডো ব্যবহার (শেষ টার্ন)',
'token.cachedLabel': 'ক্যাশড',
'token.costLabel': 'খরচ',
'token.costTitle': 'এই সেশনের আনুমানিক খরচ (USD)',
'token.sessionUsageTitle': 'সেশন ব্যবহার',
'token.subAgentsHeading': 'সাব-এজেন্ট ব্যবহার',
'token.byAgentHeading': 'এজেন্ট অনুযায়ী',
'token.orchestrator': 'অর্কেস্ট্রেটর',
'token.noSubAgents': 'এখনও কোনো সাব-এজেন্ট ব্যবহার নেই',
'token.popInput': 'ইনপুট টোকেন',
'token.popOutput': 'আউটপুট টোকেন',
'token.popCacheHit': 'ক্যাশ হিট',
'token.popContext': 'কনটেক্সট উইন্ডো',
'token.tipInput': 'এই সেশনে মডেলে পাঠানো টোকেন — আপনার প্রম্পট এবং কথোপকথনের ইতিহাস।',
'token.tipOutput': 'এই সেশনে মডেল দ্বারা তৈরি টোকেন।',
'token.tipCacheHit': 'ক্যাশড প্রম্পট প্রিফিক্স থেকে দেওয়া ইনপুট টোকেন, কম হারে বিল করা হয়।',
'token.clickForDetails': 'সেশন ব্যবহারের বিবরণের জন্য ক্লিক করুন',
'catalog.noCapabilityBinding': 'কোনো ক্যাপাবিলিটি বাইন্ডিং নেই',
'catalog.downloadFailed': 'ডাউনলোড ব্যর্থ',
'catalog.active': 'সক্রিয়',
+18
View File
@@ -2373,6 +2373,24 @@ const messages: TranslationMap = {
'token.turns': 'Runden',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'Kontextfenster-Nutzung (letzte Runde)',
'token.cachedLabel': 'Zwischengespeichert',
'token.costLabel': 'Kosten',
'token.costTitle': 'Geschätzte Kosten dieser Sitzung (USD)',
'token.sessionUsageTitle': 'Sitzungsnutzung',
'token.subAgentsHeading': 'Subagenten-Nutzung',
'token.byAgentHeading': 'Nach Agent',
'token.orchestrator': 'Orchestrator',
'token.noSubAgents': 'Noch keine Subagenten-Nutzung',
'token.popInput': 'Eingabe-Tokens',
'token.popOutput': 'Ausgabe-Tokens',
'token.popCacheHit': 'Cache-Treffer',
'token.popContext': 'Kontextfenster',
'token.tipInput':
'In dieser Sitzung an das Modell gesendete Tokens — deine Prompts plus der Gesprächsverlauf.',
'token.tipOutput': 'In dieser Sitzung vom Modell erzeugte Tokens.',
'token.tipCacheHit':
'Eingabe-Tokens aus einem zwischengespeicherten Prompt-Präfix, zu einem reduzierten Tarif abgerechnet.',
'token.clickForDetails': 'Für Details zur Sitzungsnutzung klicken',
'catalog.noCapabilityBinding': 'Keine Fähigkeitsbindung',
'catalog.downloadFailed': 'Der Download ist fehlgeschlagen',
'catalog.active': 'Aktiv',
+17
View File
@@ -2721,6 +2721,23 @@ const en: TranslationMap = {
'token.turns': 'turns',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'Context window usage (last turn)',
'token.cachedLabel': 'Cached',
'token.costLabel': 'Cost',
'token.costTitle': 'Estimated cost this session (USD)',
'token.sessionUsageTitle': 'Session usage',
'token.subAgentsHeading': 'Sub-agent usage',
'token.byAgentHeading': 'By agent',
'token.orchestrator': 'Orchestrator',
'token.noSubAgents': 'No sub-agent usage yet',
'token.popInput': 'Input tokens',
'token.popOutput': 'Output tokens',
'token.popCacheHit': 'Cache hit',
'token.popContext': 'Context window',
'token.tipInput':
'Tokens sent to the model this session — your prompts plus the conversation history.',
'token.tipOutput': 'Tokens generated by the model this session.',
'token.tipCacheHit': 'Input tokens served from a cached prompt prefix, billed at a reduced rate.',
'token.clickForDetails': 'Click for session usage details',
// Catalog
'catalog.noCapabilityBinding': 'No capability binding',
+18
View File
@@ -2359,6 +2359,24 @@ const messages: TranslationMap = {
'token.turns': 'turnos',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'Uso de ventana de contexto (último turno)',
'token.cachedLabel': 'En caché',
'token.costLabel': 'Costo',
'token.costTitle': 'Costo estimado de esta sesión (USD)',
'token.sessionUsageTitle': 'Uso de la sesión',
'token.subAgentsHeading': 'Uso de subagentes',
'token.byAgentHeading': 'Por agente',
'token.orchestrator': 'Orquestador',
'token.noSubAgents': 'Aún no hay uso de subagentes',
'token.popInput': 'Tokens de entrada',
'token.popOutput': 'Tokens de salida',
'token.popCacheHit': 'Acierto de caché',
'token.popContext': 'Ventana de contexto',
'token.tipInput':
'Tokens enviados al modelo en esta sesión: tus prompts más el historial de la conversación.',
'token.tipOutput': 'Tokens generados por el modelo en esta sesión.',
'token.tipCacheHit':
'Tokens de entrada servidos desde un prefijo de prompt en caché, facturados a una tarifa reducida.',
'token.clickForDetails': 'Haz clic para ver los detalles de uso de la sesión',
'catalog.noCapabilityBinding': 'Sin vinculación de capacidad',
'catalog.downloadFailed': 'Descarga fallida',
'catalog.active': 'Activo',
+18
View File
@@ -2372,6 +2372,24 @@ const messages: TranslationMap = {
'token.turns': 'tours',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'Utilisation de la fenêtre de contexte (dernier tour)',
'token.cachedLabel': 'En cache',
'token.costLabel': 'Coût',
'token.costTitle': 'Coût estimé de cette session (USD)',
'token.sessionUsageTitle': 'Utilisation de la session',
'token.subAgentsHeading': 'Utilisation des sous-agents',
'token.byAgentHeading': 'Par agent',
'token.orchestrator': 'Orchestrateur',
'token.noSubAgents': 'Aucune utilisation de sous-agent pour le moment',
'token.popInput': 'Jetons dentrée',
'token.popOutput': 'Jetons de sortie',
'token.popCacheHit': 'Succès de cache',
'token.popContext': 'Fenêtre de contexte',
'token.tipInput':
'Jetons envoyés au modèle durant cette session — vos invites plus lhistorique de la conversation.',
'token.tipOutput': 'Jetons générés par le modèle durant cette session.',
'token.tipCacheHit':
'Jetons dentrée servis depuis un préfixe dinvite en cache, facturés à un tarif réduit.',
'token.clickForDetails': 'Cliquez pour les détails dutilisation de la session',
'catalog.noCapabilityBinding': 'Aucune liaison de fonctionnalité',
'catalog.downloadFailed': 'Échec du téléchargement',
'catalog.active': 'Actif',
+16
View File
@@ -2315,6 +2315,22 @@ const messages: TranslationMap = {
'token.turns': 'चक्र',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'संदर्भ विंडो उपयोग (अंतिम चक्र)',
'token.cachedLabel': 'कैश्ड',
'token.costLabel': 'लागत',
'token.costTitle': 'इस सत्र की अनुमानित लागत (USD)',
'token.sessionUsageTitle': 'सत्र उपयोग',
'token.subAgentsHeading': 'उप-एजेंट उपयोग',
'token.byAgentHeading': 'एजेंट अनुसार',
'token.orchestrator': 'ऑर्केस्ट्रेटर',
'token.noSubAgents': 'अभी तक कोई उप-एजेंट उपयोग नहीं',
'token.popInput': 'इनपुट टोकन',
'token.popOutput': 'आउटपुट टोकन',
'token.popCacheHit': 'कैश हिट',
'token.popContext': 'संदर्भ विंडो',
'token.tipInput': 'इस सत्र में मॉडल को भेजे गए टोकन — आपके प्रॉम्प्ट और बातचीत का इतिहास।',
'token.tipOutput': 'इस सत्र में मॉडल द्वारा उत्पन्न टोकन।',
'token.tipCacheHit': 'कैश किए गए प्रॉम्प्ट प्रिफ़िक्स से दिए गए इनपुट टोकन, कम दर पर बिल किए गए।',
'token.clickForDetails': 'सत्र उपयोग विवरण के लिए क्लिक करें',
'catalog.noCapabilityBinding': 'कोई कैपेबिलिटी बाइंडिंग नहीं',
'catalog.downloadFailed': 'डाउनलोड विफल',
'catalog.active': 'एक्टिव',
+18
View File
@@ -2320,6 +2320,24 @@ const messages: TranslationMap = {
'token.turns': 'putaran',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'Penggunaan jendela konteks (putaran terakhir)',
'token.cachedLabel': 'Tersimpan',
'token.costLabel': 'Biaya',
'token.costTitle': 'Perkiraan biaya sesi ini (USD)',
'token.sessionUsageTitle': 'Penggunaan sesi',
'token.subAgentsHeading': 'Penggunaan sub-agen',
'token.byAgentHeading': 'Per agen',
'token.orchestrator': 'Orkestrator',
'token.noSubAgents': 'Belum ada penggunaan sub-agen',
'token.popInput': 'Token masukan',
'token.popOutput': 'Token keluaran',
'token.popCacheHit': 'Cache hit',
'token.popContext': 'Jendela konteks',
'token.tipInput':
'Token yang dikirim ke model pada sesi ini — prompt Anda ditambah riwayat percakapan.',
'token.tipOutput': 'Token yang dihasilkan model pada sesi ini.',
'token.tipCacheHit':
'Token masukan yang dilayani dari prefiks prompt yang di-cache, ditagih dengan tarif lebih rendah.',
'token.clickForDetails': 'Klik untuk detail penggunaan sesi',
'catalog.noCapabilityBinding': 'Tidak ada binding kemampuan',
'catalog.downloadFailed': 'Unduhan gagal',
'catalog.active': 'Aktif',
+18
View File
@@ -2353,6 +2353,24 @@ const messages: TranslationMap = {
'token.turns': 'turni',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'Utilizzo finestra di contesto (ultimo turno)',
'token.cachedLabel': 'In cache',
'token.costLabel': 'Costo',
'token.costTitle': 'Costo stimato di questa sessione (USD)',
'token.sessionUsageTitle': 'Utilizzo della sessione',
'token.subAgentsHeading': 'Utilizzo dei sub-agenti',
'token.byAgentHeading': 'Per agente',
'token.orchestrator': 'Orchestratore',
'token.noSubAgents': 'Nessun utilizzo di sub-agenti ancora',
'token.popInput': 'Token di input',
'token.popOutput': 'Token di output',
'token.popCacheHit': 'Cache hit',
'token.popContext': 'Finestra di contesto',
'token.tipInput':
'Token inviati al modello in questa sessione — i tuoi prompt più la cronologia della conversazione.',
'token.tipOutput': 'Token generati dal modello in questa sessione.',
'token.tipCacheHit':
'Token di input serviti da un prefisso di prompt in cache, fatturati a tariffa ridotta.',
'token.clickForDetails': 'Fai clic per i dettagli di utilizzo della sessione',
'catalog.noCapabilityBinding': 'Nessun binding di capacità',
'catalog.downloadFailed': 'Download fallito',
'catalog.active': 'Attivo',
+16
View File
@@ -2294,6 +2294,22 @@ const messages: TranslationMap = {
'token.turns': '턴',
'token.ctxLabel': 'CTX',
'token.contextWindow': '컨텍스트 윈도우 사용량 (마지막 턴)',
'token.cachedLabel': '캐시됨',
'token.costLabel': '비용',
'token.costTitle': '이 세션의 예상 비용 (USD)',
'token.sessionUsageTitle': '세션 사용량',
'token.subAgentsHeading': '하위 에이전트 사용량',
'token.byAgentHeading': '에이전트별',
'token.orchestrator': '오케스트레이터',
'token.noSubAgents': '아직 하위 에이전트 사용량이 없습니다',
'token.popInput': '입력 토큰',
'token.popOutput': '출력 토큰',
'token.popCacheHit': '캐시 적중',
'token.popContext': '컨텍스트 윈도우',
'token.tipInput': '이 세션에서 모델로 보낸 토큰 — 사용자 프롬프트와 대화 기록.',
'token.tipOutput': '이 세션에서 모델이 생성한 토큰.',
'token.tipCacheHit': '캐시된 프롬프트 접두부에서 제공된 입력 토큰으로, 낮은 요금으로 청구됩니다.',
'token.clickForDetails': '세션 사용량 세부정보를 보려면 클릭하세요',
'catalog.noCapabilityBinding': '기능 바인딩 없음',
'catalog.downloadFailed': '다운로드 실패',
'catalog.active': '활성',
+17
View File
@@ -2339,6 +2339,23 @@ const messages: TranslationMap = {
'token.turns': 'tury',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'Użycie okna kontekstu (ostatnia tura)',
'token.cachedLabel': 'Z pamięci',
'token.costLabel': 'Koszt',
'token.costTitle': 'Szacowany koszt tej sesji (USD)',
'token.sessionUsageTitle': 'Użycie sesji',
'token.subAgentsHeading': 'Użycie podagentów',
'token.byAgentHeading': 'Wg agenta',
'token.orchestrator': 'Orkiestrator',
'token.noSubAgents': 'Brak użycia podagentów',
'token.popInput': 'Tokeny wejściowe',
'token.popOutput': 'Tokeny wyjściowe',
'token.popCacheHit': 'Trafienie w pamięci podręcznej',
'token.popContext': 'Okno kontekstu',
'token.tipInput': 'Tokeny wysłane do modelu w tej sesji — Twoje prompty plus historia rozmowy.',
'token.tipOutput': 'Tokeny wygenerowane przez model w tej sesji.',
'token.tipCacheHit':
'Tokeny wejściowe podane z buforowanego prefiksu promptu, rozliczane po niższej stawce.',
'token.clickForDetails': 'Kliknij, aby zobaczyć szczegóły użycia sesji',
'catalog.noCapabilityBinding': 'Brak powiązanej możliwości',
'catalog.downloadFailed': 'Pobieranie nie powiodło się',
'catalog.active': 'Aktywny',
+18
View File
@@ -2360,6 +2360,24 @@ const messages: TranslationMap = {
'token.turns': 'turnos',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'Uso da janela de contexto (último turno)',
'token.cachedLabel': 'Em cache',
'token.costLabel': 'Custo',
'token.costTitle': 'Custo estimado desta sessão (USD)',
'token.sessionUsageTitle': 'Uso da sessão',
'token.subAgentsHeading': 'Uso de subagentes',
'token.byAgentHeading': 'Por agente',
'token.orchestrator': 'Orquestrador',
'token.noSubAgents': 'Ainda sem uso de subagentes',
'token.popInput': 'Tokens de entrada',
'token.popOutput': 'Tokens de saída',
'token.popCacheHit': 'Acerto de cache',
'token.popContext': 'Janela de contexto',
'token.tipInput':
'Tokens enviados ao modelo nesta sessão — seus prompts mais o histórico da conversa.',
'token.tipOutput': 'Tokens gerados pelo modelo nesta sessão.',
'token.tipCacheHit':
'Tokens de entrada servidos de um prefixo de prompt em cache, cobrados a uma taxa reduzida.',
'token.clickForDetails': 'Clique para ver os detalhes de uso da sessão',
'catalog.noCapabilityBinding': 'Sem vinculação de funcionalidade',
'catalog.downloadFailed': 'Falha no download',
'catalog.active': 'Ativo',
+17
View File
@@ -2334,6 +2334,23 @@ const messages: TranslationMap = {
'token.turns': 'циклов',
'token.ctxLabel': 'CTX',
'token.contextWindow': 'Использование окна контекста (последний цикл)',
'token.cachedLabel': 'Из кэша',
'token.costLabel': 'Стоимость',
'token.costTitle': 'Оценочная стоимость этой сессии (USD)',
'token.sessionUsageTitle': 'Использование сессии',
'token.subAgentsHeading': 'Использование субагентов',
'token.byAgentHeading': 'По агенту',
'token.orchestrator': 'Оркестратор',
'token.noSubAgents': 'Пока нет использования субагентов',
'token.popInput': 'Входные токены',
'token.popOutput': 'Выходные токены',
'token.popCacheHit': 'Попадание в кэш',
'token.popContext': 'Окно контекста',
'token.tipInput': 'Токены, отправленные модели в этой сессии — ваши запросы и история разговора.',
'token.tipOutput': 'Токены, сгенерированные моделью в этой сессии.',
'token.tipCacheHit':
'Входные токены, выданные из кэшированного префикса запроса и тарифицируемые по сниженной ставке.',
'token.clickForDetails': 'Нажмите, чтобы увидеть детали использования сессии',
'catalog.noCapabilityBinding': 'Нет привязки возможностей',
'catalog.downloadFailed': 'Загрузка не удалась',
'catalog.active': 'Активно',
+16
View File
@@ -2197,6 +2197,22 @@ const messages: TranslationMap = {
'token.turns': '轮',
'token.ctxLabel': '上下文',
'token.contextWindow': '上下文窗口使用量(上一轮)',
'token.cachedLabel': '已缓存',
'token.costLabel': '费用',
'token.costTitle': '本次会话的预估费用(美元)',
'token.sessionUsageTitle': '会话用量',
'token.subAgentsHeading': '子代理用量',
'token.byAgentHeading': '按代理',
'token.orchestrator': '编排器',
'token.noSubAgents': '暂无子代理用量',
'token.popInput': '输入令牌',
'token.popOutput': '输出令牌',
'token.popCacheHit': '缓存命中',
'token.popContext': '上下文窗口',
'token.tipInput': '本次会话发送给模型的令牌——你的提示词加上对话历史。',
'token.tipOutput': '本次会话由模型生成的令牌。',
'token.tipCacheHit': '由缓存的提示词前缀提供的输入令牌,按较低费率计费。',
'token.clickForDetails': '点击查看会话用量详情',
'catalog.noCapabilityBinding': '无能力绑定',
'catalog.downloadFailed': '下载失败',
'catalog.active': '活跃',
+36 -1
View File
@@ -36,6 +36,7 @@ import { trackEvent } from '../services/analytics';
import { applyOpenRouterFreeModels } from '../services/api/openrouterFreeModels';
import { subagentApi } from '../services/api/subagentApi';
import { threadApi } from '../services/api/threadApi';
import { fetchThreadTokenUsage } from '../services/api/threadUsageApi';
import {
aiRegenerate,
chatCancel,
@@ -57,6 +58,7 @@ import {
clearThreadSendPending,
enqueueFollowup,
fetchAndHydrateTurnState,
hydrateThreadUsage,
markSubagentCancelled,
markThreadSendPending,
type QueuedFollowup,
@@ -553,6 +555,39 @@ const Conversations = ({
}
};
// Seed the composer footer with the selected thread's persisted token/cost
// usage (read back from its session transcripts) so the totals reflect prior
// turns instead of starting at zero. Best-effort; live turns accumulate on top
// via recordChatTurnUsage and a brand-new thread (hasUsage=false) is left as-is.
useEffect(() => {
if (!selectedThreadId) return;
let cancelled = false;
void fetchThreadTokenUsage(selectedThreadId)
.then(u => {
if (cancelled || !u.hasUsage) return;
dispatch(
hydrateThreadUsage({
threadId: u.threadId,
inputTokens: u.inputTokens,
outputTokens: u.outputTokens,
cachedTokens: u.cachedInputTokens,
costUsd: u.costUsd,
turns: u.turnCount,
contextWindow: u.contextWindow,
lastTurnInputTokens: u.lastTurnInputTokens,
lastTurnOutputTokens: u.lastTurnOutputTokens,
subAgents: u.subagents,
})
);
})
.catch(() => {
/* best-effort seed; the footer still fills from live turns */
});
return () => {
cancelled = true;
};
}, [selectedThreadId, dispatch]);
useEffect(() => {
let cancelled = false;
@@ -2881,7 +2916,7 @@ const Conversations = ({
className="mt-2 flex items-center justify-between gap-2"
data-walkthrough="chat-agent-panel">
<div className="flex min-w-0 items-center gap-2">
<ComposerTokenStats model={resolvedModel} />
<ComposerTokenStats model={resolvedModel} threadId={selectedThreadId} />
{/* Set/show the thread goal; click opens the editor above the composer. */}
<ThreadGoalFooterTrigger ctl={threadGoal} />
</div>
@@ -70,7 +70,7 @@ export const PlanReviewCard: React.FC<Props> = ({ threadId, review }) => {
role="alertdialog"
aria-label={t('conversations.planReview.title')}
data-testid="plan-review-card"
className="mb-2 rounded-xl border border-ocean-300 bg-ocean-50 p-3 text-sm shadow-sm dark:border-ocean-700 dark:bg-ocean-950">
className="mb-2 rounded-xl border border-ocean-300 bg-surface p-3 text-sm shadow-md dark:border-ocean-700">
<div className="flex items-start gap-2">
<span aria-hidden className="text-base leading-none text-ocean-700 dark:text-ocean-200">
🗺
+46 -12
View File
@@ -200,6 +200,50 @@ function chatDoneExtraMetadata(event: ChatDoneEvent): Record<string, unknown> |
return event.citations?.length ? { citations: event.citations } : undefined;
}
/**
* Map a `chat_done` event's holistic usage onto the `recordChatTurnUsage`
* payload. Prefers the structured `usage` object (tokens + cost + context window
* + per-sub-agent breakdown); falls back to the deprecated flat token fields for
* any older core that still emits them.
*/
function chatTurnUsagePayload(event: ChatDoneEvent): {
inputTokens: number;
outputTokens: number;
cachedTokens?: number;
costUsd?: number;
contextWindow?: number;
threadId?: string;
subAgents?: Array<{
agentId: string;
inputTokens: number;
outputTokens: number;
costUsd: number;
}>;
} {
const u = event.usage;
if (u) {
return {
inputTokens: u.input_tokens,
outputTokens: u.output_tokens,
cachedTokens: u.cached_input_tokens,
costUsd: u.cost_usd,
contextWindow: u.context_window,
threadId: event.thread_id,
subAgents: (u.subagents ?? []).map(s => ({
agentId: s.agent_id,
inputTokens: s.input_tokens,
outputTokens: s.output_tokens,
costUsd: s.cost_usd,
})),
};
}
return {
inputTokens: event.total_input_tokens ?? 0,
outputTokens: event.total_output_tokens ?? 0,
threadId: event.thread_id,
};
}
export function findPendingDelegationContext(
entries: ToolTimelineEntry[],
round: number
@@ -1087,12 +1131,7 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
store.getState().chatRuntime.parallelRequestThreads[event.request_id] !== undefined
) {
const parallelRequestId = event.request_id;
dispatch(
recordChatTurnUsage({
inputTokens: event.total_input_tokens,
outputTokens: event.total_output_tokens,
})
);
dispatch(recordChatTurnUsage(chatTurnUsagePayload(event)));
if (!event.segment_total && event.full_response.length > 0) {
void (async () => {
try {
@@ -1127,12 +1166,7 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
const segmentDelivery = takeSegmentDelivery(segmentDeliveriesRef.current, deliveryKey);
const completeSegmentDelivery = hasCompleteSegmentDelivery(event, segmentDelivery);
dispatch(
recordChatTurnUsage({
inputTokens: event.total_input_tokens,
outputTokens: event.total_output_tokens,
})
);
dispatch(recordChatTurnUsage(chatTurnUsagePayload(event)));
dispatch(clearInferenceStatusForThread({ threadId: event.thread_id }));
dispatch(clearStreamingAssistantForThread({ threadId: event.thread_id }));
dispatch(clearPendingApprovalForThread({ threadId: event.thread_id }));
+92
View File
@@ -0,0 +1,92 @@
import { callCoreRpc } from '../coreRpcClient';
/** One sub-agent archetype's contribution within a thread. */
export interface ThreadSubagentUsage {
agentId: string;
inputTokens: number;
outputTokens: number;
costUsd: number;
runs: number;
}
/** Camel-cased per-thread usage totals consumed by the composer footer. */
export interface ThreadTokenUsage {
threadId: string;
inputTokens: number;
outputTokens: number;
cachedInputTokens: number;
costUsd: number;
turnCount: number;
lastTurnInputTokens: number;
lastTurnOutputTokens: number;
contextWindow: number;
model: string | null;
updated: string | null;
hasUsage: boolean;
/** Per-archetype sub-agent breakdown (already included in the totals above). */
subagents: ThreadSubagentUsage[];
}
/** Wire shape returned by `openhuman.threads_token_usage` (snake_case). */
interface ThreadSubagentUsageWire {
agent_id: string;
input_tokens: number;
output_tokens: number;
cost_usd: number;
runs: number;
}
interface ThreadTokenUsageWire {
thread_id: string;
input_tokens: number;
output_tokens: number;
cached_input_tokens: number;
cost_usd: number;
turn_count: number;
last_turn_input_tokens: number;
last_turn_output_tokens: number;
context_window: number;
model: string | null;
updated: string | null;
has_usage: boolean;
subagents?: ThreadSubagentUsageWire[];
}
interface Envelope<T> {
data?: T;
}
/**
* Fetch a thread's persisted token/cost totals from the core (read back from
* its session transcripts). Returns zeros with `hasUsage: false` for a thread
* that has no completed turns yet.
*/
export async function fetchThreadTokenUsage(threadId: string): Promise<ThreadTokenUsage> {
const response = await callCoreRpc<Envelope<ThreadTokenUsageWire>>({
method: 'openhuman.threads_token_usage',
params: { thread_id: threadId },
});
const d = response?.data;
if (!d) throw new Error('threads_token_usage returned an empty envelope');
return {
threadId: d.thread_id,
inputTokens: d.input_tokens,
outputTokens: d.output_tokens,
cachedInputTokens: d.cached_input_tokens,
costUsd: d.cost_usd,
turnCount: d.turn_count,
lastTurnInputTokens: d.last_turn_input_tokens,
lastTurnOutputTokens: d.last_turn_output_tokens,
contextWindow: d.context_window,
model: d.model,
updated: d.updated,
hasUsage: d.has_usage,
subagents: (d.subagents ?? []).map(s => ({
agentId: s.agent_id,
inputTokens: s.input_tokens,
outputTokens: s.output_tokens,
costUsd: s.cost_usd,
runs: s.runs,
})),
};
}
+36 -2
View File
@@ -51,13 +51,47 @@ export interface ChatToolResultEvent {
tool_call_id?: string;
}
/** One sub-agent's token/cost contribution within a turn (hover breakdown). */
export interface SubagentUsageWire {
task_id: string;
agent_id: string;
input_tokens: number;
output_tokens: number;
cost_usd: number;
}
/**
* Holistic token/cost/context totals for a completed turn, carried on
* `chat_done`. Every numeric is a turn total (parent agent + any sub-agents);
* `subagents` breaks the same spend down per child. `context_window` is `0` when
* the core couldn't resolve the model's window.
*/
export interface TurnUsageWire {
input_tokens: number;
output_tokens: number;
cached_input_tokens: number;
cost_usd: number;
context_window: number;
subagents?: SubagentUsageWire[];
}
export interface ChatDoneEvent {
thread_id: string;
request_id?: string;
full_response: string;
rounds_used: number;
total_input_tokens: number;
total_output_tokens: number;
/**
* @deprecated Superseded by {@link ChatDoneEvent.usage}. The core no longer
* populates these flat fields; read token totals from `usage` instead.
*/
total_input_tokens?: number;
/** @deprecated See {@link ChatDoneEvent.total_input_tokens}. */
total_output_tokens?: number;
/**
* Holistic token/cost/context usage for the turn (parent + sub-agents).
* Absent on synthetic done events that never ran a real turn.
*/
usage?: TurnUsageWire | null;
/** Emoji reaction decided by the local model (if any). */
reaction_emoji?: string | null;
/** Total segments when the response was split into bubbles by Rust. */
+164
View File
@@ -9,7 +9,10 @@ import chatRuntimeReducer, {
clearRuntimeForThread,
hydrateRuntimeFromRunLedger,
hydrateRuntimeFromSnapshot,
hydrateThreadUsage,
type QueueStatus,
recordChatTurnUsage,
resetSessionTokenUsage,
setQueueStatusForThread,
setToolTimelineForThread,
} from './chatRuntimeSlice';
@@ -48,6 +51,167 @@ function makeStore() {
return configureStore({ reducer: { chatRuntime: chatRuntimeReducer } });
}
describe('chatRuntimeSlice recordChatTurnUsage', () => {
it('accumulates tokens, cost, and context window across turns', () => {
const store = makeStore();
store.dispatch(
recordChatTurnUsage({
inputTokens: 1000,
outputTokens: 200,
cachedTokens: 50,
costUsd: 0.012,
contextWindow: 200_000,
})
);
store.dispatch(
recordChatTurnUsage({
inputTokens: 500,
outputTokens: 100,
cachedTokens: 10,
costUsd: 0.008,
contextWindow: 200_000,
})
);
const usage = store.getState().chatRuntime.sessionTokenUsage;
expect(usage.inputTokens).toBe(1500);
expect(usage.outputTokens).toBe(300);
expect(usage.cachedTokens).toBe(60);
expect(usage.costUsd).toBeCloseTo(0.02, 6);
expect(usage.turns).toBe(2);
expect(usage.contextWindow).toBe(200_000);
// Context gauge tracks the latest turn's input+output, not the running sum.
expect(usage.lastTurnContextUsed).toBe(600);
});
it('rolls sub-agent spend into a per-archetype breakdown keyed by agentId', () => {
const store = makeStore();
store.dispatch(
recordChatTurnUsage({
inputTokens: 100,
outputTokens: 20,
subAgents: [
{ agentId: 'researcher', inputTokens: 40, outputTokens: 10, costUsd: 0.001 },
{ agentId: 'coder', inputTokens: 80, outputTokens: 30, costUsd: 0.003 },
],
})
);
store.dispatch(
recordChatTurnUsage({
inputTokens: 50,
outputTokens: 10,
subAgents: [{ agentId: 'researcher', inputTokens: 60, outputTokens: 5, costUsd: 0.002 }],
})
);
const subs = store.getState().chatRuntime.sessionTokenUsage.subAgents;
expect(subs.researcher).toEqual({
agentId: 'researcher',
inputTokens: 100,
outputTokens: 15,
costUsd: 0.003,
runs: 2,
});
expect(subs.coder.runs).toBe(1);
expect(subs.coder.inputTokens).toBe(80);
});
it('keeps the prior context window when a turn reports an unknown (0) window', () => {
const store = makeStore();
store.dispatch(
recordChatTurnUsage({ inputTokens: 10, outputTokens: 5, contextWindow: 128_000 })
);
store.dispatch(recordChatTurnUsage({ inputTokens: 10, outputTokens: 5, contextWindow: 0 }));
expect(store.getState().chatRuntime.sessionTokenUsage.contextWindow).toBe(128_000);
});
it('coerces non-finite / negative inputs to zero', () => {
const store = makeStore();
store.dispatch(
recordChatTurnUsage({ inputTokens: Number.NaN, outputTokens: -50, costUsd: -1 })
);
const usage = store.getState().chatRuntime.sessionTokenUsage;
expect(usage.inputTokens).toBe(0);
expect(usage.outputTokens).toBe(0);
expect(usage.costUsd).toBe(0);
expect(usage.turns).toBe(1);
});
it('resetSessionTokenUsage clears all accumulated usage', () => {
const store = makeStore();
store.dispatch(
recordChatTurnUsage({
inputTokens: 100,
outputTokens: 20,
costUsd: 0.01,
subAgents: [{ agentId: 'researcher', inputTokens: 1, outputTokens: 1, costUsd: 0.001 }],
})
);
store.dispatch(resetSessionTokenUsage());
const usage = store.getState().chatRuntime.sessionTokenUsage;
expect(usage.inputTokens).toBe(0);
expect(usage.costUsd).toBe(0);
expect(usage.turns).toBe(0);
expect(usage.subAgents).toEqual({});
});
it('routes a turn with a threadId into that thread bucket (and the global)', () => {
const store = makeStore();
store.dispatch(
recordChatTurnUsage({ inputTokens: 100, outputTokens: 20, costUsd: 0.01, threadId: 'thr-a' })
);
store.dispatch(
recordChatTurnUsage({ inputTokens: 50, outputTokens: 10, costUsd: 0.005, threadId: 'thr-b' })
);
const { usageByThread, sessionTokenUsage } = store.getState().chatRuntime;
expect(usageByThread['thr-a'].inputTokens).toBe(100);
expect(usageByThread['thr-a'].costUsd).toBeCloseTo(0.01, 6);
expect(usageByThread['thr-b'].inputTokens).toBe(50);
// Global aggregate still sums both threads.
expect(sessionTokenUsage.inputTokens).toBe(150);
});
it('hydrateThreadUsage seeds a thread bucket and live turns accumulate on top', () => {
const store = makeStore();
store.dispatch(
hydrateThreadUsage({
threadId: 'thr-a',
inputTokens: 1000,
outputTokens: 300,
cachedTokens: 40,
costUsd: 0.02,
turns: 3,
contextWindow: 1_000_000,
lastTurnInputTokens: 400,
lastTurnOutputTokens: 120,
subAgents: [
{ agentId: 'coder', inputTokens: 300, outputTokens: 80, costUsd: 0.006, runs: 2 },
],
})
);
let bucket = store.getState().chatRuntime.usageByThread['thr-a'];
expect(bucket.inputTokens).toBe(1000);
expect(bucket.turns).toBe(3);
expect(bucket.contextWindow).toBe(1_000_000);
expect(bucket.lastTurnContextUsed).toBe(520);
// Sub-agent breakdown reconstructed from persisted transcripts.
expect(bucket.subAgents.coder).toEqual({
agentId: 'coder',
inputTokens: 300,
outputTokens: 80,
costUsd: 0.006,
runs: 2,
});
// A live turn for the same thread adds on top of the seeded base.
store.dispatch(
recordChatTurnUsage({ inputTokens: 200, outputTokens: 50, costUsd: 0.004, threadId: 'thr-a' })
);
bucket = store.getState().chatRuntime.usageByThread['thr-a'];
expect(bucket.inputTokens).toBe(1200);
expect(bucket.turns).toBe(4);
expect(bucket.costUsd).toBeCloseTo(0.024, 6);
});
});
describe('chatRuntimeSlice queue status', () => {
it('sets queue status for a thread', () => {
const store = makeStore();
+175 -30
View File
@@ -214,6 +214,20 @@ export interface StreamingAssistantState {
*/
export type InferenceTurnLifecycle = 'started' | 'streaming' | 'interrupted';
/**
* Per-sub-agent token/cost contribution, accumulated across the session and
* keyed by the sub-agent archetype id (e.g. `researcher`). Drives the hover
* breakdown under the composer footer's cost/context cluster.
*/
export interface SubAgentUsage {
agentId: string;
inputTokens: number;
outputTokens: number;
costUsd: number;
/** How many times this archetype was spawned across the session. */
runs: number;
}
/** Running per-session totals accumulated from `chat:done` events (#703). */
export interface SessionTokenUsage {
inputTokens: number;
@@ -222,6 +236,90 @@ export interface SessionTokenUsage {
lastUpdated: number;
lastTurnInputTokens: number;
lastTurnOutputTokens: number;
/** Cached-input tokens accumulated across the session. */
cachedTokens: number;
/** Total USD cost accumulated across the session (parent + sub-agents). */
costUsd: number;
/**
* Most recent known model context window (tokens). `0` until a turn reports a
* real value; the UI falls back to a default when unknown.
*/
contextWindow: number;
/** Last turn's input+output tokens — the context-window gauge numerator. */
lastTurnContextUsed: number;
/** Per-sub-agent spend for the session, keyed by archetype id. */
subAgents: Record<string, SubAgentUsage>;
}
/** A zeroed [SessionTokenUsage] bucket. */
export function emptySessionTokenUsage(): SessionTokenUsage {
return {
inputTokens: 0,
outputTokens: 0,
turns: 0,
lastUpdated: 0,
lastTurnInputTokens: 0,
lastTurnOutputTokens: 0,
cachedTokens: 0,
costUsd: 0,
contextWindow: 0,
lastTurnContextUsed: 0,
subAgents: {},
};
}
/** Payload accepted by `recordChatTurnUsage` (and applied per turn). */
export interface ChatTurnUsagePayload {
inputTokens: number;
outputTokens: number;
cachedTokens?: number;
costUsd?: number;
contextWindow?: number;
/** Thread the turn belongs to; routes the delta to that thread's bucket. */
threadId?: string;
subAgents?: Array<{
agentId: string;
inputTokens: number;
outputTokens: number;
costUsd: number;
}>;
}
const nonNeg = (n: number | undefined): number =>
typeof n === 'number' && Number.isFinite(n) ? Math.max(0, n) : 0;
/** Fold one turn's usage delta into a bucket (mutates in place). */
function applyTurnUsage(usage: SessionTokenUsage, payload: ChatTurnUsagePayload): void {
const inTok = nonNeg(payload.inputTokens);
const outTok = nonNeg(payload.outputTokens);
usage.inputTokens += inTok;
usage.outputTokens += outTok;
usage.cachedTokens += nonNeg(payload.cachedTokens);
usage.costUsd += nonNeg(payload.costUsd);
usage.turns += 1;
usage.lastUpdated = Date.now();
usage.lastTurnInputTokens = inTok;
usage.lastTurnOutputTokens = outTok;
usage.lastTurnContextUsed = inTok + outTok;
// Only overwrite the known context window when the turn reported a real value
// (>0); an unknown-window turn leaves the prior value intact.
const ctxWindow = nonNeg(payload.contextWindow);
if (ctxWindow > 0) usage.contextWindow = ctxWindow;
for (const sub of payload.subAgents ?? []) {
if (!sub || typeof sub.agentId !== 'string' || sub.agentId.length === 0) continue;
const existing = usage.subAgents[sub.agentId] ?? {
agentId: sub.agentId,
inputTokens: 0,
outputTokens: 0,
costUsd: 0,
runs: 0,
};
existing.inputTokens += nonNeg(sub.inputTokens);
existing.outputTokens += nonNeg(sub.outputTokens);
existing.costUsd += nonNeg(sub.costUsd);
existing.runs += 1;
usage.subAgents[sub.agentId] = existing;
}
}
/**
@@ -356,7 +454,15 @@ interface ChatRuntimeState {
* download / retry affordances (#2779).
*/
artifactsByThread: Record<string, ArtifactSnapshot[]>;
/** Global, app-session-wide token usage (legacy aggregate). */
sessionTokenUsage: SessionTokenUsage;
/**
* Per-thread token usage, keyed by thread id. Seeded from persisted
* transcripts via `hydrateThreadUsage` when a thread is opened, then kept live
* by `recordChatTurnUsage`. The composer footer reads the active thread's
* bucket so its totals reflect the selected thread, not the whole app session.
*/
usageByThread: Record<string, SessionTokenUsage>;
queueStatusByThread: Record<string, QueueStatus>;
/**
* Follow-up messages the user submitted while a turn was still streaming
@@ -407,14 +513,8 @@ const initialState: ChatRuntimeState = {
pendingApprovalByThread: {},
pendingPlanReviewByThread: {},
artifactsByThread: {},
sessionTokenUsage: {
inputTokens: 0,
outputTokens: 0,
turns: 0,
lastUpdated: 0,
lastTurnInputTokens: 0,
lastTurnOutputTokens: 0,
},
sessionTokenUsage: emptySessionTokenUsage(),
usageByThread: {},
queueStatusByThread: {},
queuedFollowupsByThread: {},
};
@@ -1121,32 +1221,76 @@ const chatRuntimeSlice = createSlice({
state.queuedFollowupsByThread = {};
state.pendingSendThreadIds = {};
},
recordChatTurnUsage: (
recordChatTurnUsage: (state, action: PayloadAction<ChatTurnUsagePayload>) => {
// Fold into the global aggregate and, when the turn names a thread, into
// that thread's bucket (what the composer footer reads).
applyTurnUsage(state.sessionTokenUsage, action.payload);
const threadId = action.payload.threadId;
if (threadId) {
const bucket = state.usageByThread[threadId] ?? emptySessionTokenUsage();
applyTurnUsage(bucket, action.payload);
state.usageByThread[threadId] = bucket;
}
},
/**
* Seed a thread's usage bucket from persisted transcript totals (the
* `openhuman.threads_token_usage` RPC). Replaces the bucket so re-opening a
* thread reflects its on-disk history rather than starting at zero. Live
* turns then accumulate on top via `recordChatTurnUsage`.
*/
hydrateThreadUsage: (
state,
action: PayloadAction<{ inputTokens: number; outputTokens: number }>
action: PayloadAction<{
threadId: string;
inputTokens: number;
outputTokens: number;
cachedTokens: number;
costUsd: number;
turns: number;
contextWindow: number;
lastTurnInputTokens: number;
lastTurnOutputTokens: number;
subAgents?: Array<{
agentId: string;
inputTokens: number;
outputTokens: number;
costUsd: number;
runs: number;
}>;
}>
) => {
const inTok = Number.isFinite(action.payload.inputTokens)
? Math.max(0, action.payload.inputTokens)
: 0;
const outTok = Number.isFinite(action.payload.outputTokens)
? Math.max(0, action.payload.outputTokens)
: 0;
state.sessionTokenUsage.inputTokens += inTok;
state.sessionTokenUsage.outputTokens += outTok;
state.sessionTokenUsage.turns += 1;
state.sessionTokenUsage.lastUpdated = Date.now();
state.sessionTokenUsage.lastTurnInputTokens = inTok;
state.sessionTokenUsage.lastTurnOutputTokens = outTok;
const p = action.payload;
if (!p.threadId) return;
// Reconstruct the per-archetype sub-agent map from the persisted breakdown
// (read back from the thread's `__` sub-agent transcripts).
const subAgents: Record<string, SubAgentUsage> = {};
for (const s of p.subAgents ?? []) {
if (!s || typeof s.agentId !== 'string' || s.agentId.length === 0) continue;
subAgents[s.agentId] = {
agentId: s.agentId,
inputTokens: nonNeg(s.inputTokens),
outputTokens: nonNeg(s.outputTokens),
costUsd: nonNeg(s.costUsd),
runs: nonNeg(s.runs),
};
}
state.usageByThread[p.threadId] = {
inputTokens: nonNeg(p.inputTokens),
outputTokens: nonNeg(p.outputTokens),
cachedTokens: nonNeg(p.cachedTokens),
costUsd: nonNeg(p.costUsd),
turns: nonNeg(p.turns),
lastUpdated: Date.now(),
lastTurnInputTokens: nonNeg(p.lastTurnInputTokens),
lastTurnOutputTokens: nonNeg(p.lastTurnOutputTokens),
contextWindow: nonNeg(p.contextWindow),
lastTurnContextUsed: nonNeg(p.lastTurnInputTokens) + nonNeg(p.lastTurnOutputTokens),
subAgents,
};
},
resetSessionTokenUsage: state => {
state.sessionTokenUsage = {
inputTokens: 0,
outputTokens: 0,
turns: 0,
lastUpdated: 0,
lastTurnInputTokens: 0,
lastTurnOutputTokens: 0,
};
state.sessionTokenUsage = emptySessionTokenUsage();
state.usageByThread = {};
},
/**
* Apply a persisted [TurnState] snapshot from the Rust core to the
@@ -1292,6 +1436,7 @@ export const {
clearRuntimeForThread,
clearAllChatRuntime,
recordChatTurnUsage,
hydrateThreadUsage,
resetSessionTokenUsage,
hydrateRuntimeFromSnapshot,
hydrateRuntimeFromRunLedger,
+38
View File
@@ -210,6 +210,44 @@ pub struct WebChannelEvent {
/// shown after [`Self::tool_display_label`].
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_display_detail: Option<String>,
/// Holistic token/cost/context usage for a completed turn (parent +
/// sub-agents), carried on `chat_done`. Lets the UI footer show session
/// tokens, USD cost, and real context-window utilisation, with a
/// per-sub-agent hover breakdown. `None` for every non-`chat_done` event and
/// for synthetic done events that never ran a real turn.
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<TurnUsagePayload>,
}
/// Token/cost/context totals for one completed turn, attached to `chat_done`.
///
/// Every numeric is a turn total (parent agent **plus** any sub-agents spawned
/// during the turn); the `subagents` list breaks the same spend down per child
/// for the UI hover. `context_window` is `0` when the core couldn't resolve the
/// model's window (e.g. an unknown cloud model) — the UI falls back to a
/// default in that case.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct TurnUsagePayload {
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_input_tokens: u64,
pub cost_usd: f64,
pub context_window: u64,
/// Per-sub-agent spend, omitted from the wire when no sub-agents ran.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub subagents: Vec<SubagentUsagePayload>,
}
/// One sub-agent's token/cost contribution within a turn (hover breakdown).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct SubagentUsagePayload {
pub task_id: String,
pub agent_id: String,
pub input_tokens: u64,
pub output_tokens: u64,
pub cost_usd: f64,
}
/// Per-event subagent progress detail attached to `WebChannelEvent`.
+40 -35
View File
@@ -62,45 +62,47 @@ const FALLBACK_PRICING: ModelPricing = ModelPricing {
/// them as best-effort estimates for cases where the backend doesn't
/// echo `charged_amount_usd`.
pub const PRICING_TABLE: &[ModelPricing] = &[
// Reasoning tier — currently maps to Claude Opus 4.x family.
// Reasoning tier — managed "Pro" model rates (estimate; the backend's
// echoed `charged_amount_usd` is authoritative when present). Shared with
// the coding/agentic tiers below. Update when backend pricing changes.
ModelPricing {
model: "reasoning-v1",
input_per_mtok_usd: 15.00,
cached_input_per_mtok_usd: 1.50,
output_per_mtok_usd: 75.00,
input_per_mtok_usd: 0.435,
cached_input_per_mtok_usd: 0.003625,
output_per_mtok_usd: 0.87,
},
// Chat tier — Kimi K2.6 Turbo on Fireworks (backend PR #760).
// Low TTFT, 128k context, `supportsThinking: false`. Rates track
// Fireworks' published Kimi turbo pricing at time of writing.
// Chat tier — managed "Flash" model rates (estimate). Cheaper, lower-latency
// model used for direct conversational turns.
ModelPricing {
model: "chat-v1",
input_per_mtok_usd: 0.60,
cached_input_per_mtok_usd: 0.06,
output_per_mtok_usd: 2.50,
input_per_mtok_usd: 0.14,
cached_input_per_mtok_usd: 0.0028,
output_per_mtok_usd: 0.28,
},
// Legacy chat tier slug retained for older transcripts/configs.
// Legacy chat tier slug retained for older transcripts/configs — "Flash"
// rates, same as `chat-v1`.
ModelPricing {
model: "reasoning-quick-v1",
input_per_mtok_usd: 0.60,
cached_input_per_mtok_usd: 0.06,
output_per_mtok_usd: 2.50,
input_per_mtok_usd: 0.14,
cached_input_per_mtok_usd: 0.0028,
output_per_mtok_usd: 0.28,
},
// Agentic tier — maps to Sonnet-class models.
// Agentic tier — managed "Pro" model rates (same as reasoning).
ModelPricing {
model: "agentic-v1",
input_per_mtok_usd: 3.00,
cached_input_per_mtok_usd: 0.30,
output_per_mtok_usd: 15.00,
input_per_mtok_usd: 0.435,
cached_input_per_mtok_usd: 0.003625,
output_per_mtok_usd: 0.87,
},
// Coding tier — Sonnet-class.
// Coding tier — managed "Pro" model rates (same as reasoning).
ModelPricing {
model: "coding-v1",
input_per_mtok_usd: 3.00,
cached_input_per_mtok_usd: 0.30,
output_per_mtok_usd: 15.00,
input_per_mtok_usd: 0.435,
cached_input_per_mtok_usd: 0.003625,
output_per_mtok_usd: 0.87,
},
// Vision tier — multimodal Sonnet-class. Estimate only; the backend's
// echoed `charged_amount_usd` is authoritative when present.
// Vision tier — multimodal; estimate only. The backend's echoed
// `charged_amount_usd` is authoritative when present.
ModelPricing {
model: "vision-v1",
input_per_mtok_usd: 3.00,
@@ -236,8 +238,9 @@ mod tests {
#[test]
fn lookup_pricing_matches_canonical_tiers() {
assert_eq!(lookup_pricing("reasoning-v1").input_per_mtok_usd, 15.0);
assert_eq!(lookup_pricing("agentic-v1").output_per_mtok_usd, 15.0);
// Reasoning/agentic share the managed "Pro" rates.
assert_eq!(lookup_pricing("reasoning-v1").input_per_mtok_usd, 0.435);
assert_eq!(lookup_pricing("agentic-v1").output_per_mtok_usd, 0.87);
}
#[test]
@@ -257,7 +260,9 @@ mod tests {
#[test]
fn lookup_pricing_handles_concrete_vendor_names() {
assert_eq!(lookup_pricing("claude-opus-4.7").input_per_mtok_usd, 15.0);
// `claude-opus-4.7` (dotted, not a catalog id) resolves via the `opus`
// vendor heuristic to the reasoning tier ("Pro" rates).
assert_eq!(lookup_pricing("claude-opus-4.7").input_per_mtok_usd, 0.435);
assert_eq!(
lookup_pricing("claude-sonnet-4-6").output_per_mtok_usd,
15.0
@@ -275,11 +280,11 @@ mod tests {
#[test]
fn estimate_call_cost_subtracts_cached_input() {
// 1M standard input + 1M cached input + 1M output on agentic-v1.
// 1M standard input + 1M cached input + 1M output on agentic-v1 ("Pro").
let u = usage(2_000_000, 1_000_000, 1_000_000, 0.0);
let est = estimate_call_cost_usd("agentic-v1", &u);
// 1M * 3 + 1M * 0.3 + 1M * 15 = 18.3
assert!((est - 18.3).abs() < 1e-6, "got {est}");
// 1M*0.435 + 1M*0.003625 + 1M*0.87 = 1.308625
assert!((est - 1.308625).abs() < 1e-6, "got {est}");
}
#[test]
@@ -291,19 +296,19 @@ mod tests {
#[test]
fn call_cost_falls_back_to_estimate_when_charged_zero() {
let u = usage(1_000_000, 0, 0, 0.0);
// 1M input * 3 = 3
assert!((call_cost_usd("agentic-v1", &u) - 3.0).abs() < 1e-6);
// 1M input * 0.435 = 0.435
assert!((call_cost_usd("agentic-v1", &u) - 0.435).abs() < 1e-6);
}
#[test]
fn turn_cost_accumulates_charged_and_estimated_separately() {
let mut tc = TurnCost::new();
tc.add_call("reasoning-v1", &usage(0, 0, 0, 0.10));
tc.add_call("agentic-v1", &usage(1_000_000, 0, 0, 0.0)); // est: 3.00
tc.add_call("agentic-v1", &usage(1_000_000, 0, 0, 0.0)); // est: 0.435
assert_eq!(tc.call_count, 2);
assert!((tc.charged_usd - 0.10).abs() < 1e-6);
assert!((tc.estimated_usd - 3.0).abs() < 1e-6);
assert!((tc.total_usd() - 3.10).abs() < 1e-6);
assert!((tc.estimated_usd - 0.435).abs() < 1e-6);
assert!((tc.total_usd() - 0.535).abs() < 1e-6);
}
#[test]
+1
View File
@@ -47,6 +47,7 @@ pub(crate) mod tool_filter;
mod tool_loop;
pub(crate) mod tool_result_artifacts;
pub mod turn_attachments_context;
pub mod turn_subagent_usage;
pub mod worktree_context;
pub use definition::{
@@ -531,6 +531,7 @@ impl AgentBuilder {
auto_save: self.auto_save.unwrap_or(false),
last_memory_context: None,
last_turn_citations: Vec::new(),
last_turn_usage_totals: None,
history: Vec::new(),
post_turn_hooks: self.post_turn_hooks,
learning_enabled: self.learning_enabled,
@@ -347,6 +347,15 @@ impl Agent {
std::mem::take(&mut self.last_turn_citations)
}
/// Drain and return the holistic token/cost/context totals for the latest
/// completed turn (parent + sub-agents). `None` until a turn has run.
/// Consumed by web-channel delivery to populate the `chat_done` usage fields.
pub fn take_last_turn_usage_totals(
&mut self,
) -> Option<crate::openhuman::agent::harness::turn_subagent_usage::LastTurnUsage> {
self.last_turn_usage_totals.take()
}
// ─────────────────────────────────────────────────────────────────
// Static helpers for turn parsing + telemetry
// ─────────────────────────────────────────────────────────────────
@@ -54,7 +54,7 @@ use crate::openhuman::inference::provider::ChatMessage;
use crate::openhuman::inference::provider::ToolCall;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::fmt::Write as FmtWrite;
use std::fs;
use std::path::{Path, PathBuf};
@@ -620,6 +620,206 @@ pub fn find_root_transcript_for_thread(workspace_dir: &Path, thread_id: &str) ->
matches.pop()
}
/// Aggregated token/cost usage for a chat thread, summed across **all** of the
/// thread's root session transcripts (a thread reopened across days/restarts
/// produces several files). `last_turn_*`, `model`, and `updated` come from the
/// newest transcript so the UI can render a context-window gauge for the most
/// recent turn. Returns `None` when no transcript exists yet (a brand-new
/// thread with no completed turns).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ThreadUsageSummary {
/// Orchestrator (parent) token totals — the root transcript(s) only. Root
/// transcripts never include sub-agent calls (those go to a separate
/// observer + their own `__` transcript files); see [`Self::subagents`].
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_input_tokens: u64,
pub cost_usd: f64,
pub turn_count: usize,
/// Input/output tokens of the most recent assistant turn (context gauge).
pub last_turn_input_tokens: u64,
pub last_turn_output_tokens: u64,
/// Model that served the most recent turn, if recorded.
pub model: Option<String>,
/// RFC-3339 `updated` of the newest transcript.
pub updated: String,
/// Per-archetype sub-agent spend, reconstructed from the thread's `__`
/// sub-agent transcripts (grouped by `agent_name`).
pub subagents: Vec<SubagentArchetypeUsage>,
}
/// One sub-agent archetype's summed spend within a thread (e.g. all `coder`
/// runs). `model` is the model that served one of its runs, used to price it.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SubagentArchetypeUsage {
pub agent_id: String,
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_input_tokens: u64,
/// How many sub-agent runs of this archetype contributed.
pub runs: usize,
pub model: Option<String>,
}
/// Parse just the `_meta` header of a root transcript JSONL (cheap — stops at
/// the first non-empty line).
fn read_transcript_meta_only(path: &Path) -> Option<TranscriptMeta> {
let raw = fs::read_to_string(path).ok()?;
for line in raw.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let ml: MetaLine = serde_json::from_str(line).ok()?;
let mp = ml.meta;
return Some(TranscriptMeta {
agent_name: mp.agent,
agent_id: mp.agent_id,
agent_type: mp.agent_type,
dispatcher: mp.dispatcher,
provider: mp.provider,
model: mp.model,
created: mp.created,
updated: mp.updated,
turn_count: mp.turn_count,
input_tokens: mp.input_tokens,
output_tokens: mp.output_tokens,
cached_input_tokens: mp.cached_input_tokens,
charged_amount_usd: mp.charged_amount_usd,
thread_id: mp.thread_id,
task_id: mp.task_id,
});
}
None
}
/// Extract the last assistant message's usage + model from a transcript JSONL.
/// Only the final assistant message of a turn carries these (see the JSONL
/// format docs at the top of this module).
fn read_last_assistant_usage(path: &Path) -> Option<(MessageUsage, Option<String>)> {
let raw = fs::read_to_string(path).ok()?;
let mut result = None;
let mut seen_meta = false;
for line in raw.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if !seen_meta {
seen_meta = true; // first non-empty line is the `_meta` header
continue;
}
if let Ok(ml) = serde_json::from_str::<MessageLine>(line) {
if ml.role == "assistant" {
if let Some(usage) = ml.usage {
result = Some((usage, ml.model));
}
}
}
}
result
}
/// Summed token/cost usage for `thread_id` across its root transcripts, or
/// `None` when the thread has no persisted turns yet.
pub fn read_thread_usage_summary(
workspace_dir: &Path,
thread_id: &str,
) -> Option<ThreadUsageSummary> {
let thread_id = thread_id.trim();
if thread_id.is_empty() {
return None;
}
let raw_dir = raw_session_dir(workspace_dir);
let entries = fs::read_dir(&raw_dir).ok()?;
// Single scan: split the thread's transcripts into root (orchestrator) and
// `__` sub-agent files. Root totals stay the parent's; sub-agent files are
// grouped by archetype for the per-agent breakdown.
let mut root_matches: Vec<PathBuf> = Vec::new();
let mut sub_matches: Vec<PathBuf> = Vec::new();
for path in entries.flatten().map(|entry| entry.path()) {
if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let is_subagent = stem.contains("__");
let matches_thread = read_transcript_meta_only(&path)
.map(|m| m.thread_id.as_deref() == Some(thread_id))
.unwrap_or(false);
if !matches_thread {
continue;
}
if is_subagent {
sub_matches.push(path);
} else {
root_matches.push(path);
}
}
if root_matches.is_empty() && sub_matches.is_empty() {
return None;
}
root_matches.sort();
let mut summary = ThreadUsageSummary::default();
for path in &root_matches {
if let Some(meta) = read_transcript_meta_only(path) {
summary.input_tokens = summary.input_tokens.saturating_add(meta.input_tokens);
summary.output_tokens = summary.output_tokens.saturating_add(meta.output_tokens);
summary.cached_input_tokens = summary
.cached_input_tokens
.saturating_add(meta.cached_input_tokens);
summary.cost_usd += meta.charged_amount_usd;
summary.turn_count = summary.turn_count.saturating_add(meta.turn_count);
}
}
// Newest root transcript drives the last-turn gauge + model + updated stamp.
if let Some(newest) = root_matches.last() {
if let Some(meta) = read_transcript_meta_only(newest) {
summary.updated = meta.updated;
}
if let Some((usage, model)) = read_last_assistant_usage(newest) {
summary.last_turn_input_tokens = usage.input;
summary.last_turn_output_tokens = usage.output;
summary.model = model;
}
}
// Group sub-agent transcripts by archetype (`agent_name`).
let mut groups: BTreeMap<String, SubagentArchetypeUsage> = BTreeMap::new();
for path in &sub_matches {
let Some(meta) = read_transcript_meta_only(path) else {
continue;
};
let group =
groups
.entry(meta.agent_name.clone())
.or_insert_with(|| SubagentArchetypeUsage {
agent_id: meta.agent_name.clone(),
..Default::default()
});
group.input_tokens = group.input_tokens.saturating_add(meta.input_tokens);
group.output_tokens = group.output_tokens.saturating_add(meta.output_tokens);
group.cached_input_tokens = group
.cached_input_tokens
.saturating_add(meta.cached_input_tokens);
group.runs = group.runs.saturating_add(1);
if group.model.is_none() {
if let Some((_, model)) = read_last_assistant_usage(path) {
group.model = model;
}
}
}
summary.subagents = groups.into_values().collect();
Some(summary)
}
// ── Path resolution ──────────────────────────────────────────────────
/// Resolve a transcript path under `session_raw/{stem}.jsonl` — a
@@ -821,3 +821,158 @@ fn find_root_transcript_for_thread_excludes_subagent_files() {
found.display()
);
}
#[test]
fn read_thread_usage_summary_totals_last_turn_and_model() {
let ws = TempDir::new().unwrap();
let raw = raw_session_dir(ws.path());
std::fs::create_dir_all(&raw).unwrap();
let mut meta = sample_meta();
meta.thread_id = Some("thr-xyz".into());
meta.input_tokens = 5000;
meta.output_tokens = 1200;
meta.cached_input_tokens = 800;
meta.charged_amount_usd = 0.0045;
meta.turn_count = 3;
let tu = TurnUsage {
provider: "openhuman-backend".into(),
model: "reasoning-v1".into(),
usage: MessageUsage {
input: 400,
output: 120,
cached_input: 50,
context_window: 1_000_000,
cost_usd: 0.0009,
},
ts: "2026-04-11T14:35:22Z".into(),
reasoning_content: None,
tool_calls: Vec::new(),
iteration: 0,
};
let path = raw.join("1700000000_main.jsonl");
write_transcript(&path, &sample_messages(), &meta, Some(&tu)).unwrap();
let summary = read_thread_usage_summary(ws.path(), "thr-xyz").expect("summary present");
assert_eq!(summary.input_tokens, 5000);
assert_eq!(summary.output_tokens, 1200);
assert_eq!(summary.cached_input_tokens, 800);
assert!((summary.cost_usd - 0.0045).abs() < 1e-9);
assert_eq!(summary.turn_count, 3);
assert_eq!(summary.last_turn_input_tokens, 400);
assert_eq!(summary.last_turn_output_tokens, 120);
assert_eq!(summary.model.as_deref(), Some("reasoning-v1"));
}
#[test]
fn read_thread_usage_summary_sums_multiple_transcripts() {
let ws = TempDir::new().unwrap();
let raw = raw_session_dir(ws.path());
std::fs::create_dir_all(&raw).unwrap();
let mut mk = |stem: &str, input: u64, cost: f64| {
let mut meta = sample_meta();
meta.thread_id = Some("thr-multi".into());
meta.input_tokens = input;
meta.output_tokens = 0;
meta.cached_input_tokens = 0;
meta.charged_amount_usd = cost;
meta.turn_count = 1;
write_transcript(
&raw.join(format!("{stem}.jsonl")),
&sample_messages(),
&meta,
None,
)
.unwrap();
};
mk("1700000000_main", 100, 0.01);
mk("1700000100_main", 250, 0.02);
let s = read_thread_usage_summary(ws.path(), "thr-multi").expect("summary present");
assert_eq!(s.input_tokens, 350);
assert!((s.cost_usd - 0.03).abs() < 1e-9);
assert_eq!(s.turn_count, 2);
}
#[test]
fn read_thread_usage_summary_none_for_unknown_thread() {
let ws = TempDir::new().unwrap();
assert!(read_thread_usage_summary(ws.path(), "no-such-thread").is_none());
// Empty thread id is rejected too.
assert!(read_thread_usage_summary(ws.path(), " ").is_none());
}
#[test]
fn read_thread_usage_summary_groups_subagents_by_archetype() {
let ws = TempDir::new().unwrap();
let raw = raw_session_dir(ws.path());
std::fs::create_dir_all(&raw).unwrap();
// Root (orchestrator) transcript — never includes sub-agent calls.
let mut root = sample_meta();
root.thread_id = Some("thr-sub".into());
root.agent_name = "main".into();
root.input_tokens = 1000;
root.output_tokens = 200;
root.cached_input_tokens = 0;
root.charged_amount_usd = 0.0;
root.turn_count = 2;
write_transcript(
&raw.join("1700000000_main.jsonl"),
&sample_messages(),
&root,
None,
)
.unwrap();
// Sub-agent transcripts (stems contain `__`): coder x2 + researcher x1.
let mut sub = |stem: &str, agent: &str, input: u64, output: u64| {
let mut m = sample_meta();
m.thread_id = Some("thr-sub".into());
m.agent_name = agent.into();
m.input_tokens = input;
m.output_tokens = output;
m.cached_input_tokens = 0;
m.charged_amount_usd = 0.0;
m.turn_count = 1;
write_transcript(
&raw.join(format!("{stem}.jsonl")),
&sample_messages(),
&m,
None,
)
.unwrap();
};
sub("1700000000_main__1700000001_coder", "coder", 300, 60);
sub("1700000000_main__1700000002_coder", "coder", 100, 20);
sub(
"1700000000_main__1700000003_researcher",
"researcher",
500,
90,
);
let s = read_thread_usage_summary(ws.path(), "thr-sub").expect("summary present");
// Root totals are orchestrator-only (sub-agents are separate).
assert_eq!(s.input_tokens, 1000);
assert_eq!(s.output_tokens, 200);
// Grouped by archetype.
assert_eq!(s.subagents.len(), 2);
let coder = s
.subagents
.iter()
.find(|g| g.agent_id == "coder")
.expect("coder group");
assert_eq!(coder.input_tokens, 400);
assert_eq!(coder.output_tokens, 80);
assert_eq!(coder.runs, 2);
let researcher = s
.subagents
.iter()
.find(|g| g.agent_id == "researcher")
.expect("researcher group");
assert_eq!(researcher.input_tokens, 500);
assert_eq!(researcher.runs, 1);
}
@@ -803,7 +803,8 @@ impl Agent {
crate::openhuman::agent::multimodal::extract_image_placeholders_in_text(
user_message,
);
let outcome =
let (outcome_result, subagent_usage_entries) =
super::super::super::turn_subagent_usage::with_turn_collector(
super::super::super::turn_attachments_context::with_current_turn_image_placeholders(
turn_image_placeholders,
super::super::super::model_vision_context::with_current_model_vision(
@@ -829,18 +830,64 @@ impl Agent {
None, // main agent compacts via its ContextManager in before_dispatch
)),
),
),
)
.await?;
.await;
let outcome = outcome_result?;
// Pull the observer's accounting out, then drop it to release the
// `&mut self` borrow so the epilogue can use `self`.
let did_push_final = observer.did_push_final;
let cumulative_input = observer.cumulative_input;
let cumulative_output = observer.cumulative_output;
let cumulative_cached = observer.cumulative_cached;
let cumulative_charged = observer.cumulative_charged;
let mut cumulative_input = observer.cumulative_input;
let mut cumulative_output = observer.cumulative_output;
let mut cumulative_cached = observer.cumulative_cached;
let mut cumulative_charged = observer.cumulative_charged;
let last_turn_usage = observer.last_turn_usage.take();
drop(observer);
// Roll any sub-agent spend gathered during this turn into the
// session-level token/cost meters so the UI footer reflects the
// *holistic* cost (parent + delegated children). The global cost
// tracker is fed separately, per provider call, by each sub-agent's
// observer. `subagent_usage_entries` is also forwarded to the
// `chat_done` event for the per-child hover breakdown.
if !subagent_usage_entries.is_empty() {
let mut sub_input = 0u64;
let mut sub_output = 0u64;
let mut sub_cached = 0u64;
let mut sub_charged = 0.0f64;
for entry in &subagent_usage_entries {
sub_input += entry.usage.input_tokens;
sub_output += entry.usage.output_tokens;
sub_cached += entry.usage.cached_input_tokens;
sub_charged += entry.usage.charged_amount_usd;
}
tracing::debug!(
subagents = subagent_usage_entries.len(),
sub_input,
sub_output,
sub_charged,
"[agent_loop] folding sub-agent spend into turn totals"
);
cumulative_input += sub_input;
cumulative_output += sub_output;
cumulative_cached += sub_cached;
cumulative_charged += sub_charged;
}
// Capture the turn's holistic totals (parent + sub-agents) so the
// web-channel delivery layer can forward them on `chat_done` for the
// UI footer's session token / cost / context meters.
self.last_turn_usage_totals = Some(
crate::openhuman::agent::harness::turn_subagent_usage::LastTurnUsage {
input_tokens: cumulative_input,
output_tokens: cumulative_output,
cached_input_tokens: cumulative_cached,
cost_usd: cumulative_charged,
context_window: turn_context_window.unwrap_or(0),
subagents: subagent_usage_entries,
},
);
let records = std::mem::take(&mut tool_source.records);
self.context.record_tool_calls(records.len());
@@ -309,10 +309,17 @@ impl TurnObserver for AgentObserver<'_> {
fn record_usage(&mut self, provider: &str, model: &str, usage: &UsageInfo) {
self.agent.context.record_usage(usage);
crate::openhuman::cost::record_provider_usage(model, usage);
// Effective per-call cost: the backend-charged amount when the provider
// echoes one, else the per-model catalog estimate (#4124). Using this
// instead of raw `charged_amount_usd` means BYO/local providers that
// never bill a charge still contribute a priced cost to the session
// total. `cumulative_charged` is therefore the *net cost*, not strictly
// the backend charge.
let call_cost = crate::openhuman::agent::cost::call_cost_usd(model, usage);
self.cumulative_input += usage.input_tokens;
self.cumulative_output += usage.output_tokens;
self.cumulative_cached += usage.cached_input_tokens;
self.cumulative_charged += usage.charged_amount_usd;
self.cumulative_charged += call_cost;
self.last_turn_usage = Some(transcript::TurnUsage {
provider: provider.to_string(),
model: model.to_string(),
@@ -321,7 +328,7 @@ impl TurnObserver for AgentObserver<'_> {
output: usage.output_tokens,
cached_input: usage.cached_input_tokens,
context_window: usage.context_window,
cost_usd: usage.charged_amount_usd,
cost_usd: call_cost,
},
ts: chrono::Utc::now().to_rfc3339(),
reasoning_content: None,
@@ -70,6 +70,12 @@ pub struct Agent {
/// Citation metadata collected from memory recall for the most recent turn.
/// Consumed by web-channel delivery to render source chips in the UI.
pub(super) last_turn_citations: Vec<crate::openhuman::agent::memory_loader::MemoryCitation>,
/// Holistic token/cost/context accounting for the most recent turn (parent +
/// any sub-agents spawned during it). Consumed by web-channel delivery to
/// surface session token/cost/context meters in the UI footer. `None` until
/// the first turn completes.
pub(super) last_turn_usage_totals:
Option<crate::openhuman::agent::harness::turn_subagent_usage::LastTurnUsage>,
pub(super) history: Vec<ConversationMessage>,
pub(super) post_turn_hooks: Vec<Arc<dyn PostTurnHook>>,
pub(super) learning_enabled: bool,
@@ -42,7 +42,7 @@ pub use autonomous::{autonomous_iter_cap, with_autonomous_iter_cap};
pub use ops::run_subagent;
pub use types::{
SubagentCheckpointData, SubagentMode, SubagentRunError, SubagentRunOptions, SubagentRunOutcome,
SubagentRunStatus,
SubagentRunStatus, SubagentUsage,
};
// Crate-internal re-exports: `agent::debug` calls the text-mode protocol
@@ -18,6 +18,9 @@ pub(super) struct SubagentObserver {
pub(super) task_id: String,
pub(super) force_text_mode: bool,
pub(super) usage: AggregatedUsage,
/// Provenance + usage of the sub-agent's most recent provider call. Persisted
/// onto the transcript's last assistant message (carries provider + model so
/// per-thread usage reads can price the sub-agent at its actual model).
pub(super) last_turn_usage: Option<transcript::TurnUsage>,
}
@@ -109,22 +112,36 @@ impl super::super::super::engine::TurnObserver for SubagentObserver {
fn record_usage(
&mut self,
provider: &str,
_model: &str,
model: &str,
usage: &crate::openhuman::inference::provider::UsageInfo,
) {
self.usage.input_tokens += usage.input_tokens;
self.usage.output_tokens += usage.output_tokens;
self.usage.cached_input_tokens += usage.cached_input_tokens;
self.usage.charged_amount_usd += usage.charged_amount_usd;
// Effective per-call cost: backend-charged when present, else the
// per-model catalog estimate (#4124) — so a sub-agent on a BYO/local
// provider that bills no charge still contributes a priced cost to the
// parent turn's net total. `charged_amount_usd` here is the *net cost*,
// matching the parent adapter's convention.
let call_cost = crate::openhuman::agent::cost::call_cost_usd(model, usage);
self.usage.charged_amount_usd += call_cost;
// Mirror the parent adapter: feed every sub-agent provider call into the
// global cost tracker so the cost dashboard/summary reflects delegated
// spend (previously sub-agent tokens were invisible to it). The per-turn
// rollup into the UI footer happens separately via `turn_subagent_usage`.
crate::openhuman::cost::record_provider_usage(model, usage);
// Provenance for the transcript's last assistant message (#4134): keeps
// the provider + model so per-thread usage reads can price the sub-agent
// at its actual model.
self.last_turn_usage = Some(transcript::TurnUsage {
provider: provider.to_string(),
model: _model.to_string(),
model: model.to_string(),
usage: transcript::MessageUsage {
input: usage.input_tokens,
output: usage.output_tokens,
cached_input: usage.cached_input_tokens,
context_window: usage.context_window,
cost_usd: usage.charged_amount_usd,
cost_usd: call_cost,
},
ts: chrono::Utc::now().to_rfc3339(),
reasoning_content: None,
@@ -794,7 +794,7 @@ async fn run_typed_mode(
model_vision,
"[subagent_runner] resolved sub-agent model vision capability"
);
let (output, iterations, _agg_usage, early_exit_tool) = Box::pin(run_inner_loop(
let (output, iterations, agg_usage, early_exit_tool) = Box::pin(run_inner_loop(
subagent_provider.as_ref(),
&mut history,
&parent.all_tools,
@@ -884,6 +884,22 @@ async fn run_typed_mode(
crate::openhuman::agent::harness::subagent_runner::types::SubagentRunStatus::Completed
};
// Surface this run's token/cost totals so the parent turn can roll them
// into the session-level meters and the global cost tracker. Also push the
// breakdown into any active turn-scoped collector (see
// `turn_subagent_usage`) so a delegating parent attributes per-child spend.
let usage = crate::openhuman::agent::harness::subagent_runner::types::SubagentUsage {
input_tokens: agg_usage.input_tokens,
output_tokens: agg_usage.output_tokens,
cached_input_tokens: agg_usage.cached_input_tokens,
charged_amount_usd: agg_usage.charged_amount_usd,
};
crate::openhuman::agent::harness::turn_subagent_usage::record_subagent_usage(
task_id,
&definition.id,
usage,
);
Ok(SubagentRunOutcome {
task_id: task_id.to_string(),
agent_id: definition.id.clone(),
@@ -893,5 +909,6 @@ async fn run_typed_mode(
mode: SubagentMode::Typed,
status,
final_history: history,
usage,
})
}
@@ -112,6 +112,24 @@ pub struct SubagentRunOutcome {
/// sessions persist this so an idle worker can resume without rebuilding
/// its context from only the parent transcript.
pub final_history: Vec<ChatMessage>,
/// Token + cost accounting accumulated across every provider call this
/// sub-agent made. Surfaced so the parent turn can roll child spend into
/// the session totals (tokens + USD) and the global cost tracker. See
/// [`SubagentUsage`].
pub usage: SubagentUsage,
}
/// Token + cost totals for a single sub-agent run.
///
/// Mirrors the inner-loop `AggregatedUsage`, lifted into the public outcome so
/// the parent turn can fold sub-agent spend into the session-level token/cost
/// meters surfaced in the UI footer.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct SubagentUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_input_tokens: u64,
pub charged_amount_usd: f64,
}
/// Which prompt-construction path the runner took for a sub-agent.
@@ -0,0 +1,174 @@
//! Task-local collector for **sub-agent token/cost spend within the current
//! turn**.
//!
//! When a parent agent delegates to a sub-agent mid-turn, the sub-agent's
//! provider calls are accounted by its own [`SubagentObserver`] and never reach
//! the parent's `AgentObserver` cumulative totals (the two run on separate
//! observers). Without a bridge, sub-agent spend is invisible to the
//! session-level token/cost meters surfaced in the UI footer, and to the global
//! cost tracker.
//!
//! This module installs an [`Arc<Mutex<Vec<SubagentUsageEntry>>>`] as a
//! task-local around the parent's `run_turn_engine` call. Synchronous
//! delegations (`spawn_subagent`) run inline on the same tokio task, so the
//! sub-agent runner can [`record_subagent_usage`] its totals into the active
//! collector. After the engine returns, the parent [`drain`]s the collector to:
//!
//! 1. fold child tokens + USD into the turn's cumulative meters, and
//! 2. attribute per-child spend for the `chat_done` breakdown (hover detail).
//!
//! Background / async sub-agents (`spawn_async_subagent`) run on detached tasks
//! that do **not** inherit this task-local; their spend completes after the
//! parent turn's `chat_done` and is captured by the global cost tracker
//! instead. [`current_collector`] returns `None` outside any scope (CLI /
//! direct invocation / tests), so recording is strictly additive.
use std::sync::{Arc, Mutex};
use super::subagent_runner::SubagentUsage;
/// One sub-agent's spend, tagged with its identity for the per-child breakdown.
#[derive(Debug, Clone, PartialEq)]
pub struct SubagentUsageEntry {
pub task_id: String,
pub agent_id: String,
pub usage: SubagentUsage,
}
/// Holistic token/cost accounting for a single completed turn, including any
/// sub-agent spend rolled in. Captured on the session at turn end and consumed
/// by the web-channel delivery layer, which forwards it on the `chat_done`
/// event so the UI footer can show session tokens, context-window utilisation,
/// USD cost, and a per-sub-agent hover breakdown.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct LastTurnUsage {
/// Input (prompt) tokens for the turn, parent + sub-agents.
pub input_tokens: u64,
/// Output (completion) tokens for the turn, parent + sub-agents.
pub output_tokens: u64,
/// Cached-input tokens for the turn, parent + sub-agents.
pub cached_input_tokens: u64,
/// USD cost for the turn (backend-charged where available, else estimated),
/// parent + sub-agents.
pub cost_usd: f64,
/// The model's context window for this turn (`0` when unknown, e.g. a cloud
/// model whose window the core couldn't resolve). Lets the UI show real
/// context utilisation instead of a hard-coded default.
pub context_window: u64,
/// Per-sub-agent spend gathered during the turn, for the hover breakdown.
pub subagents: Vec<SubagentUsageEntry>,
}
/// Shared, mutable list of sub-agent spend gathered during one parent turn.
pub type TurnSubagentUsage = Arc<Mutex<Vec<SubagentUsageEntry>>>;
tokio::task_local! {
/// Active per-turn sub-agent usage collector, installed around the parent's
/// `run_turn_engine` call. Absent outside a turn scope.
static TURN_SUBAGENT_USAGE: TurnSubagentUsage;
}
/// The collector active for the current turn, or `None` when no scope is in
/// effect (e.g. a sub-agent running on a detached background task, or a direct
/// CLI invocation).
pub fn current_collector() -> Option<TurnSubagentUsage> {
TURN_SUBAGENT_USAGE.try_with(|c| c.clone()).ok()
}
/// Record a finished sub-agent's token/cost totals into the active turn
/// collector. No-op when there is no active scope. Called by the sub-agent
/// runner once it has the run's aggregated usage.
pub fn record_subagent_usage(task_id: &str, agent_id: &str, usage: SubagentUsage) {
let Some(collector) = current_collector() else {
tracing::trace!(
task_id,
agent_id,
"[turn_subagent_usage] no active collector — sub-agent spend not rolled into parent turn"
);
return;
};
let entry = SubagentUsageEntry {
task_id: task_id.to_string(),
agent_id: agent_id.to_string(),
usage,
};
// Recover the inner vec on poison (a panic in another sub-agent) rather than
// dropping this turn's accounting entirely.
let mut guard = collector.lock().unwrap_or_else(|poisoned| {
tracing::warn!(
task_id,
agent_id,
"[turn_subagent_usage] collector mutex poisoned — recovering"
);
poisoned.into_inner()
});
tracing::debug!(
task_id,
agent_id,
input_tokens = usage.input_tokens,
output_tokens = usage.output_tokens,
charged_usd = usage.charged_amount_usd,
"[turn_subagent_usage] recorded sub-agent spend into parent turn"
);
guard.push(entry);
}
/// Run `future` with a fresh sub-agent usage collector installed, returning both
/// the future's output and the gathered per-child entries. Intended call site is
/// around the parent agent's `run_turn_engine` invocation.
pub async fn with_turn_collector<F, R>(future: F) -> (R, Vec<SubagentUsageEntry>)
where
F: std::future::Future<Output = R>,
{
let collector: TurnSubagentUsage = Arc::new(Mutex::new(Vec::new()));
let out = TURN_SUBAGENT_USAGE.scope(collector.clone(), future).await;
let entries = collector
.lock()
.map(|g| g.clone())
.unwrap_or_else(|p| p.into_inner().clone());
(out, entries)
}
#[cfg(test)]
mod tests {
use super::*;
fn usage(input: u64, output: u64, usd: f64) -> SubagentUsage {
SubagentUsage {
input_tokens: input,
output_tokens: output,
cached_input_tokens: 0,
charged_amount_usd: usd,
}
}
#[tokio::test]
async fn no_collector_outside_scope() {
assert!(current_collector().is_none());
// Must not panic when there is nothing to record into.
record_subagent_usage("t1", "researcher", usage(10, 5, 0.01));
}
#[tokio::test]
async fn collects_entries_within_scope() {
let ((), entries) = with_turn_collector(async {
record_subagent_usage("t1", "researcher", usage(10, 5, 0.01));
record_subagent_usage("t2", "coder", usage(20, 8, 0.02));
})
.await;
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].task_id, "t1");
assert_eq!(entries[0].usage.input_tokens, 10);
assert_eq!(entries[1].agent_id, "coder");
assert_eq!(entries[1].usage.charged_amount_usd, 0.02);
}
#[tokio::test]
async fn scope_does_not_leak() {
let _ = with_turn_collector(async {
record_subagent_usage("t1", "researcher", usage(1, 1, 0.0));
})
.await;
assert!(current_collector().is_none());
}
}
@@ -227,6 +227,9 @@ pub(super) async fn run_autonomous(
response,
prompt,
&[],
// Background/cron turns don't surface in the chat footer; their
// token/cost spend is still captured by the global cost tracker.
None,
)
.await;
}
@@ -859,6 +859,7 @@ mod tests {
mode: SubagentMode::Typed,
status: SubagentRunStatus::Completed,
final_history: Vec::new(),
usage: Default::default(),
}
}
+1
View File
@@ -215,6 +215,7 @@ impl EventHandler for ProactiveMessageSubscriber {
task_board: None,
tool_display_label: None,
tool_display_detail: None,
usage: None,
});
// 2. If an active external channel is configured, deliver there too.
@@ -9,7 +9,8 @@
//! 2. **Emoji reactions** — decide whether the assistant should react to the
//! user's message with an emoji.
use crate::core::socketio::WebChannelEvent;
use crate::core::socketio::{SubagentUsagePayload, TurnUsagePayload, WebChannelEvent};
use crate::openhuman::agent::harness::turn_subagent_usage::LastTurnUsage;
use crate::openhuman::config::rpc as config_rpc;
use super::web::publish_web_channel_event;
@@ -17,6 +18,32 @@ use super::web::publish_web_channel_event;
const MIN_SEGMENT_CHARS: usize = 40;
const MAX_SEGMENTS: usize = 5;
/// Convert a turn's [`LastTurnUsage`] into the wire payload carried on
/// `chat_done`. Returns `None` for a turn that recorded no spend at all (e.g. a
/// synthetic budget-exhausted placeholder) so the event stays compact.
fn usage_payload(usage: Option<&LastTurnUsage>) -> Option<TurnUsagePayload> {
let usage = usage?;
let subagents = usage
.subagents
.iter()
.map(|s| SubagentUsagePayload {
task_id: s.task_id.clone(),
agent_id: s.agent_id.clone(),
input_tokens: s.usage.input_tokens,
output_tokens: s.usage.output_tokens,
cost_usd: s.usage.charged_amount_usd,
})
.collect();
Some(TurnUsagePayload {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cached_input_tokens: usage.cached_input_tokens,
cost_usd: usage.cost_usd,
context_window: usage.context_window,
subagents,
})
}
/// Deliver an agent response to the frontend, applying local-model
/// presentation (segmentation + reaction) when the model is available.
///
@@ -30,7 +57,10 @@ pub async fn deliver_response(
full_response: &str,
user_message: &str,
citations: &[crate::openhuman::agent::memory_loader::MemoryCitation],
usage: Option<&LastTurnUsage>,
) {
let usage_payload = usage_payload(usage);
// Spawn reaction decision in parallel — it runs on the local model and
// shouldn't block segmentation or delivery.
let user_msg_owned = user_message.to_string();
@@ -78,6 +108,7 @@ pub async fn deliver_response(
} else {
Some(serde_json::json!(citations))
},
usage: usage_payload,
});
return;
}
@@ -126,6 +157,8 @@ pub async fn deliver_response(
} else {
None
},
// Usage is attached only to the terminal `chat_done`, never segments.
usage: None,
});
}
@@ -164,6 +197,7 @@ pub async fn deliver_response(
} else {
Some(serde_json::json!(citations))
},
usage: usage_payload,
});
}
@@ -466,6 +500,7 @@ pub mod test_support {
full_response,
user_message,
citations,
None,
)
.await;
}
@@ -609,6 +609,7 @@ pub async fn start_chat(
&chat_result.full_response,
&user_message,
&chat_result.citations,
chat_result.usage.as_ref(),
)
.await;
}
@@ -813,6 +814,7 @@ async fn spawn_parallel_turn(
&chat_result.full_response,
&user_message,
&chat_result.citations,
chat_result.usage.as_ref(),
)
.await;
}
@@ -237,9 +237,11 @@ pub(crate) async fn run_chat_task(
// later genuine empty response. See #3386.
super::ops::clear_budget_signal(thread_id).await;
let citations = agent.take_last_turn_citations();
let usage = agent.take_last_turn_usage_totals();
Ok(WebChatTaskResult {
full_response: response,
citations,
usage,
})
}
Err(err) => {
@@ -269,6 +271,7 @@ pub(crate) async fn run_chat_task(
Ok(WebChatTaskResult {
full_response: inference_budget_exceeded_user_message().to_string(),
citations: Vec::new(),
usage: None,
})
}
BudgetCorrelation::UpgradeEmptyToBudget => {
@@ -286,6 +289,7 @@ pub(crate) async fn run_chat_task(
Ok(WebChatTaskResult {
full_response: inference_budget_exceeded_user_message().to_string(),
citations: Vec::new(),
usage: None,
})
}
BudgetCorrelation::PassThrough => Err(err_message),
@@ -408,6 +412,7 @@ mod tests {
Ok(WebChatTaskResult {
full_response: "hello".to_string(),
citations: Vec::new(),
usage: None,
})
}
@@ -68,6 +68,10 @@ pub(super) struct ParallelEntry {
pub(super) struct WebChatTaskResult {
pub(super) full_response: String,
pub(super) citations: Vec<crate::openhuman::agent::memory_loader::MemoryCitation>,
/// Holistic token/cost/context totals for the turn (parent + sub-agents),
/// forwarded to the frontend on `chat_done`. `None` for synthetic results
/// (e.g. budget-exhausted placeholders) that never ran a real turn.
pub(super) usage: Option<crate::openhuman::agent::harness::turn_subagent_usage::LastTurnUsage>,
}
/// Per-request metadata carried alongside a chat send. Currently used by the
+5 -2
View File
@@ -11,6 +11,8 @@ use crate::openhuman::config::{
/// Conservative default for OpenHuman abstract tier models (tokens).
const TIER_LARGE_CONTEXT: u64 = 200_000;
/// Reasoning tier — backed by a 1M-context model.
const TIER_REASONING_CONTEXT: u64 = 1_000_000;
const TIER_STANDARD_CONTEXT: u64 = 128_000;
const TIER_LOCAL_CONTEXT: u64 = 8_192;
@@ -121,7 +123,8 @@ pub fn context_window_for_model(model: &str) -> Option<u64> {
fn tier_context_window(model: &str) -> Option<u64> {
match model {
MODEL_REASONING_V1 | MODEL_AGENTIC_V1 | MODEL_CODING_V1 => Some(TIER_LARGE_CONTEXT),
MODEL_REASONING_V1 => Some(TIER_REASONING_CONTEXT),
MODEL_AGENTIC_V1 | MODEL_CODING_V1 => Some(TIER_LARGE_CONTEXT),
"summarization-v1" => Some(TIER_SUMMARIZATION_CONTEXT),
MODEL_CHAT_V1 | MODEL_REASONING_QUICK_V1 | "chat" => Some(TIER_STANDARD_CONTEXT),
m if m.starts_with("gemma") || m.contains(":1b") || m.contains("270m") => {
@@ -271,7 +274,7 @@ mod tests {
#[test]
fn tier_aliases_resolve() {
assert_eq!(context_window_for_model("reasoning-v1"), Some(200_000));
assert_eq!(context_window_for_model("reasoning-v1"), Some(1_000_000));
assert_eq!(context_window_for_model("agentic-v1"), Some(200_000));
assert_eq!(context_window_for_model("chat-v1"), Some(128_000));
assert_eq!(
+158
View File
@@ -664,6 +664,164 @@ pub async fn turn_state_clear(
Ok(envelope(ClearTurnStateResponse { cleared }, None, None))
}
/// Request for [`token_usage`]: the thread whose persisted usage to total.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ThreadTokenUsageRequest {
pub thread_id: String,
}
/// Aggregated token/cost usage for one thread, read back from its persisted
/// session transcripts. Seeds the UI footer when the user selects a thread so
/// the totals reflect prior turns instead of starting at zero.
#[derive(Debug, Clone, serde::Serialize)]
pub struct ThreadTokenUsageResponse {
pub thread_id: String,
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_input_tokens: u64,
pub cost_usd: f64,
pub turn_count: usize,
/// Tokens of the most recent turn — numerator for the context-window gauge.
pub last_turn_input_tokens: u64,
pub last_turn_output_tokens: u64,
/// Context window (tokens) inferred from the last model; `0` when unknown.
pub context_window: u64,
pub model: Option<String>,
pub updated: Option<String>,
/// `false` when the thread has no persisted turns yet (all zeros).
pub has_usage: bool,
/// Per-archetype sub-agent spend (re-audited at current pricing). The
/// top-level totals already include this; it's broken out for the UI's
/// per-agent footer rows.
pub subagents: Vec<SubagentUsageDto>,
}
/// One sub-agent archetype's contribution within a thread.
#[derive(Debug, Clone, serde::Serialize)]
pub struct SubagentUsageDto {
pub agent_id: String,
pub input_tokens: u64,
pub output_tokens: u64,
pub cost_usd: f64,
pub runs: usize,
}
/// Total a thread's persisted token/cost usage across its root transcripts.
pub async fn token_usage(
request: ThreadTokenUsageRequest,
) -> Result<RpcOutcome<ApiEnvelope<ThreadTokenUsageResponse>>, String> {
let dir = workspace_dir().await?;
let summary = crate::openhuman::agent::harness::session::transcript::read_thread_usage_summary(
&dir,
&request.thread_id,
);
// Re-audit cost at CURRENT pricing rather than trusting the
// `charged_amount_usd` persisted in the transcript: those values were
// stamped at turn time and don't reflect later tier-pricing corrections.
// Recompute from the persisted token counts using the last-known model's
// rates; falls back to `fallback` only when the model is unknown.
let audit_cost =
|model: Option<&str>, input: u64, output: u64, cached: u64, fallback: f64| match model {
Some(m) => crate::openhuman::agent::cost::estimate_call_cost_usd(
m,
&crate::openhuman::inference::provider::UsageInfo {
input_tokens: input,
output_tokens: output,
cached_input_tokens: cached,
..Default::default()
},
),
None => fallback,
};
let response = match summary {
Some(s) => {
let context_window = s
.model
.as_deref()
.and_then(crate::openhuman::inference::model_context::context_window_for_model)
.unwrap_or(0);
// Orchestrator (root) spend, re-audited.
let orchestrator_cost = audit_cost(
s.model.as_deref(),
s.input_tokens,
s.output_tokens,
s.cached_input_tokens,
s.cost_usd,
);
// Sub-agent archetypes, each re-audited with its own model. Older
// sub-agent transcripts didn't persist a model on their messages, so
// fall back to the thread's (root) model rather than pricing them at
// $0 — sub-agents usually run on the same managed tier as the parent.
let mut subagents = Vec::with_capacity(s.subagents.len());
let (mut sub_in, mut sub_out, mut sub_cached, mut sub_cost) = (0u64, 0u64, 0u64, 0.0);
for g in &s.subagents {
let sub_model = g.model.as_deref().or(s.model.as_deref());
let cost = audit_cost(
sub_model,
g.input_tokens,
g.output_tokens,
g.cached_input_tokens,
0.0,
);
sub_in = sub_in.saturating_add(g.input_tokens);
sub_out = sub_out.saturating_add(g.output_tokens);
sub_cached = sub_cached.saturating_add(g.cached_input_tokens);
sub_cost += cost;
subagents.push(SubagentUsageDto {
agent_id: g.agent_id.clone(),
input_tokens: g.input_tokens,
output_tokens: g.output_tokens,
cost_usd: cost,
runs: g.runs,
});
}
// Top-level totals = orchestrator + all sub-agents.
ThreadTokenUsageResponse {
thread_id: request.thread_id.clone(),
input_tokens: s.input_tokens.saturating_add(sub_in),
output_tokens: s.output_tokens.saturating_add(sub_out),
cached_input_tokens: s.cached_input_tokens.saturating_add(sub_cached),
cost_usd: orchestrator_cost + sub_cost,
turn_count: s.turn_count,
last_turn_input_tokens: s.last_turn_input_tokens,
last_turn_output_tokens: s.last_turn_output_tokens,
context_window,
model: s.model,
updated: Some(s.updated),
has_usage: true,
subagents,
}
}
None => ThreadTokenUsageResponse {
thread_id: request.thread_id.clone(),
input_tokens: 0,
output_tokens: 0,
cached_input_tokens: 0,
cost_usd: 0.0,
turn_count: 0,
last_turn_input_tokens: 0,
last_turn_output_tokens: 0,
context_window: 0,
model: None,
updated: None,
has_usage: false,
subagents: Vec::new(),
},
};
let has_usage = response.has_usage;
Ok(envelope(
response,
Some(counts([("has_usage", usize::from(has_usage))])),
None,
))
}
#[cfg(test)]
#[path = "ops_tests.rs"]
mod tests;
+29
View File
@@ -34,6 +34,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("turn_state_clear"),
schemas("task_board_get"),
schemas("task_board_put"),
schemas("token_usage"),
]
}
@@ -103,6 +104,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("task_board_put"),
handler: handle_task_board_put,
},
RegisteredController {
schema: schemas("token_usage"),
handler: handle_token_usage,
},
]
}
@@ -450,6 +455,23 @@ pub fn schemas(function: &str) -> ControllerSchema {
required: true,
}],
},
"token_usage" => ControllerSchema {
namespace: "threads",
function: "token_usage",
description: "Total a thread's persisted token/cost usage from its session transcripts.",
inputs: vec![FieldSchema {
name: "thread_id",
ty: TypeSchema::String,
comment: "Thread identifier.",
required: true,
}],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "Envelope with the thread's token/cost totals (zeros when no turns yet).",
required: true,
}],
},
_other => ControllerSchema {
namespace: "threads",
function: "unknown",
@@ -647,6 +669,13 @@ fn handle_task_board_put(params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_token_usage(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let p = parse::<ops::ThreadTokenUsageRequest>(params)?;
to_json(ops::token_usage(p).await?)
})
}
// ── Helpers ──────────────────────────────────────────────────────────
fn parse<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
+124
View File
@@ -13264,3 +13264,127 @@ async fn poll_team_task_status(rpc_base: &str, team_id: &str, task_id: &str, wan
}
false
}
/// End-to-end: plant a thread's session transcript on disk, then verify the
/// `openhuman.threads_token_usage` RPC reads back the correct cumulative token
/// totals, cost, last-turn usage, model, and inferred context window — the data
/// the composer footer seeds itself from when a thread is selected.
#[tokio::test]
async fn json_rpc_threads_token_usage_reads_persisted_thread_totals() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let workspace = home.join("workspace");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &workspace);
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
write_min_config(&openhuman_home, "http://127.0.0.1:9");
// Plant a root session transcript for thread "thr-e2e": a `_meta` header with
// the cumulative totals + an assistant message carrying last-turn usage/model.
let raw = workspace.join("session_raw");
std::fs::create_dir_all(&raw).expect("mkdir session_raw");
let jsonl = format!(
"{}\n{}\n{}\n",
json!({"_meta": {
"agent": "main", "dispatcher": "native",
"created": "2026-04-11T14:30:00Z", "updated": "2026-04-11T14:35:22Z",
"turn_count": 2, "input_tokens": 4200, "output_tokens": 900,
"cached_input_tokens": 600, "charged_amount_usd": 0.0123, "thread_id": "thr-e2e"
}}),
json!({"role": "user", "content": "hi"}),
json!({"role": "assistant", "content": "hello", "model": "reasoning-v1",
"usage": {"input": 350, "output": 80, "cached_input": 40, "cost_usd": 0.0009},
"ts": "2026-04-11T14:35:22Z"}),
);
std::fs::write(raw.join("1700000000_main.jsonl"), jsonl).expect("write transcript");
// A sub-agent transcript (stem contains `__`) for the SAME thread: a `coder`
// archetype. Its message carries NO model (mirroring how sub-agent
// transcripts were historically written), so pricing must fall back to the
// thread's model rather than $0. Its spend is grouped under `coder` and
// folded into the thread totals — not collapsed into the orchestrator.
let sub_jsonl = format!(
"{}\n{}\n",
json!({"_meta": {
"agent": "coder", "dispatcher": "native",
"created": "2026-04-11T14:33:00Z", "updated": "2026-04-11T14:34:00Z",
"turn_count": 1, "input_tokens": 1000, "output_tokens": 200,
"cached_input_tokens": 0, "charged_amount_usd": 0.0, "thread_id": "thr-e2e"
}}),
json!({"role": "assistant", "content": "done"}),
);
std::fs::write(
raw.join("1700000000_main__1700000050_coder.jsonl"),
sub_jsonl,
)
.expect("write subagent transcript");
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{rpc_addr}");
// Known thread → totals read back from the transcripts (orchestrator + coder).
let resp = post_json_rpc(
&rpc_base,
1,
"openhuman.threads_token_usage",
json!({ "thread_id": "thr-e2e" }),
)
.await;
let envelope = assert_no_jsonrpc_error(&resp, "threads_token_usage");
let data = envelope
.get("data")
.unwrap_or_else(|| panic!("missing data envelope: {envelope}"));
// Top-level totals include the sub-agent: 4200+1000 in, 900+200 out.
assert_eq!(data["input_tokens"], 5200);
assert_eq!(data["output_tokens"], 1100);
assert_eq!(data["cached_input_tokens"], 600);
// Cost is RE-AUDITED at current pricing, NOT the stale persisted charge.
// The coder sub-agent has no model, so it's priced at the thread's model
// (reasoning-v1 = "Pro"), NOT $0.
// orchestrator: (4200-600)*0.435 + 600*0.003625 + 900*0.87 = 0.002351175
// coder: 1000*0.435 + 0 + 200*0.87 = 0.000609
// total = 0.002960175
let cost = data["cost_usd"].as_f64().expect("cost_usd");
assert!(
(cost - 0.002_960_175).abs() < 1e-9,
"re-audited total cost should be ~0.00296, got {cost}"
);
assert_eq!(data["turn_count"], 2);
assert_eq!(data["last_turn_input_tokens"], 350);
assert_eq!(data["last_turn_output_tokens"], 80);
assert_eq!(data["model"], "reasoning-v1");
// reasoning-v1 resolves to a 1M context window.
assert_eq!(data["context_window"], 1_000_000);
assert_eq!(data["has_usage"], true);
// Sub-agent breakdown grouped by archetype.
let subs = data["subagents"].as_array().expect("subagents array");
assert_eq!(subs.len(), 1);
assert_eq!(subs[0]["agent_id"], "coder");
assert_eq!(subs[0]["input_tokens"], 1000);
assert_eq!(subs[0]["output_tokens"], 200);
assert_eq!(subs[0]["runs"], 1);
assert!((subs[0]["cost_usd"].as_f64().expect("sub cost") - 0.000_609).abs() < 1e-9);
// Unknown thread → all-zero totals with has_usage=false (brand-new thread).
let resp_unknown = post_json_rpc(
&rpc_base,
2,
"openhuman.threads_token_usage",
json!({ "thread_id": "thr-does-not-exist" }),
)
.await;
let unknown = assert_no_jsonrpc_error(&resp_unknown, "threads_token_usage unknown");
let unknown_data = unknown
.get("data")
.unwrap_or_else(|| panic!("missing data envelope: {unknown}"));
assert_eq!(unknown_data["input_tokens"], 0);
assert_eq!(unknown_data["cost_usd"], 0.0);
assert_eq!(unknown_data["has_usage"], false);
rpc_join.abort();
}