From dbd1c0cd6fa813327b0e243623a1bade1a0ea6ec Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:04:39 -0700 Subject: [PATCH] feat(agent): track session token costs (#4136) --- .../chat/ComposerTokenStats.test.tsx | 146 ++++++++++ .../components/chat/ComposerTokenStats.tsx | 263 ++++++++++++++---- app/src/lib/i18n/ar.ts | 16 ++ app/src/lib/i18n/bn.ts | 16 ++ app/src/lib/i18n/de.ts | 18 ++ app/src/lib/i18n/en.ts | 17 ++ app/src/lib/i18n/es.ts | 18 ++ app/src/lib/i18n/fr.ts | 18 ++ app/src/lib/i18n/hi.ts | 16 ++ app/src/lib/i18n/id.ts | 18 ++ app/src/lib/i18n/it.ts | 18 ++ app/src/lib/i18n/ko.ts | 16 ++ app/src/lib/i18n/pl.ts | 17 ++ app/src/lib/i18n/pt.ts | 18 ++ app/src/lib/i18n/ru.ts | 17 ++ app/src/lib/i18n/zh-CN.ts | 16 ++ app/src/pages/Conversations.tsx | 37 ++- .../components/PlanReviewCard.tsx | 2 +- app/src/providers/ChatRuntimeProvider.tsx | 58 +++- app/src/services/api/threadUsageApi.ts | 92 ++++++ app/src/services/chatService.ts | 38 ++- app/src/store/chatRuntimeSlice.test.ts | 164 +++++++++++ app/src/store/chatRuntimeSlice.ts | 205 ++++++++++++-- src/core/socketio.rs | 38 +++ src/openhuman/agent/cost.rs | 75 ++--- src/openhuman/agent/harness/mod.rs | 1 + .../agent/harness/session/builder/setters.rs | 1 + .../agent/harness/session/runtime.rs | 9 + .../agent/harness/session/transcript.rs | 202 +++++++++++++- .../agent/harness/session/transcript_tests.rs | 155 +++++++++++ .../agent/harness/session/turn/core.rs | 59 +++- .../harness/session/turn_engine_adapter.rs | 11 +- src/openhuman/agent/harness/session/types.rs | 6 + .../agent/harness/subagent_runner/mod.rs | 2 +- .../harness/subagent_runner/ops/observer.rs | 25 +- .../harness/subagent_runner/ops/runner.rs | 19 +- .../agent/harness/subagent_runner/types.rs | 18 ++ .../agent/harness/turn_subagent_usage.rs | 174 ++++++++++++ .../agent/task_dispatcher/executor.rs | 3 + .../tools/spawn_subagent.rs | 1 + src/openhuman/channels/proactive.rs | 1 + .../channels/providers/presentation.rs | 37 ++- src/openhuman/channels/providers/web/ops.rs | 2 + .../channels/providers/web/run_task.rs | 5 + src/openhuman/channels/providers/web/types.rs | 4 + src/openhuman/inference/model_context.rs | 7 +- src/openhuman/threads/ops.rs | 158 +++++++++++ src/openhuman/threads/schemas.rs | 29 ++ tests/json_rpc_e2e.rs | 124 +++++++++ 49 files changed, 2255 insertions(+), 155 deletions(-) create mode 100644 app/src/components/chat/ComposerTokenStats.test.tsx create mode 100644 app/src/services/api/threadUsageApi.ts create mode 100644 src/openhuman/agent/harness/turn_subagent_usage.rs diff --git a/app/src/components/chat/ComposerTokenStats.test.tsx b/app/src/components/chat/ComposerTokenStats.test.tsx new file mode 100644 index 000000000..1550c9100 --- /dev/null +++ b/app/src/components/chat/ComposerTokenStats.test.tsx @@ -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[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( + + + + ); +} + +const oneTurn = [ + { + inputTokens: 1200, + outputTokens: 300, + cachedTokens: 50, + costUsd: 0.0123, + contextWindow: 200_000, + }, +]; + +describe('', () => { + 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(); + }); +}); diff --git a/app/src/components/chat/ComposerTokenStats.tsx b/app/src/components/chat/ComposerTokenStats.tsx index 8fb99ae83..4ecf15668 100644 --- a/app/src/components/chat/ComposerTokenStats.tsx +++ b/app/src/components/chat/ComposerTokenStats.tsx @@ -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 ·; +/** + * 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 ( +
+
+ {label} +
+
{value}
+
+ ); +} + +/** 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 ( +
  • + + {name} + + + {fmt(tokens)} · {fmtUsd(costUsd)} · {runs}× + +
  • + ); } 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(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( + + {t('token.ctxLabel')} {contextPct}% ({fmt(contextUsed)}/{fmt(contextWindow)}) + + ); + if (showCost) { parts.push( - - {model} - - ); - } - if (showIn) { - parts.push( - - {t('token.inLabel')} {fmt(inTok)} - - ); - } - if (showOut) { - parts.push( - - {t('token.outLabel')} {fmt(outTok)} - - ); - } - if (turns > 0) { - parts.push( - - {turns} {turns === 1 ? t('token.turn') : t('token.turns')} - - ); - } - if (showCtx) { - parts.push( - - {t('token.ctxLabel')} {contextPct}% ({fmt(contextUsed)}/{fmt(DEFAULT_CONTEXT_WINDOW)}) + + {fmtUsd(costUsd)} ); } @@ -86,13 +166,84 @@ export default function ComposerTokenStats({ model }: ComposerTokenStatsProps = if (parts.length === 0) return null; return ( -
    - {parts.map((part, i) => ( - - {i > 0 && dot()} - {part} - - ))} +
    + {/* Hover hint that the compact row is interactive; click opens the full + breakdown. The hint is suppressed while the popover is already open. */} + + + + {open && ( +
    +
    {t('token.sessionUsageTitle')}
    + {model && ( +
    + {model} +
    + )} +
    + + + {ok(cachedTok) && ( + 0 ? Math.min(100, Math.round((cachedTok / inTok) * 100)) : 0 + }%)`} + /> + )} + + +
    +
    +
    {t('token.byAgentHeading')}
    +
      + {/* Orchestrator first, then each sub-agent archetype. */} + + {subAgents.map(sub => ( + + ))} +
    + {subAgents.length === 0 && ( +
    {t('token.noSubAgents')}
    + )} +
    +
    + )}
    ); } diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index d98c92e65..e423c5dec 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': 'نشط', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index a4ab5ef54..d78c723df 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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': 'সক্রিয়', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index b4e673e99..9f8c94d95 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index c3d046150..7f643d22d 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index d3cac50b3..54d425ab8 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index ae63137aa..79da3501b 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 8740dbac5..d760158c4 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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': 'एक्टिव', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index d3dad2c49..0318f0604 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 82d9579e2..9aafd277a 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index f8b3700b6..c64bcae8d 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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': '활성', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 3a117cd32..09087fa9e 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index ffe6ad0a6..00e4c583e 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 6f15e1bf4..55d2f418b 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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': 'Активно', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 48c785003..68c058d03 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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': '活跃', diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index f2547bd88..1b8aeaf86 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -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">
    - + {/* Set/show the thread goal; click opens the editor above the composer. */}
    diff --git a/app/src/pages/conversations/components/PlanReviewCard.tsx b/app/src/pages/conversations/components/PlanReviewCard.tsx index 8013ef27d..117bcd3fe 100644 --- a/app/src/pages/conversations/components/PlanReviewCard.tsx +++ b/app/src/pages/conversations/components/PlanReviewCard.tsx @@ -70,7 +70,7 @@ export const PlanReviewCard: React.FC = ({ 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">
    🗺️ diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx index f206b3686..0d8675479 100644 --- a/app/src/providers/ChatRuntimeProvider.tsx +++ b/app/src/providers/ChatRuntimeProvider.tsx @@ -200,6 +200,50 @@ function chatDoneExtraMetadata(event: ChatDoneEvent): Record | 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 })); diff --git a/app/src/services/api/threadUsageApi.ts b/app/src/services/api/threadUsageApi.ts new file mode 100644 index 000000000..10a113439 --- /dev/null +++ b/app/src/services/api/threadUsageApi.ts @@ -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 { + 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 { + const response = await callCoreRpc>({ + 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, + })), + }; +} diff --git a/app/src/services/chatService.ts b/app/src/services/chatService.ts index 3f27fb01f..60c0b0aef 100644 --- a/app/src/services/chatService.ts +++ b/app/src/services/chatService.ts @@ -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. */ diff --git a/app/src/store/chatRuntimeSlice.test.ts b/app/src/store/chatRuntimeSlice.test.ts index 3674b912b..33b607164 100644 --- a/app/src/store/chatRuntimeSlice.test.ts +++ b/app/src/store/chatRuntimeSlice.test.ts @@ -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(); diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts index d22af566f..e4197ce7a 100644 --- a/app/src/store/chatRuntimeSlice.ts +++ b/app/src/store/chatRuntimeSlice.ts @@ -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; +} + +/** 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; + /** 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; queueStatusByThread: Record; /** * 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) => { + // 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 = {}; + 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, diff --git a/src/core/socketio.rs b/src/core/socketio.rs index 5373cb99a..8bce55a2a 100644 --- a/src/core/socketio.rs +++ b/src/core/socketio.rs @@ -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, + /// 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, +} + +/// 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, +} + +/// 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`. diff --git a/src/openhuman/agent/cost.rs b/src/openhuman/agent/cost.rs index 0a4c5c6ff..e7bcf9b88 100644 --- a/src/openhuman/agent/cost.rs +++ b/src/openhuman/agent/cost.rs @@ -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] diff --git a/src/openhuman/agent/harness/mod.rs b/src/openhuman/agent/harness/mod.rs index fb9ef20e4..f772ea4a5 100644 --- a/src/openhuman/agent/harness/mod.rs +++ b/src/openhuman/agent/harness/mod.rs @@ -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::{ diff --git a/src/openhuman/agent/harness/session/builder/setters.rs b/src/openhuman/agent/harness/session/builder/setters.rs index 996e05cce..61d8be429 100644 --- a/src/openhuman/agent/harness/session/builder/setters.rs +++ b/src/openhuman/agent/harness/session/builder/setters.rs @@ -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, diff --git a/src/openhuman/agent/harness/session/runtime.rs b/src/openhuman/agent/harness/session/runtime.rs index a625bfcd4..9cd793508 100644 --- a/src/openhuman/agent/harness/session/runtime.rs +++ b/src/openhuman/agent/harness/session/runtime.rs @@ -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 { + self.last_turn_usage_totals.take() + } + // ───────────────────────────────────────────────────────────────── // Static helpers for turn parsing + telemetry // ───────────────────────────────────────────────────────────────── diff --git a/src/openhuman/agent/harness/session/transcript.rs b/src/openhuman/agent/harness/session/transcript.rs index 9612344d8..4e293e382 100644 --- a/src/openhuman/agent/harness/session/transcript.rs +++ b/src/openhuman/agent/harness/session/transcript.rs @@ -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, + /// 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, +} + +/// 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, +} + +/// 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 { + 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)> { + 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::(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 { + 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 = Vec::new(); + let mut sub_matches: Vec = 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 = 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 diff --git a/src/openhuman/agent/harness/session/transcript_tests.rs b/src/openhuman/agent/harness/session/transcript_tests.rs index f01d1cb6e..cb35c7f89 100644 --- a/src/openhuman/agent/harness/session/transcript_tests.rs +++ b/src/openhuman/agent/harness/session/transcript_tests.rs @@ -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); +} diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs index 6a02c18cc..35318e395 100644 --- a/src/openhuman/agent/harness/session/turn/core.rs +++ b/src/openhuman/agent/harness/session/turn/core.rs @@ -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()); diff --git a/src/openhuman/agent/harness/session/turn_engine_adapter.rs b/src/openhuman/agent/harness/session/turn_engine_adapter.rs index 5ecf274cf..8cd8c5511 100644 --- a/src/openhuman/agent/harness/session/turn_engine_adapter.rs +++ b/src/openhuman/agent/harness/session/turn_engine_adapter.rs @@ -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, diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index c950ff879..82de2ee30 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -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, + /// 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, pub(super) history: Vec, pub(super) post_turn_hooks: Vec>, pub(super) learning_enabled: bool, diff --git a/src/openhuman/agent/harness/subagent_runner/mod.rs b/src/openhuman/agent/harness/subagent_runner/mod.rs index 734e6f9f4..3c3a7c423 100644 --- a/src/openhuman/agent/harness/subagent_runner/mod.rs +++ b/src/openhuman/agent/harness/subagent_runner/mod.rs @@ -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 diff --git a/src/openhuman/agent/harness/subagent_runner/ops/observer.rs b/src/openhuman/agent/harness/subagent_runner/ops/observer.rs index f8cf5b016..4fcfec396 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/observer.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/observer.rs @@ -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, } @@ -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, diff --git a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs index 957433eac..0bfe3dd50 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs @@ -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, }) } diff --git a/src/openhuman/agent/harness/subagent_runner/types.rs b/src/openhuman/agent/harness/subagent_runner/types.rs index f29073a55..c03a4cb5c 100644 --- a/src/openhuman/agent/harness/subagent_runner/types.rs +++ b/src/openhuman/agent/harness/subagent_runner/types.rs @@ -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, + /// 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. diff --git a/src/openhuman/agent/harness/turn_subagent_usage.rs b/src/openhuman/agent/harness/turn_subagent_usage.rs new file mode 100644 index 000000000..ba32b9832 --- /dev/null +++ b/src/openhuman/agent/harness/turn_subagent_usage.rs @@ -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>>`] 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, +} + +/// Shared, mutable list of sub-agent spend gathered during one parent turn. +pub type TurnSubagentUsage = Arc>>; + +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 { + 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(future: F) -> (R, Vec) +where + F: std::future::Future, +{ + 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()); + } +} diff --git a/src/openhuman/agent/task_dispatcher/executor.rs b/src/openhuman/agent/task_dispatcher/executor.rs index 46c28816e..9a478e3e2 100644 --- a/src/openhuman/agent/task_dispatcher/executor.rs +++ b/src/openhuman/agent/task_dispatcher/executor.rs @@ -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; } diff --git a/src/openhuman/agent_orchestration/tools/spawn_subagent.rs b/src/openhuman/agent_orchestration/tools/spawn_subagent.rs index 492b1db76..edf7e69c6 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_subagent.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_subagent.rs @@ -859,6 +859,7 @@ mod tests { mode: SubagentMode::Typed, status: SubagentRunStatus::Completed, final_history: Vec::new(), + usage: Default::default(), } } diff --git a/src/openhuman/channels/proactive.rs b/src/openhuman/channels/proactive.rs index d94d4481c..f8d7c56f8 100644 --- a/src/openhuman/channels/proactive.rs +++ b/src/openhuman/channels/proactive.rs @@ -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. diff --git a/src/openhuman/channels/providers/presentation.rs b/src/openhuman/channels/providers/presentation.rs index a5d475095..385b0178e 100644 --- a/src/openhuman/channels/providers/presentation.rs +++ b/src/openhuman/channels/providers/presentation.rs @@ -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 { + 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; } diff --git a/src/openhuman/channels/providers/web/ops.rs b/src/openhuman/channels/providers/web/ops.rs index 69a098bf0..3b2626800 100644 --- a/src/openhuman/channels/providers/web/ops.rs +++ b/src/openhuman/channels/providers/web/ops.rs @@ -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; } diff --git a/src/openhuman/channels/providers/web/run_task.rs b/src/openhuman/channels/providers/web/run_task.rs index 6b5b23fed..c7dd277df 100644 --- a/src/openhuman/channels/providers/web/run_task.rs +++ b/src/openhuman/channels/providers/web/run_task.rs @@ -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, }) } diff --git a/src/openhuman/channels/providers/web/types.rs b/src/openhuman/channels/providers/web/types.rs index e8f8b936a..3cc666687 100644 --- a/src/openhuman/channels/providers/web/types.rs +++ b/src/openhuman/channels/providers/web/types.rs @@ -68,6 +68,10 @@ pub(super) struct ParallelEntry { pub(super) struct WebChatTaskResult { pub(super) full_response: String, pub(super) citations: Vec, + /// 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, } /// Per-request metadata carried alongside a chat send. Currently used by the diff --git a/src/openhuman/inference/model_context.rs b/src/openhuman/inference/model_context.rs index de9af34b8..4da1b814a 100644 --- a/src/openhuman/inference/model_context.rs +++ b/src/openhuman/inference/model_context.rs @@ -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 { fn tier_context_window(model: &str) -> Option { 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!( diff --git a/src/openhuman/threads/ops.rs b/src/openhuman/threads/ops.rs index 156de2762..b74aac19e 100644 --- a/src/openhuman/threads/ops.rs +++ b/src/openhuman/threads/ops.rs @@ -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, + pub updated: Option, + /// `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, +} + +/// 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>, 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; diff --git a/src/openhuman/threads/schemas.rs b/src/openhuman/threads/schemas.rs index 8a5c20e20..47b866674 100644 --- a/src/openhuman/threads/schemas.rs +++ b/src/openhuman/threads/schemas.rs @@ -34,6 +34,7 @@ pub fn all_controller_schemas() -> Vec { 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 { 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) -> ControllerFuture { }) } +fn handle_token_usage(params: Map) -> ControllerFuture { + Box::pin(async move { + let p = parse::(params)?; + to_json(ops::token_usage(p).await?) + }) +} + // ── Helpers ────────────────────────────────────────────────────────── fn parse(params: Map) -> Result { diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 8e8a18ba2..67949d71e 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -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(); +}