Skip to content

Commit 5f7129f

Browse files
authored
Merge pull request #9 from jhd3197/dev
Add cost tracking and pricing display for agent runs
2 parents b7879dd + f891e5b commit 5f7129f

12 files changed

Lines changed: 134 additions & 8 deletions

File tree

agentsite/api/app.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ async def lifespan(app: FastAPI):
5252
deps.agent_config_repo = deps.AgentConfigRepository(deps.db)
5353
deps.agent_run_repo = deps.AgentRunRepository(deps.db)
5454
deps.message_repo = deps.MessageRepository(deps.db)
55+
# Backfill costs for runs recorded before cost tracking was added
56+
try:
57+
updated = await deps.agent_run_repo.backfill_costs()
58+
if updated:
59+
logger.info("Backfilled costs for %d agent runs", updated)
60+
except Exception:
61+
logger.debug("Cost backfill skipped", exc_info=True)
5562
logger.info("AgentSite started — data dir: %s", settings.data_dir)
5663
yield
5764
await deps.db.close()

agentsite/api/routes/agents.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22

33
from __future__ import annotations
44

5+
import logging
6+
57
from fastapi import APIRouter, Depends, HTTPException
68
from pydantic import BaseModel
79

810
from ...models import AgentConfig
911
from ..deps import get_agent_config_repo, get_agent_run_repo
1012

13+
logger = logging.getLogger("agentsite.api.agents")
14+
1115
router = APIRouter(prefix="/api/agents", tags=["agents"])
1216

1317

@@ -75,3 +79,14 @@ async def get_daily_stats(
7579
):
7680
"""Get daily token aggregates for the last N days."""
7781
return await repo.get_daily_stats(days=days)
82+
83+
84+
@router.post("/runs/backfill-costs")
85+
async def backfill_costs(repo=Depends(get_agent_run_repo)):
86+
"""Recalculate costs for existing runs that have tokens but zero cost.
87+
88+
Uses Prompture's pricing engine to compute costs from stored token counts
89+
and the project's model.
90+
"""
91+
updated = await repo.backfill_costs()
92+
return {"updated": updated}

agentsite/api/routes/models.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ async def list_models():
2929
logger.warning("Model discovery failed: %s", exc)
3030
enriched = []
3131

32+
# Try to import pricing helper
33+
try:
34+
from prompture.model_rates import get_model_rates
35+
except ImportError:
36+
get_model_rates = None
37+
3238
# Group by provider
3339
groups: dict[str, list[dict]] = {}
3440
for entry in enriched:
@@ -38,6 +44,20 @@ async def list_models():
3844
# service to route to (e.g. openrouter/moonshotai/kimi-k2.5).
3945
model_id = f"{provider}/{raw_id}" if not raw_id.startswith(f"{provider}/") else raw_id
4046
caps = entry.get("capabilities") or {}
47+
48+
# Fetch pricing from models.dev cache (per 1M tokens)
49+
pricing = None
50+
if get_model_rates is not None:
51+
try:
52+
rates = get_model_rates(provider, raw_id)
53+
if rates:
54+
pricing = {
55+
"input": rates.get("input"),
56+
"output": rates.get("output"),
57+
}
58+
except Exception:
59+
pass
60+
4161
groups.setdefault(provider, []).append(
4262
{
4363
"id": model_id,
@@ -50,6 +70,7 @@ async def list_models():
5070
"supports_structured_output", False
5171
),
5272
"is_reasoning": caps.get("is_reasoning", False),
73+
"pricing": pricing,
5374
}
5475
)
5576

agentsite/engine/pipeline.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,11 @@ def _on_agent_complete(name: str, result: Any) -> None:
193193
)
194194
run.input_tokens = input_tokens
195195
run.output_tokens = output_tokens
196+
run.cost = float(
197+
usage.get("total_cost", 0.0)
198+
or usage.get("cost", 0.0)
199+
or 0.0
200+
)
196201

