mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(agent): track session token costs (#4136)
This commit is contained in:
@@ -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 600−240=360, cost 0.01−0.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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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': 'نشط',
|
||||
|
||||
@@ -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': 'সক্রিয়',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 d’entré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 l’historique de la conversation.',
|
||||
'token.tipOutput': 'Jetons générés par le modèle durant cette session.',
|
||||
'token.tipCacheHit':
|
||||
'Jetons d’entrée servis depuis un préfixe d’invite en cache, facturés à un tarif réduit.',
|
||||
'token.clickForDetails': 'Cliquez pour les détails d’utilisation de la session',
|
||||
'catalog.noCapabilityBinding': 'Aucune liaison de fonctionnalité',
|
||||
'catalog.downloadFailed': 'Échec du téléchargement',
|
||||
'catalog.active': 'Actif',
|
||||
|
||||
@@ -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': 'एक्टिव',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '활성',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Активно',
|
||||
|
||||
@@ -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,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">
|
||||
🗺️
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user