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.
@@ -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
+ ]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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)