197202
# Capture developer output directly for fallback extraction
198203
if agent_key == "developer":
@@ -237,6 +242,7 @@ def _on_agent_complete(name: str, result: Any) -> None:
237242
self._developer_reasoning = reasoning_text
238243

239244
agent_model = self._agent_models.get(agent_key, "")
245+
agent_cost = run.cost if run else 0.0
240246
self._emit(
241247
"agent_complete",
242248
agent=agent_key,
@@ -246,6 +252,7 @@ def _on_agent_complete(name: str, result: Any) -> None:
246252
"duration_s": duration_s,
247253
"input_tokens": input_tokens,
248254
"output_tokens": output_tokens,
255+
"cost": agent_cost,
249256
"tool_calls_count": len(tool_calls),
250257
"model": agent_model,
251258
"reasoning": reasoning_text,

agentsite/engine/reasoning_patch.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Patch Prompture Conversation to preserve reasoning on assistant messages.
22
3-
Prompture 0.0.47 stores ``reasoning_content`` on assistant messages only in
3+
Prompture 0.0.49 stores ``reasoning_content`` on assistant messages only in
44
the native tool-calling path. For the simple ``ask()`` and ``ask_for_json()``
55
paths, reasoning is stored on ``Conversation.last_reasoning`` but **not** on
66
the message dicts in the history.

agentsite/storage/repository.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -505,13 +505,14 @@ async def get_stats(self, since: str | None = None) -> dict:
505505
}
506506

