v1.13.10: per-tool token cost accounting (rolling 100-call view)
Surfaces per-tool prompt/completion-token rolling averages in
AgentPicker for at-a-glance agent-cost hints. Implementation is a
SQL view on top of messages_with_parts plus a read endpoint and
AgentPicker tooltip extension. No new write site; all source data
already lands via the existing tool-phase.ts:94-95 / error-handler.ts:
109-110 / sentinel-summaries.ts UPDATEs that v1.13.7's includeUsage:
true fix made non-NULL.
(1) schema.sql — new tool_cost_stats view. Window-functions over
messages_with_parts.tool_calls with LATERAL jsonb_array_elements.
Attribution: equal split — multi-tool turn divides tokens N-ways;
the 100-call rolling mean absorbs split noise. Filters: status=
'complete' + metadata.kind NOT IN ('cap_hit','doom_loop') exclude
failed turns and sentinels respectively; tool_calls IS NOT NULL is
defense-in-depth since sentinels are role='system' rows. CREATE OR
REPLACE means schema apply is idempotent.
(2) routes/tools.ts NEW + index.ts wire-in. GET /api/tools/cost_stats
returns { stats: ToolCostStat[] } with mean_prompt_tokens / mean_
completion_tokens computed at read time (sum / n_calls). Sorted by
tool_name ASC. No pagination — ≤30 tools.
(3) __tests__/tool_cost_stats.test.ts NEW — 7 integration tests
keyed off DATABASE_URL env var. Tests skip gracefully when unset
(no-DB default). beforeAll applies the schema via sql.unsafe(read
FileSync(schema.sql)) for self-contained runs. Helper insertAssistant
Turn shared across cases. Covers: empty state, single-tool attribution,
multi-tool equal split, 100-call FIFO window, NULL-tokens exclusion,
parts-authoritative read via messages_with_parts, failed/sentinel
exclusion.
(4) web/api/types.ts + client.ts — ToolCostStat interface + api.tools.
costStats() method binding.
(5) AgentPicker.tsx — fetch costStats on mount, compute per-agent
sum-of-means across whitelisted tools, render muted cost line below
description: "~5.2k prompt / 280 completion · 6/8 tools · last call
3h ago". Skips line entirely when no tool history; preserves existing
native title= for layout backward-compat. formatK/formatAgo colocated.
Tests: 202/202 pass (195 prior + 7 new view-integration). Server +
web tsc clean.
Smoke: schema applied cleanly; GET /api/tools/cost_stats returns
canonical JSON; view + endpoint agree. Single-row result expected
given the v1.13.1-A → v1.13.7 NULL latent regression window; new
traffic populates organically.
Roadmap row at boocode_roadmap.md:114 plus schema row at :474 both
match. View vs table decision documented in handoff_v1.13.10_per_
tool_cost.md (rollback-safe, microsecond-fast at BooCode scale).
~270 LoC across 8 files (5 modified + 3 new).
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { Agent, AgentParseError } from '@/api/types';
|
||||
import type { Agent, AgentParseError, ToolCostStat } from '@/api/types';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -22,6 +22,10 @@ export function AgentPicker({ projectId, value, onChange }: Props) {
|
||||
const [parseErrors, setParseErrors] = useState<AgentParseError[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
// v1.13.10: per-tool cost rolling window. Fetched once on mount; would
|
||||
// refresh on remount or page reload. Acceptable for a decision aid — the
|
||||
// 100-call rolling mean doesn't shift fast.
|
||||
const [costStats, setCostStats] = useState<ToolCostStat[]>([]);
|
||||
|
||||
// v1.8.1: per-agent parse errors are non-blocking. Silent if any agents
|
||||
// loaded successfully; a gray warning toast fires only when EVERY agent
|
||||
@@ -52,6 +56,29 @@ export function AgentPicker({ projectId, value, onChange }: Props) {
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// v1.13.10: cost stats are project-independent — the 100-call rolling
|
||||
// window is global across all chats. Fetch once per mount; tolerate failure
|
||||
// silently (cost line hides).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.tools
|
||||
.costStats()
|
||||
.then((r) => {
|
||||
if (!cancelled) setCostStats(r.stats);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setCostStats([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const costByTool = useMemo(
|
||||
() => Object.fromEntries(costStats.map((s) => [s.tool_name, s])),
|
||||
[costStats],
|
||||
);
|
||||
|
||||
const selectedAgent = agents?.find((a) => a.id === value) ?? null;
|
||||
const triggerLabel = value === null
|
||||
? 'No agent'
|
||||
@@ -86,25 +113,33 @@ export function AgentPicker({ projectId, value, onChange }: Props) {
|
||||
<span className="font-medium">No agent</span>
|
||||
</DropdownMenuItem>
|
||||
{agents.length > 0 && <DropdownMenuSeparator />}
|
||||
{agents.map((a) => (
|
||||
<DropdownMenuItem
|
||||
key={a.id}
|
||||
onSelect={() => void onChange(a.id)}
|
||||
className="text-xs flex-col items-start gap-0.5"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Check
|
||||
className={`size-3 ${a.id === value ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
<span className="font-medium">{a.name}</span>
|
||||
</div>
|
||||
{a.description && (
|
||||
<span className="text-muted-foreground pl-[18px] truncate w-full">
|
||||
{a.description}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{agents.map((a) => {
|
||||
const cost = agentCost(a, costByTool);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={a.id}
|
||||
onSelect={() => void onChange(a.id)}
|
||||
className="text-xs flex-col items-start gap-0.5"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Check
|
||||
className={`size-3 ${a.id === value ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
<span className="font-medium">{a.name}</span>
|
||||
</div>
|
||||
{a.description && (
|
||||
<span className="text-muted-foreground pl-[18px] truncate w-full">
|
||||
{a.description}
|
||||
</span>
|
||||
)}
|
||||
{cost.nWithData > 0 && (
|
||||
<span className="text-muted-foreground/70 pl-[18px] truncate w-full">
|
||||
~{formatK(cost.prompt)} prompt / {cost.completion} completion · {cost.nWithData}/{cost.nTools} tools{cost.mostRecent ? ` · last call ${formatAgo(cost.mostRecent)}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{parseErrors.length > 0 && (
|
||||
<div
|
||||
className="px-2 py-1.5 mt-1 text-xs text-amber-500 border-t border-border"
|
||||
@@ -119,3 +154,49 @@ export function AgentPicker({ projectId, value, onChange }: Props) {
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// v1.13.10: sum the per-tool means across an agent's whitelisted tools.
|
||||
// Sum-of-means, not mean-of-sums — we're combining independent rolling
|
||||
// averages. nWithData reflects how many of the agent's tools have any
|
||||
// history yet; the line hides entirely when zero so a fresh deploy doesn't
|
||||
// render "0k / 0 / 0 tools".
|
||||
function agentCost(
|
||||
agent: Agent,
|
||||
costByTool: Record<string, ToolCostStat>,
|
||||
): {
|
||||
prompt: number;
|
||||
completion: number;
|
||||
nTools: number;
|
||||
nWithData: number;
|
||||
mostRecent: string | null;
|
||||
} {
|
||||
let prompt = 0;
|
||||
let completion = 0;
|
||||
let nWithData = 0;
|
||||
let mostRecent: string | null = null;
|
||||
for (const t of agent.tools) {
|
||||
const s = costByTool[t];
|
||||
if (!s) continue;
|
||||
prompt += s.mean_prompt_tokens;
|
||||
completion += s.mean_completion_tokens;
|
||||
nWithData++;
|
||||
if (!mostRecent || s.updated_at > mostRecent) mostRecent = s.updated_at;
|
||||
}
|
||||
return { prompt, completion, nTools: agent.tools.length, nWithData, mostRecent };
|
||||
}
|
||||
|
||||
function formatK(n: number): string {
|
||||
if (n < 1000) return String(n);
|
||||
if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
|
||||
return `${Math.round(n / 1000)}k`;
|
||||
}
|
||||
|
||||
function formatAgo(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '—';
|
||||
const diff = Date.now() - then;
|
||||
if (diff < 60_000) return 'just now';
|
||||
if (diff < 3_600_000) return `${Math.round(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)}h ago`;
|
||||
return `${Math.round(diff / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user