agentcanvas 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentcanvas/__init__.py +22 -0
- agentcanvas/__main__.py +4 -0
- agentcanvas/cli.py +79 -0
- agentcanvas/logfire_client.py +104 -0
- agentcanvas/models.py +102 -0
- agentcanvas/parser.py +311 -0
- agentcanvas/pricing.py +51 -0
- agentcanvas/render.py +811 -0
- agentcanvas-0.1.0.dist-info/METADATA +238 -0
- agentcanvas-0.1.0.dist-info/RECORD +13 -0
- agentcanvas-0.1.0.dist-info/WHEEL +4 -0
- agentcanvas-0.1.0.dist-info/entry_points.txt +2 -0
- agentcanvas-0.1.0.dist-info/licenses/LICENSE +21 -0
agentcanvas/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""agentcanvas — visualize a Pydantic AI agent workflow from Logfire logs.
|
|
2
|
+
|
|
3
|
+
Reads the OpenTelemetry (GenAI) spans emitted by Pydantic AI's instrumentation,
|
|
4
|
+
parses them into a typed :class:`WorkflowReport` (turns, rounds, tools, nested
|
|
5
|
+
agents), prices each model call with ``genai-prices``, and renders a
|
|
6
|
+
self-contained, interactive HTML report.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .logfire_client import LogfireClient
|
|
10
|
+
from .models import AgentRun, ModelCall, ToolCall, WorkflowReport
|
|
11
|
+
from .parser import parse_run
|
|
12
|
+
from .render import render_html
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"LogfireClient",
|
|
16
|
+
"WorkflowReport",
|
|
17
|
+
"AgentRun",
|
|
18
|
+
"ModelCall",
|
|
19
|
+
"ToolCall",
|
|
20
|
+
"parse_run",
|
|
21
|
+
"render_html",
|
|
22
|
+
]
|
agentcanvas/__main__.py
ADDED
agentcanvas/cli.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Command-line interface: pull an agent run from Logfire and build the HTML report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
import webbrowser
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from dotenv import load_dotenv
|
|
11
|
+
|
|
12
|
+
from .logfire_client import LogfireClient
|
|
13
|
+
from .parser import parse_run
|
|
14
|
+
from .render import render_html
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv: list[str] | None = None) -> int:
|
|
18
|
+
load_dotenv()
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="agentcanvas",
|
|
21
|
+
description="Visualize a Pydantic AI agent workflow from Logfire logs.",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("--trace-id", help="Trace id to visualize (default: latest).")
|
|
24
|
+
parser.add_argument("--list", action="store_true", help="List recent runs and exit.")
|
|
25
|
+
parser.add_argument("-o", "--output", default="agent_flow.html", help="Output HTML file.")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--no-open", action="store_true", help="Do not open the report in a browser."
|
|
28
|
+
)
|
|
29
|
+
args = parser.parse_args(argv)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
client = LogfireClient()
|
|
33
|
+
except RuntimeError as exc:
|
|
34
|
+
print(f"✖ {exc}", file=sys.stderr)
|
|
35
|
+
return 1
|
|
36
|
+
|
|
37
|
+
if args.list:
|
|
38
|
+
rows = client.list_recent_traces()
|
|
39
|
+
if not rows:
|
|
40
|
+
print("No agent runs found in the project.")
|
|
41
|
+
return 0
|
|
42
|
+
print("Recent agent runs:\n")
|
|
43
|
+
for row in rows:
|
|
44
|
+
dur = row.get("duration")
|
|
45
|
+
suffix = f" ({float(dur):.2f}s)" if dur else ""
|
|
46
|
+
print(f" {row['trace_id']} {row.get('start_timestamp', '')}{suffix}")
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
trace_id = args.trace_id or client.latest_trace_id()
|
|
50
|
+
if not trace_id:
|
|
51
|
+
print("✖ No trace found in Logfire.", file=sys.stderr)
|
|
52
|
+
return 1
|
|
53
|
+
|
|
54
|
+
print(f"→ Fetching trace {trace_id} …")
|
|
55
|
+
spans = client.fetch_trace(trace_id)
|
|
56
|
+
if not spans:
|
|
57
|
+
print("✖ Trace contains no spans.", file=sys.stderr)
|
|
58
|
+
return 1
|
|
59
|
+
|
|
60
|
+
report = parse_run(spans, trace_id)
|
|
61
|
+
totals = report.totals
|
|
62
|
+
cost = f", cost ${totals.total_cost_usd:.6f}" if report.meta.cost_known else ", cost unknown"
|
|
63
|
+
print(
|
|
64
|
+
f"→ Parsed: {totals.num_turns} turn(s), {totals.num_model_calls} model call(s), "
|
|
65
|
+
f"{totals.num_tools} tool call(s), {totals.num_nested_agents} nested agent(s), "
|
|
66
|
+
f"{totals.total_tokens} tokens{cost}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
out = Path(args.output).resolve()
|
|
70
|
+
out.write_text(render_html(report), encoding="utf-8")
|
|
71
|
+
print(f"✓ Report written: {out}")
|
|
72
|
+
|
|
73
|
+
if not args.no_open:
|
|
74
|
+
webbrowser.open(out.as_uri())
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""A thin client for the Logfire Query API (read spans via SQL + read token)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
# Default region; override with LOGFIRE_BASE_URL (e.g. https://logfire-eu.pydantic.dev).
|
|
12
|
+
DEFAULT_BASE_URL = "https://logfire-us.pydantic.dev"
|
|
13
|
+
|
|
14
|
+
# Columns the parser needs. `attributes` carries the whole GenAI payload.
|
|
15
|
+
_SPAN_COLUMNS = (
|
|
16
|
+
"span_id, parent_span_id, span_name, "
|
|
17
|
+
"start_timestamp, end_timestamp, duration, "
|
|
18
|
+
"otel_status_code, otel_status_message, attributes"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _sql_str(value: str) -> str:
|
|
23
|
+
"""Quote a value as a SQL string literal, escaping embedded quotes (ANSI style)."""
|
|
24
|
+
return "'" + value.replace("'", "''") + "'"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LogfireClient:
|
|
28
|
+
"""Runs SQL queries against Logfire and returns rows as a list of dicts."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, read_token: str | None = None, base_url: str | None = None):
|
|
31
|
+
self.read_token = read_token or os.environ.get("LOGFIRE_READ_TOKEN")
|
|
32
|
+
if not self.read_token:
|
|
33
|
+
raise RuntimeError(
|
|
34
|
+
"Missing Logfire read token. Set LOGFIRE_READ_TOKEN in .env or pass read_token=..."
|
|
35
|
+
)
|
|
36
|
+
resolved = base_url or os.environ.get("LOGFIRE_BASE_URL") or DEFAULT_BASE_URL
|
|
37
|
+
self.base_url = resolved.rstrip("/")
|
|
38
|
+
|
|
39
|
+
def query(self, sql: str) -> list[dict[str, Any]]:
|
|
40
|
+
"""Run SQL and return rows (Logfire returns columns — we transpose here)."""
|
|
41
|
+
resp = httpx.get(
|
|
42
|
+
f"{self.base_url}/v1/query",
|
|
43
|
+
params={"sql": sql},
|
|
44
|
+
headers={
|
|
45
|
+
"Authorization": f"Bearer {self.read_token}",
|
|
46
|
+
"Accept": "application/json",
|
|
47
|
+
},
|
|
48
|
+
timeout=30.0,
|
|
49
|
+
)
|
|
50
|
+
resp.raise_for_status()
|
|
51
|
+
payload = resp.json()
|
|
52
|
+
return _columns_to_rows(payload)
|
|
53
|
+
|
|
54
|
+
def latest_trace_id(self) -> str | None:
|
|
55
|
+
"""Latest trace that is an actual agent run (has an `invoke_agent` span)."""
|
|
56
|
+
rows = self.query(
|
|
57
|
+
"SELECT trace_id FROM records WHERE span_name LIKE 'invoke_agent%' "
|
|
58
|
+
"ORDER BY start_timestamp DESC LIMIT 1"
|
|
59
|
+
)
|
|
60
|
+
return rows[0]["trace_id"] if rows else None
|
|
61
|
+
|
|
62
|
+
def list_recent_traces(self, limit: int = 20) -> list[dict[str, Any]]:
|
|
63
|
+
"""List recent agent runs (by the invoke_agent span)."""
|
|
64
|
+
return self.query(
|
|
65
|
+
"SELECT trace_id, span_name, start_timestamp, duration "
|
|
66
|
+
"FROM records WHERE span_name LIKE 'invoke_agent%' "
|
|
67
|
+
f"ORDER BY start_timestamp DESC LIMIT {int(limit)}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def fetch_trace(self, trace_id: str) -> list[dict[str, Any]]:
|
|
71
|
+
"""All spans of a given trace, sorted chronologically."""
|
|
72
|
+
if not trace_id:
|
|
73
|
+
raise ValueError("trace_id must not be empty")
|
|
74
|
+
rows = self.query(
|
|
75
|
+
f"SELECT {_SPAN_COLUMNS} FROM records "
|
|
76
|
+
f"WHERE trace_id = {_sql_str(trace_id)} ORDER BY start_timestamp ASC"
|
|
77
|
+
)
|
|
78
|
+
for row in rows:
|
|
79
|
+
row["attributes"] = _ensure_dict(row.get("attributes"))
|
|
80
|
+
return rows
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _columns_to_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|
84
|
+
cols = payload.get("columns", [])
|
|
85
|
+
if not cols:
|
|
86
|
+
return []
|
|
87
|
+
names = [c["name"] for c in cols]
|
|
88
|
+
values = [c["values"] for c in cols]
|
|
89
|
+
n = len(values[0]) if values else 0
|
|
90
|
+
return [{names[c]: values[c][i] for c in range(len(names))} for i in range(n)]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _ensure_dict(value: Any) -> dict[str, Any]:
|
|
94
|
+
"""The `attributes` column may come back as a JSON string or a native object."""
|
|
95
|
+
if value is None:
|
|
96
|
+
return {}
|
|
97
|
+
if isinstance(value, dict):
|
|
98
|
+
return value
|
|
99
|
+
if isinstance(value, str):
|
|
100
|
+
try:
|
|
101
|
+
return json.loads(value)
|
|
102
|
+
except json.JSONDecodeError:
|
|
103
|
+
return {}
|
|
104
|
+
return {}
|
agentcanvas/models.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Typed models for a parsed agent workflow.
|
|
2
|
+
|
|
3
|
+
These mirror exactly the data the HTML renderer consumes. ``WorkflowReport`` is
|
|
4
|
+
serialised to JSON and embedded in the report; nothing outside these fields is
|
|
5
|
+
emitted, so the payload stays lean and the renderer stays in sync.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ToolDefinition(BaseModel):
|
|
16
|
+
"""A tool the model was offered for a request (name + description)."""
|
|
17
|
+
|
|
18
|
+
name: str | None = None
|
|
19
|
+
description: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ToolCall(BaseModel):
|
|
23
|
+
"""A single tool execution. ``nested`` is set when the tool is itself an agent."""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
arguments: Any = None
|
|
27
|
+
result: str | None = None
|
|
28
|
+
duration_s: float = 0.0
|
|
29
|
+
nested: AgentRun | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ModelCall(BaseModel):
|
|
33
|
+
"""One request to the model (a ``chat`` span) plus the tools it triggered."""
|
|
34
|
+
|
|
35
|
+
model: str = ""
|
|
36
|
+
provider: str | None = None
|
|
37
|
+
server: str | None = None
|
|
38
|
+
response_id: str | None = None
|
|
39
|
+
input_tokens: int = 0
|
|
40
|
+
output_tokens: int = 0
|
|
41
|
+
reasoning_tokens: int = 0
|
|
42
|
+
cost_usd: float | None = None
|
|
43
|
+
cost_input_usd: float | None = None
|
|
44
|
+
cost_output_usd: float | None = None
|
|
45
|
+
finish_reasons: list[str] = Field(default_factory=list)
|
|
46
|
+
duration_s: float = 0.0
|
|
47
|
+
thinking: list[str] = Field(default_factory=list)
|
|
48
|
+
text_out: list[str] = Field(default_factory=list)
|
|
49
|
+
decided_tool_calls: list[str] = Field(default_factory=list)
|
|
50
|
+
available_tools: list[ToolDefinition] = Field(default_factory=list)
|
|
51
|
+
thinking_config: Any = None
|
|
52
|
+
tools: list[ToolCall] = Field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AgentRun(BaseModel):
|
|
56
|
+
"""One agent invocation: a conversation turn, or a nested sub-agent."""
|
|
57
|
+
|
|
58
|
+
agent_name: str = "agent"
|
|
59
|
+
model: str = ""
|
|
60
|
+
final_output: str | None = None
|
|
61
|
+
user_prompt: str | None = None
|
|
62
|
+
rounds: list[ModelCall] = Field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ConversationMessage(BaseModel):
|
|
66
|
+
"""One entry in the human-readable transcript."""
|
|
67
|
+
|
|
68
|
+
role: str
|
|
69
|
+
text: str = ""
|
|
70
|
+
thinking: list[str] = Field(default_factory=list)
|
|
71
|
+
tool_calls: list[str] = Field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Meta(BaseModel):
|
|
75
|
+
trace_id: str
|
|
76
|
+
model: str = ""
|
|
77
|
+
duration_s: float = 0.0
|
|
78
|
+
num_turns: int = 0
|
|
79
|
+
cost_known: bool = False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Totals(BaseModel):
|
|
83
|
+
total_tokens: int = 0
|
|
84
|
+
total_cost_usd: float | None = None
|
|
85
|
+
reasoning_tokens: int = 0
|
|
86
|
+
num_model_calls: int = 0
|
|
87
|
+
num_tools: int = 0
|
|
88
|
+
num_nested_agents: int = 0
|
|
89
|
+
num_turns: int = 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class WorkflowReport(BaseModel):
|
|
93
|
+
"""The complete, renderable workflow."""
|
|
94
|
+
|
|
95
|
+
meta: Meta
|
|
96
|
+
totals: Totals
|
|
97
|
+
conversation: list[ConversationMessage] = Field(default_factory=list)
|
|
98
|
+
turns: list[AgentRun] = Field(default_factory=list)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Resolve the ToolCall <-> AgentRun forward reference.
|
|
102
|
+
ToolCall.model_rebuild()
|
agentcanvas/parser.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Parse raw Logfire spans into a typed :class:`WorkflowReport`.
|
|
2
|
+
|
|
3
|
+
Logfire spans form a tree via ``parent_span_id``. We turn that tree into a
|
|
4
|
+
recursive structure that naturally supports multiple conversation turns,
|
|
5
|
+
arbitrarily deep nested agents-as-tools, per-call reasoning, token usage and
|
|
6
|
+
exact cost. Relies on Pydantic AI v2's OpenTelemetry GenAI span names:
|
|
7
|
+
``invoke_agent <name>``, ``chat <model>``, ``execute_tool <name>``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .models import (
|
|
17
|
+
AgentRun,
|
|
18
|
+
ConversationMessage,
|
|
19
|
+
Meta,
|
|
20
|
+
ModelCall,
|
|
21
|
+
ToolCall,
|
|
22
|
+
ToolDefinition,
|
|
23
|
+
Totals,
|
|
24
|
+
WorkflowReport,
|
|
25
|
+
)
|
|
26
|
+
from .pricing import price_request
|
|
27
|
+
|
|
28
|
+
Span = dict[str, Any]
|
|
29
|
+
ChildIndex = dict[str | None, list[Span]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _maybe_json(value: Any) -> Any:
|
|
33
|
+
if isinstance(value, str):
|
|
34
|
+
try:
|
|
35
|
+
return json.loads(value)
|
|
36
|
+
except json.JSONDecodeError:
|
|
37
|
+
return value
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _stringify(value: Any) -> str | None:
|
|
42
|
+
if value is None or isinstance(value, str):
|
|
43
|
+
return value
|
|
44
|
+
return json.dumps(value, ensure_ascii=False)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class _Part:
|
|
49
|
+
kind: str
|
|
50
|
+
role: str | None = None
|
|
51
|
+
content: str | None = None
|
|
52
|
+
name: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parts(raw: Any) -> list[_Part]:
|
|
56
|
+
"""Flatten a GenAI message list into ordered parts (text/thinking/tool_call)."""
|
|
57
|
+
messages = _maybe_json(raw)
|
|
58
|
+
out: list[_Part] = []
|
|
59
|
+
if not isinstance(messages, list):
|
|
60
|
+
return out
|
|
61
|
+
for message in messages:
|
|
62
|
+
if not isinstance(message, dict):
|
|
63
|
+
continue
|
|
64
|
+
role = message.get("role")
|
|
65
|
+
for part in message.get("parts", []):
|
|
66
|
+
if not isinstance(part, dict):
|
|
67
|
+
continue
|
|
68
|
+
ptype = part.get("type", "")
|
|
69
|
+
if ptype == "tool_call":
|
|
70
|
+
out.append(_Part("tool_call", role, name=part.get("name")))
|
|
71
|
+
elif ptype in ("text", "thinking") and part.get("content"):
|
|
72
|
+
out.append(_Part(ptype, role, content=part.get("content")))
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _user_prompt(chat_attrs: dict[str, Any]) -> str | None:
|
|
77
|
+
"""The last user-authored text in a request's input — the turn's prompt."""
|
|
78
|
+
for part in reversed(_parts(chat_attrs.get("gen_ai.input.messages"))):
|
|
79
|
+
if part.kind == "text" and part.role == "user" and part.content:
|
|
80
|
+
return part.content
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _conversation(all_messages: Any) -> list[ConversationMessage]:
|
|
85
|
+
"""Flatten the full message history into a human-readable transcript."""
|
|
86
|
+
messages = _maybe_json(all_messages)
|
|
87
|
+
out: list[ConversationMessage] = []
|
|
88
|
+
if not isinstance(messages, list):
|
|
89
|
+
return out
|
|
90
|
+
for message in messages:
|
|
91
|
+
if not isinstance(message, dict):
|
|
92
|
+
continue
|
|
93
|
+
role = message.get("role")
|
|
94
|
+
texts: list[str] = []
|
|
95
|
+
thinking: list[str] = []
|
|
96
|
+
tool_calls: list[str] = []
|
|
97
|
+
tool_results: list[str] = []
|
|
98
|
+
for part in message.get("parts", []):
|
|
99
|
+
if not isinstance(part, dict):
|
|
100
|
+
continue
|
|
101
|
+
ptype = part.get("type")
|
|
102
|
+
content = part.get("content")
|
|
103
|
+
if ptype == "text" and content:
|
|
104
|
+
texts.append(content)
|
|
105
|
+
elif ptype == "thinking" and content:
|
|
106
|
+
thinking.append(content)
|
|
107
|
+
elif ptype == "tool_call" and part.get("name"):
|
|
108
|
+
tool_calls.append(part["name"])
|
|
109
|
+
elif ptype == "tool_call_response" and part.get("name"):
|
|
110
|
+
tool_results.append(part["name"])
|
|
111
|
+
if role == "user" and texts:
|
|
112
|
+
out.append(ConversationMessage(role="user", text="\n".join(texts)))
|
|
113
|
+
elif role == "user" and tool_results:
|
|
114
|
+
out.append(
|
|
115
|
+
ConversationMessage(
|
|
116
|
+
role="tool", text="Returned results from: " + ", ".join(tool_results)
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
elif role == "assistant":
|
|
120
|
+
out.append(
|
|
121
|
+
ConversationMessage(
|
|
122
|
+
role="assistant",
|
|
123
|
+
text="\n".join(texts),
|
|
124
|
+
thinking=thinking,
|
|
125
|
+
tool_calls=tool_calls,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
return out
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _child_index(spans: list[Span]) -> ChildIndex:
|
|
132
|
+
children: ChildIndex = {}
|
|
133
|
+
for span in spans:
|
|
134
|
+
children.setdefault(span.get("parent_span_id"), []).append(span)
|
|
135
|
+
for siblings in children.values():
|
|
136
|
+
siblings.sort(key=lambda s: s.get("start_timestamp") or "")
|
|
137
|
+
return children
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _kind(span: Span) -> str:
|
|
141
|
+
name = span["span_name"]
|
|
142
|
+
if name.startswith("invoke_agent"):
|
|
143
|
+
return "agent"
|
|
144
|
+
if name.startswith("chat "):
|
|
145
|
+
return "model"
|
|
146
|
+
if name.startswith("execute_tool "):
|
|
147
|
+
return "tool"
|
|
148
|
+
return "other"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _parse_model_call(span: Span) -> ModelCall:
|
|
152
|
+
attrs = span["attributes"]
|
|
153
|
+
model = attrs.get("gen_ai.request.model") or attrs.get("gen_ai.response.model") or ""
|
|
154
|
+
input_tokens = int(attrs.get("gen_ai.usage.input_tokens", 0) or 0)
|
|
155
|
+
output_tokens = int(attrs.get("gen_ai.usage.output_tokens", 0) or 0)
|
|
156
|
+
cost = price_request(model, input_tokens, output_tokens)
|
|
157
|
+
|
|
158
|
+
parts = _parts(attrs.get("gen_ai.output.messages"))
|
|
159
|
+
finish = attrs.get("gen_ai.response.finish_reasons") or []
|
|
160
|
+
if isinstance(finish, str):
|
|
161
|
+
finish = [finish]
|
|
162
|
+
|
|
163
|
+
request_params = _maybe_json(attrs.get("model_request_parameters"))
|
|
164
|
+
available = []
|
|
165
|
+
thinking_config = None
|
|
166
|
+
if isinstance(request_params, dict):
|
|
167
|
+
thinking_config = request_params.get("thinking")
|
|
168
|
+
for tool in request_params.get("function_tools") or []:
|
|
169
|
+
if isinstance(tool, dict):
|
|
170
|
+
available.append(
|
|
171
|
+
ToolDefinition(name=tool.get("name"), description=tool.get("description"))
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return ModelCall(
|
|
175
|
+
model=model,
|
|
176
|
+
provider=attrs.get("gen_ai.provider.name") or attrs.get("gen_ai.system"),
|
|
177
|
+
server=attrs.get("server.address"),
|
|
178
|
+
response_id=attrs.get("gen_ai.response.id"),
|
|
179
|
+
input_tokens=input_tokens,
|
|
180
|
+
output_tokens=output_tokens,
|
|
181
|
+
reasoning_tokens=int(attrs.get("gen_ai.usage.details.reasoning_tokens", 0) or 0),
|
|
182
|
+
cost_usd=cost.total_usd,
|
|
183
|
+
cost_input_usd=cost.input_usd,
|
|
184
|
+
cost_output_usd=cost.output_usd,
|
|
185
|
+
finish_reasons=list(finish),
|
|
186
|
+
duration_s=float(span.get("duration") or 0.0),
|
|
187
|
+
thinking=[p.content for p in parts if p.kind == "thinking" and p.content],
|
|
188
|
+
text_out=[p.content for p in parts if p.kind == "text" and p.content],
|
|
189
|
+
decided_tool_calls=[p.name for p in parts if p.kind == "tool_call" and p.name],
|
|
190
|
+
available_tools=available,
|
|
191
|
+
thinking_config=thinking_config,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _parse_tool(span: Span, index: ChildIndex) -> ToolCall:
|
|
196
|
+
attrs = span["attributes"]
|
|
197
|
+
nested: AgentRun | None = None
|
|
198
|
+
for child in index.get(span["span_id"], []):
|
|
199
|
+
if _kind(child) == "agent":
|
|
200
|
+
nested = _parse_agent(child, index)
|
|
201
|
+
break
|
|
202
|
+
return ToolCall(
|
|
203
|
+
name=attrs.get("gen_ai.tool.name", span["span_name"].replace("execute_tool ", "")),
|
|
204
|
+
arguments=_maybe_json(attrs.get("gen_ai.tool.call.arguments")),
|
|
205
|
+
result=_stringify(attrs.get("gen_ai.tool.call.result")),
|
|
206
|
+
duration_s=float(span.get("duration") or 0.0),
|
|
207
|
+
nested=nested,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _parse_agent(span: Span, index: ChildIndex) -> AgentRun:
|
|
212
|
+
attrs = span["attributes"]
|
|
213
|
+
rounds: list[ModelCall] = []
|
|
214
|
+
current: ModelCall | None = None
|
|
215
|
+
user_prompt: str | None = None
|
|
216
|
+
|
|
217
|
+
for child in index.get(span["span_id"], []):
|
|
218
|
+
kind = _kind(child)
|
|
219
|
+
if kind == "model":
|
|
220
|
+
current = _parse_model_call(child)
|
|
221
|
+
rounds.append(current)
|
|
222
|
+
if user_prompt is None:
|
|
223
|
+
user_prompt = _user_prompt(child["attributes"])
|
|
224
|
+
elif kind == "tool":
|
|
225
|
+
if current is None:
|
|
226
|
+
current = ModelCall()
|
|
227
|
+
rounds.append(current)
|
|
228
|
+
current.tools.append(_parse_tool(child, index))
|
|
229
|
+
|
|
230
|
+
return AgentRun(
|
|
231
|
+
agent_name=attrs.get("gen_ai.agent.name", span["span_name"].replace("invoke_agent ", "")),
|
|
232
|
+
model=attrs.get("model_name", ""),
|
|
233
|
+
final_output=_stringify(_maybe_json(attrs.get("final_result"))),
|
|
234
|
+
user_prompt=user_prompt,
|
|
235
|
+
rounds=rounds,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass
|
|
240
|
+
class _Acc:
|
|
241
|
+
input: int = 0
|
|
242
|
+
output: int = 0
|
|
243
|
+
reasoning: int = 0
|
|
244
|
+
cost: float = 0.0
|
|
245
|
+
cost_known: bool = False
|
|
246
|
+
model_calls: int = 0
|
|
247
|
+
tools: int = 0
|
|
248
|
+
nested_agents: int = 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _accumulate(agent: AgentRun, acc: _Acc) -> None:
|
|
252
|
+
for call in agent.rounds:
|
|
253
|
+
acc.input += call.input_tokens
|
|
254
|
+
acc.output += call.output_tokens
|
|
255
|
+
acc.reasoning += call.reasoning_tokens
|
|
256
|
+
if call.model:
|
|
257
|
+
acc.model_calls += 1
|
|
258
|
+
if call.cost_usd is not None:
|
|
259
|
+
acc.cost += call.cost_usd
|
|
260
|
+
acc.cost_known = True
|
|
261
|
+
for tool in call.tools:
|
|
262
|
+
acc.tools += 1
|
|
263
|
+
if tool.nested is not None:
|
|
264
|
+
acc.nested_agents += 1
|
|
265
|
+
_accumulate(tool.nested, acc)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def parse_run(spans: list[Span], trace_id: str) -> WorkflowReport:
|
|
269
|
+
"""Build a :class:`WorkflowReport` from the spans of a single trace."""
|
|
270
|
+
index = _child_index(spans)
|
|
271
|
+
tool_ids = {s["span_id"] for s in spans if _kind(s) == "tool"}
|
|
272
|
+
top_agents = sorted(
|
|
273
|
+
(s for s in spans if _kind(s) == "agent" and s.get("parent_span_id") not in tool_ids),
|
|
274
|
+
key=lambda s: s.get("start_timestamp") or "",
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
turns = [_parse_agent(span, index) for span in top_agents]
|
|
278
|
+
|
|
279
|
+
acc = _Acc()
|
|
280
|
+
for turn in turns:
|
|
281
|
+
_accumulate(turn, acc)
|
|
282
|
+
|
|
283
|
+
roots = index.get(None, [])
|
|
284
|
+
duration = max((float(s.get("duration") or 0.0) for s in roots), default=0.0)
|
|
285
|
+
conversation = (
|
|
286
|
+
_conversation(top_agents[-1]["attributes"].get("pydantic_ai.all_messages"))
|
|
287
|
+
if top_agents
|
|
288
|
+
else []
|
|
289
|
+
)
|
|
290
|
+
model = next((t.model for t in turns if t.model), "")
|
|
291
|
+
|
|
292
|
+
return WorkflowReport(
|
|
293
|
+
meta=Meta(
|
|
294
|
+
trace_id=trace_id,
|
|
295
|
+
model=model,
|
|
296
|
+
duration_s=duration,
|
|
297
|
+
num_turns=len(turns),
|
|
298
|
+
cost_known=acc.cost_known,
|
|
299
|
+
),
|
|
300
|
+
totals=Totals(
|
|
301
|
+
total_tokens=acc.input + acc.output,
|
|
302
|
+
total_cost_usd=acc.cost if acc.cost_known else None,
|
|
303
|
+
reasoning_tokens=acc.reasoning,
|
|
304
|
+
num_model_calls=acc.model_calls,
|
|
305
|
+
num_tools=acc.tools,
|
|
306
|
+
num_nested_agents=acc.nested_agents,
|
|
307
|
+
num_turns=len(turns),
|
|
308
|
+
),
|
|
309
|
+
conversation=conversation,
|
|
310
|
+
turns=turns,
|
|
311
|
+
)
|
agentcanvas/pricing.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Compute exact per-request model costs from token counts via ``genai-prices``.
|
|
2
|
+
|
|
3
|
+
Logfire records token counts but not a dollar amount; this fills that gap using
|
|
4
|
+
Pydantic's local price database. If the model is unknown (or the library is
|
|
5
|
+
unavailable), the cost is reported as not known rather than guessed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class CostBreakdown:
|
|
15
|
+
total_usd: float | None
|
|
16
|
+
input_usd: float | None
|
|
17
|
+
output_usd: float | None
|
|
18
|
+
known: bool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_UNKNOWN = CostBreakdown(None, None, None, False)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _model_ref(model: str) -> str:
|
|
25
|
+
"""``openai/gpt-5.5`` -> ``gpt-5.5`` (genai-prices resolves the bare name)."""
|
|
26
|
+
return model.split("/")[-1] if model else model
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def price_request(model: str, input_tokens: int, output_tokens: int) -> CostBreakdown:
|
|
30
|
+
"""Return the cost breakdown of a single model request."""
|
|
31
|
+
if not model:
|
|
32
|
+
return _UNKNOWN
|
|
33
|
+
try:
|
|
34
|
+
from genai_prices import calc_price
|
|
35
|
+
from genai_prices.types import Usage
|
|
36
|
+
|
|
37
|
+
calc = calc_price(
|
|
38
|
+
Usage(input_tokens=input_tokens, output_tokens=output_tokens),
|
|
39
|
+
model_ref=_model_ref(model),
|
|
40
|
+
)
|
|
41
|
+
except Exception:
|
|
42
|
+
return _UNKNOWN
|
|
43
|
+
|
|
44
|
+
total = float(calc.total_price)
|
|
45
|
+
input_price = float(getattr(calc, "input_price", 0) or 0)
|
|
46
|
+
output_price = float(getattr(calc, "output_price", 0) or 0)
|
|
47
|
+
if input_price == 0 and output_price == 0 and total:
|
|
48
|
+
total_tokens = max(input_tokens + output_tokens, 1)
|
|
49
|
+
input_price = total * input_tokens / total_tokens
|
|
50
|
+
output_price = total * output_tokens / total_tokens
|
|
51
|
+
return CostBreakdown(total, input_price, output_price, True)
|