507507
async def get_daily_stats(self, days: int = 30) -> list[dict]:
508-
"""Get daily token aggregates for the last N days."""
508+
"""Get daily token and cost aggregates for the last N days."""
509509
cursor = await self._db.conn.execute(
510510
"""
511511
SELECT
512512
DATE(started_at) as date,
513513
SUM(input_tokens) as input_tokens,
514-
SUM(output_tokens) as output_tokens
514+
SUM(output_tokens) as output_tokens,
515+
SUM(cost) as cost
515516
FROM agent_runs
516517
WHERE started_at >= DATE('now', ?)
517518
GROUP BY DATE(started_at)
@@ -525,10 +526,47 @@ async def get_daily_stats(self, days: int = 30) -> list[dict]:
525526
"date": row["date"],
526527
"input_tokens": row["input_tokens"] or 0,
527528
"output_tokens": row["output_tokens"] or 0,
529+
"cost": round(row["cost"] or 0.0, 4),
528530
}
529531
for row in rows
530532
]
531533

534+
async def backfill_costs(self) -> int:
535+
"""Recalculate costs for runs with tokens but zero cost."""
536+
cursor = await self._db.conn.execute(
537+
"""SELECT ar.id, ar.input_tokens, ar.output_tokens, p.model
538+
FROM agent_runs ar
539+
JOIN projects p ON ar.project_id = p.id
540+
WHERE ar.cost = 0.0 AND (ar.input_tokens > 0 OR ar.output_tokens > 0)"""
541+
)
542+
rows = await cursor.fetchall()
543+
if not rows:
544+
return 0
545+
546+
try:
547+
from prompture.drivers import get_driver_for_model
548+
549+
updated = 0
550+
for row in rows:
551+
model_str = row["model"] or ""
552+
provider = model_str.split("/")[0] if "/" in model_str else ""
553+
model_id = model_str.split("/", 1)[1] if "/" in model_str else model_str
554+
try:
555+
drv = get_driver_for_model(model_str)
556+
cost = drv._calculate_cost(provider, model_id, row["input_tokens"], row["output_tokens"])
557+
except Exception:
558+
cost = 0.0
559+
if cost > 0:
560+
await self._db.conn.execute(
561+
"UPDATE agent_runs SET cost = ? WHERE id = ?",
562+
(round(cost, 6), row["id"]),
563+
)
564+
updated += 1
565+
await self._db.conn.commit()
566+
return updated
567+
except ImportError:
568+
return 0
569+
532570
@staticmethod
533571
def _row_to_run(row: Any) -> AgentRun:
534572
return AgentRun(

frontend/src/components/builder/ProgressPipeline.jsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ const ALL_AGENTS = {
4848

4949
const DEFAULT_ORDER = ["pm", "designer", "developer", "reviewer"];
5050

51+
function formatCost(cost) {
52+
if (!cost || cost === 0) return null;
53+
if (cost < 0.001) return `$${cost.toFixed(6)}`;
54+
if (cost < 0.01) return `$${cost.toFixed(4)}`;
55+
return `$${cost.toFixed(3)}`;
56+
}
57+
5158
function shortModelName(model) {
5259
if (!model) return null;
5360
// Extract last segment: "openrouter/moonshotai/kimi-k2.5" -> "kimi-k2.5"
@@ -115,9 +122,12 @@ export default function ProgressPipeline({ agents, pipelineAgents }) {
115122
{isComplete && agents[key]?.duration_s != null && (
116123
<span
117124
className="text-[10px] tabular-nums opacity-70"
118-
title={`${agents[key].input_tokens || 0} in / ${agents[key].output_tokens || 0} out tokens`}
125+
title={`${agents[key].input_tokens || 0} in / ${agents[key].output_tokens || 0} out tokens${agents[key].cost ? ` · ${formatCost(agents[key].cost)}` : ""}`}
119126
>
120127
{formatDuration(agents[key].duration_s)}
128+
{formatCost(agents[key]?.cost) && (
129+
<span className="ml-1 text-emerald-400/70">{formatCost(agents[key].cost)}</span>
130+
)}
121131
</span>
122132
)}
123133
</div>

frontend/src/hooks/useGeneration.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export default function useGeneration(projectId) {
2828
duration_s: msg.data?.duration_s,
2929
input_tokens: msg.data?.input_tokens,
3030
output_tokens: msg.data?.output_tokens,
31+
cost: msg.data?.cost || 0,
3132
output_preview: msg.data?.output_preview || "",
3233
full_output: msg.data?.full_output || "",
3334
tool_calls_count: msg.data?.tool_calls_count || 0,

frontend/src/pages/ModelsPage.jsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ function formatNumber(n) {
3535
return n.toString();
3636
}
3737

38+
function formatPrice(rate) {
39+
if (rate == null) return "—";
40+
if (rate === 0) return "free";
41+
if (rate >= 1) return `$${rate.toFixed(2)}`;
42+
return `$${rate.toFixed(4)}`.replace(/0+$/, "");
43+
}
44+
3845
function CapBadge({ icon: Icon, label, active }) {
3946
if (!active) return null;
4047
return (
@@ -257,6 +264,26 @@ export default function ModelsPage() {
257264
</p>
258265
</div>
259266

267+
{/* Pricing per 1M tokens */}
268+
<div className="hidden xl:block text-right shrink-0 w-28">
269+
<p className="text-[10px] text-slate-600">
270+
Price / 1M tok
271+
</p>
272+
{m.pricing ? (
273+
<p className="text-xs text-slate-400 font-mono">
274+
<span className="text-emerald-400">
275+
{formatPrice(m.pricing.input)}
276+
</span>
277+
<span className="text-slate-600 mx-0.5">/</span>
278+
<span className="text-amber-400">
279+
{formatPrice(m.pricing.output)}
280+
</span>
281+
</p>
282+
) : (
283+
<p className="text-xs text-slate-600"></p>
284+
)}
285+
</div>
286+
260287
{/* Set as default */}
261288
{!isDefault && (
262289
<button

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ classifiers = [
1515
"Operating System :: OS Independent",
1616
]
1717
dependencies = [
18-
"prompture>=0.0.47",
18+
"prompture>=0.0.49",
1919
"fastapi>=0.100",
2020
"uvicorn[standard]>=0.20",
2121
"aiosqlite>=0.19",

0 commit comments

Comments
 (0)