prompt-flamegraph 0.2.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,32 @@
1
+ # prompt-flamegraph - Lightweight prompt context flamegraph generator for LLMs.
2
+ # Copyright (C) 2025 fjjjuv
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """prompt_flamegraph: lightweight prompt context flamegraph generator for LLMs."""
18
+
19
+ from .core import build_tree, count_tokens, profile_prompt
20
+ from .diff import diff_prompts
21
+ from .waste import detect_waste, WasteReport, Finding
22
+
23
+ __version__ = "0.2.0"
24
+ __all__ = [
25
+ "build_tree",
26
+ "count_tokens",
27
+ "diff_prompts",
28
+ "detect_waste",
29
+ "Finding",
30
+ "profile_prompt",
31
+ "WasteReport",
32
+ ]
@@ -0,0 +1,20 @@
1
+ # prompt-flamegraph - Lightweight prompt context flamegraph generator for LLMs.
2
+ # Copyright (C) 2025 fjjjuv
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ from .cli import main
18
+ import sys
19
+
20
+ sys.exit(main())
@@ -0,0 +1,233 @@
1
+ # prompt-flamegraph - Lightweight prompt context flamegraph generator for LLMs.
2
+ # Copyright (C) 2025 fjjjuv
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """Command-line interface for prompt-flamegraph."""
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ SAMPLE_PROMPT = {
27
+ "system_prompt": (
28
+ "You are a helpful coding assistant. Be concise. "
29
+ "Always think step by step before answering."
30
+ ),
31
+ "tools": [
32
+ {
33
+ "name": "read_file",
34
+ "description": "Read a file from disk. Accepts a path argument.",
35
+ "schema": "{\"path\": \"string\"}",
36
+ },
37
+ {
38
+ "name": "run_command",
39
+ "description": "Execute a shell command and return stdout/stderr.",
40
+ "schema": "{\"command\": \"string\"}",
41
+ },
42
+ ],
43
+ "rag_context": {
44
+ "doc_1.py": "import os\n\ndef hello():\n return 'hello'\n",
45
+ "doc_2.py": "import sys\n\ndef world():\n return 'world'\n",
46
+ },
47
+ "chat_history": [
48
+ {"role": "user", "content": "Help me profile my prompt tokens."},
49
+ {"role": "assistant", "content": "Sure, I can help you understand where your tokens go."},
50
+ ],
51
+ }
52
+
53
+
54
+ def _load_input(value: str) -> dict:
55
+ path = Path(value)
56
+ if path.exists():
57
+ raw = path.read_text(encoding="utf-8")
58
+ else:
59
+ raw = value
60
+ try:
61
+ data = json.loads(raw)
62
+ except json.JSONDecodeError as exc:
63
+ raise SystemExit(f"Invalid JSON input: {exc}") from exc
64
+ if not isinstance(data, (dict, list)):
65
+ raise SystemExit("Input JSON must be a dict or a list.")
66
+ return data
67
+
68
+
69
+ def _detect_output(args: argparse.Namespace) -> str:
70
+ if args.output:
71
+ return args.output
72
+ ext = {"html": ".html", "svg": ".svg", "md": ".md"}.get(args.format, ".html")
73
+ return f"prompt_flamegraph{ext}"
74
+
75
+
76
+ def main(argv: list[str] | None = None) -> int:
77
+ parser = argparse.ArgumentParser(
78
+ prog="prompt-flamegraph",
79
+ description="Generate a lightweight flamegraph of your LLM prompt tokens.",
80
+ )
81
+ parser.add_argument(
82
+ "input",
83
+ nargs="?",
84
+ help="JSON file (or JSON string) representing the prompt structure.",
85
+ )
86
+ parser.add_argument(
87
+ "-o",
88
+ "--output",
89
+ default=None,
90
+ help="Output file (inferred from --format if not given).",
91
+ )
92
+ parser.add_argument(
93
+ "-t",
94
+ "--title",
95
+ default=None,
96
+ help="Title of the output.",
97
+ )
98
+ parser.add_argument(
99
+ "--format",
100
+ choices=["html", "svg", "md"],
101
+ default="html",
102
+ help="Output format (default: html).",
103
+ )
104
+ parser.add_argument(
105
+ "--tokenizer",
106
+ default=None,
107
+ help="Tokenizer to use: 'tiktoken', 'words', or a callable.",
108
+ )
109
+ parser.add_argument(
110
+ "--cost",
111
+ type=float,
112
+ default=None,
113
+ help="Cost per token (e.g. 1.5e-6 for $1.5 per million tokens).",
114
+ )
115
+ parser.add_argument(
116
+ "--diff",
117
+ metavar="FILE",
118
+ default=None,
119
+ help="Compare INPUT with another JSON file and output a diff flamegraph.",
120
+ )
121
+ parser.add_argument(
122
+ "--terminal",
123
+ action="store_true",
124
+ help="Print a terminal bar chart instead of writing a file.",
125
+ )
126
+ parser.add_argument(
127
+ "--no-waste",
128
+ action="store_true",
129
+ help="Disable waste detection for HTML output.",
130
+ )
131
+ parser.add_argument(
132
+ "--demo",
133
+ action="store_true",
134
+ help="Use the built-in sample prompt.",
135
+ )
136
+ parser.add_argument(
137
+ "--width",
138
+ type=int,
139
+ default=1200,
140
+ help="Graph width in pixels (SVG/HTML).",
141
+ )
142
+ parser.add_argument(
143
+ "--height",
144
+ type=int,
145
+ default=720,
146
+ help="Graph max height in pixels (HTML).",
147
+ )
148
+
149
+ args = parser.parse_args(argv)
150
+
151
+ if args.demo:
152
+ data = SAMPLE_PROMPT
153
+ elif args.input:
154
+ data = _load_input(args.input)
155
+ else:
156
+ parser.print_help()
157
+ return 1
158
+
159
+ output = _detect_output(args)
160
+
161
+ if args.terminal:
162
+ from .core import build_tree
163
+ from .terminal import to_terminal
164
+
165
+ tree = build_tree(data, tokenizer=args.tokenizer)
166
+ to_terminal(tree, title=args.title, cost_per_token=args.cost)
167
+ return 0
168
+
169
+ if args.diff:
170
+ from .core import build_tree
171
+ from .diff import build_diff_tree
172
+ from .render import to_html
173
+ from .export import to_svg, to_markdown
174
+
175
+ v2 = _load_input(args.diff)
176
+ t1 = build_tree(data, tokenizer=args.tokenizer)
177
+ t2 = build_tree(v2, tokenizer=args.tokenizer)
178
+ diff_tree = build_diff_tree(t1, t2)
179
+
180
+ if args.format == "html":
181
+ html = to_html(diff_tree, title=args.title or "Prompt Diff", cost_per_token=args.cost, width=args.width, height=args.height)
182
+ with open(output, "w", encoding="utf-8") as f:
183
+ f.write(html)
184
+ elif args.format == "svg":
185
+ from .export import to_svg
186
+ svg = to_svg(diff_tree, title=args.title or "Prompt Diff", width=args.width)
187
+ with open(output, "w", encoding="utf-8") as f:
188
+ f.write(svg)
189
+ elif args.format == "md":
190
+ from .export import to_markdown
191
+ md = to_markdown(diff_tree, title=args.title or "Prompt Diff", cost_per_token=args.cost)
192
+ with open(output, "w", encoding="utf-8") as f:
193
+ f.write(md)
194
+
195
+ print(f"Diff written to: {output}")
196
+ return 0
197
+
198
+ if args.format == "html":
199
+ from .core import profile_prompt
200
+
201
+ profile_prompt(
202
+ data,
203
+ output=output,
204
+ title=args.title,
205
+ tokenizer=args.tokenizer,
206
+ cost_per_token=args.cost,
207
+ detect_waste=not args.no_waste,
208
+ width=args.width,
209
+ height=args.height,
210
+ )
211
+ elif args.format == "svg":
212
+ from .core import build_tree
213
+ from .export import to_svg
214
+
215
+ tree = build_tree(data, tokenizer=args.tokenizer)
216
+ svg = to_svg(tree, title=args.title, width=args.width)
217
+ with open(output, "w", encoding="utf-8") as f:
218
+ f.write(svg)
219
+ elif args.format == "md":
220
+ from .core import build_tree
221
+ from .export import to_markdown
222
+
223
+ tree = build_tree(data, tokenizer=args.tokenizer)
224
+ md = to_markdown(tree, title=args.title, cost_per_token=args.cost)
225
+ with open(output, "w", encoding="utf-8") as f:
226
+ f.write(md)
227
+
228
+ print(f"Flamegraph written to: {output}")
229
+ return 0
230
+
231
+
232
+ if __name__ == "__main__":
233
+ sys.exit(main())
@@ -0,0 +1,180 @@
1
+ # prompt-flamegraph - Lightweight prompt context flamegraph generator for LLMs.
2
+ # Copyright (C) 2025 fjjjuv
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """Core logic: token counting and tree building."""
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ from dataclasses import dataclass, field
24
+ from typing import Any, Callable, Iterable
25
+
26
+
27
+ @dataclass
28
+ class Node:
29
+ name: str
30
+ tokens: int
31
+ children: list[Node] = field(default_factory=list)
32
+ text: str | None = None
33
+ change: str | None = None # 'added', 'removed', 'same', 'changed'
34
+ delta: int = 0
35
+
36
+ @property
37
+ def is_leaf(self) -> bool:
38
+ return not self.children
39
+
40
+
41
+ Tokenizer = Callable[[str], int]
42
+
43
+
44
+ def _default_count(text: str) -> int:
45
+ """Fallback token estimator when tiktoken is not installed."""
46
+ text = text.strip()
47
+ if not text:
48
+ return 0
49
+ # Word-ish tokens plus isolated punctuation. Rough but stable and fast.
50
+ return len(re.findall(r"\w+|[^\w\s]", text, flags=re.UNICODE))
51
+
52
+
53
+ def _load_tiktoken() -> Tokenizer | None:
54
+ try:
55
+ import tiktoken
56
+ except ImportError:
57
+ return None
58
+
59
+ enc = tiktoken.get_encoding("cl100k_base")
60
+
61
+ def count(text: str) -> int:
62
+ return len(enc.encode(text))
63
+
64
+ return count
65
+
66
+
67
+ def get_tokenizer(
68
+ tokenizer: Tokenizer | str | None = None,
69
+ ) -> Tokenizer:
70
+ """Resolve a tokenizer from a name, callable, or default."""
71
+ if tokenizer is None:
72
+ tiktoken_count = _load_tiktoken()
73
+ if tiktoken_count is not None:
74
+ return tiktoken_count
75
+ return _default_count
76
+
77
+ if callable(tokenizer):
78
+ return tokenizer
79
+
80
+ if tokenizer == "tiktoken" or tokenizer.startswith("cl100k"):
81
+ tiktoken_count = _load_tiktoken()
82
+ if tiktoken_count is None:
83
+ raise ImportError(
84
+ "tiktoken is not installed. Run 'pip install prompt-flamegraph[tiktoken]'"
85
+ )
86
+ return tiktoken_count
87
+
88
+ if tokenizer == "words":
89
+ return _default_count
90
+
91
+ raise ValueError(f"Unknown tokenizer: {tokenizer}")
92
+
93
+
94
+ def count_tokens(text: str, tokenizer: Tokenizer | str | None = None) -> int:
95
+ """Count tokens in a string using the selected tokenizer."""
96
+ return get_tokenizer(tokenizer)(text)
97
+
98
+
99
+ def _guess_name(value: Any, index: int) -> str:
100
+ if isinstance(value, dict):
101
+ for key in ("name", "title", "id", "role", "filename", "file", "source"):
102
+ candidate = value.get(key)
103
+ if isinstance(candidate, str) and candidate.strip():
104
+ return candidate
105
+ return f"item_{index}"
106
+
107
+
108
+ def _to_text(value: Any) -> str:
109
+ if isinstance(value, str):
110
+ return value
111
+ return json.dumps(value, ensure_ascii=False, sort_keys=True)
112
+
113
+
114
+ def build_tree(
115
+ data: Any,
116
+ name: str = "prompt",
117
+ tokenizer: Tokenizer | str | None = None,
118
+ _count_fn: Callable[[str], int] | None = None,
119
+ ) -> Node:
120
+ """Recursively build a token tree from a nested dict/list/string."""
121
+ count_fn = _count_fn or get_tokenizer(tokenizer)
122
+
123
+ if isinstance(data, dict):
124
+ children = [
125
+ build_tree(value, name=key, _count_fn=count_fn)
126
+ for key, value in data.items()
127
+ ]
128
+ return Node(name=name, tokens=sum(c.tokens for c in children), children=children)
129
+
130
+ if isinstance(data, (list, tuple)):
131
+ children = [
132
+ build_tree(value, name=_guess_name(value, i), _count_fn=count_fn)
133
+ for i, value in enumerate(data)
134
+ ]
135
+ return Node(name=name, tokens=sum(c.tokens for c in children), children=children)
136
+
137
+ text = _to_text(data)
138
+ return Node(name=name, tokens=count_fn(text), text=text)
139
+
140
+
141
+ def flatten_tree(node: Node) -> list[Node]:
142
+ """Return a flat list of all nodes in depth-first order."""
143
+ out = [node]
144
+ for child in node.children:
145
+ out.extend(flatten_tree(child))
146
+ return out
147
+
148
+
149
+ def profile_prompt(
150
+ data: Any,
151
+ output: str | None = "prompt_flamegraph.html",
152
+ title: str | None = None,
153
+ tokenizer: Tokenizer | str | None = None,
154
+ cost_per_token: float | None = None,
155
+ detect_waste: bool = True,
156
+ width: int = 1200,
157
+ height: int = 720,
158
+ ) -> str:
159
+ """Build a prompt token tree and render it to a standalone HTML flamegraph."""
160
+ from . import render
161
+
162
+ tree = build_tree(data, tokenizer=tokenizer)
163
+ waste_report = None
164
+ if detect_waste:
165
+ from .waste import detect_waste
166
+
167
+ waste_report = detect_waste(tree)
168
+
169
+ html = render.to_html(
170
+ tree,
171
+ title=title or "Prompt Flamegraph",
172
+ cost_per_token=cost_per_token,
173
+ waste_report=waste_report,
174
+ width=width,
175
+ height=height,
176
+ )
177
+ if output:
178
+ with open(output, "w", encoding="utf-8") as f:
179
+ f.write(html)
180
+ return html
@@ -0,0 +1,131 @@
1
+ # prompt-flamegraph - Lightweight prompt context flamegraph generator for LLMs.
2
+ # Copyright (C) 2025 fjjjuv
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """Compare two prompt trees and build a diff flamegraph."""
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any
22
+
23
+ from .core import Node
24
+
25
+
26
+ def _all_paths(node: Node, path: tuple[str, ...]) -> dict[tuple[str, ...], tuple[int, str | None, list[str]]]:
27
+ """Return a dict path -> (tokens, text, child_names) for every node."""
28
+ current = (*path, node.name)
29
+ result = {current: (node.tokens, node.text, [c.name for c in node.children])}
30
+ for child in node.children:
31
+ result.update(_all_paths(child, current))
32
+ return result
33
+
34
+
35
+ def _build_subtree(
36
+ name: str,
37
+ path: tuple[str, ...],
38
+ v1_map: dict,
39
+ v2_map: dict,
40
+ ) -> Node:
41
+ """Recursively build the unified diff tree for a given path prefix."""
42
+ in_v1 = path in v1_map
43
+ in_v2 = path in v2_map
44
+
45
+ if in_v2:
46
+ tokens_v2, text_v2, children_v2 = v2_map[path]
47
+ else:
48
+ tokens_v2, text_v2, children_v2 = 0, None, []
49
+
50
+ if in_v1:
51
+ tokens_v1, text_v1, children_v1 = v1_map[path]
52
+ else:
53
+ tokens_v1, text_v1, children_v1 = 0, None, []
54
+
55
+ if in_v1 and in_v2:
56
+ if text_v1 == text_v2 and tokens_v1 == tokens_v2:
57
+ change = "same"
58
+ else:
59
+ change = "changed"
60
+ delta = tokens_v2 - tokens_v1
61
+ elif in_v2 and not in_v1:
62
+ change = "added"
63
+ delta = tokens_v2
64
+ elif in_v1 and not in_v2:
65
+ change = "removed"
66
+ delta = -tokens_v1
67
+ else:
68
+ # Should not happen
69
+ change = "same"
70
+ delta = 0
71
+
72
+ # Child union
73
+ child_names = sorted(set(children_v1) | set(children_v2))
74
+ children: list[Node] = []
75
+ for child_name in child_names:
76
+ child_path = (*path, child_name)
77
+ children.append(_build_subtree(child_name, child_path, v1_map, v2_map))
78
+
79
+ return Node(
80
+ name=name,
81
+ tokens=tokens_v2 or tokens_v1,
82
+ children=children,
83
+ text=text_v2 or text_v1,
84
+ change=change,
85
+ delta=delta,
86
+ )
87
+
88
+
89
+ def build_diff_tree(v1: Node, v2: Node) -> Node:
90
+ """Build a unified diff tree from two prompt trees."""
91
+ v1_map = _all_paths(v1, ())
92
+ v2_map = _all_paths(v2, ())
93
+ root_name = v2.name if v2.name == v1.name else "diff"
94
+ root_path = (root_name,)
95
+ # Ensure both maps contain the chosen root
96
+ if (v1.name,) in v1_map and (v2.name,) in v2_map and v1.name == v2.name:
97
+ v1_map[root_path] = v1_map.pop((v1.name,))
98
+ v2_map[root_path] = v2_map.pop((v2.name,))
99
+ tree = _build_subtree(root_name, root_path, v1_map, v2_map)
100
+ tree.tokens = max(v1.tokens, v2.tokens, 1)
101
+ return tree
102
+
103
+
104
+ def diff_prompts(
105
+ v1: Any,
106
+ v2: Any,
107
+ output: str | None = "prompt_diff.html",
108
+ title: str | None = None,
109
+ tokenizer: Any = None,
110
+ cost_per_token: float | None = None,
111
+ width: int = 1200,
112
+ height: int = 720,
113
+ ) -> str:
114
+ """Build and render a diff flamegraph between two prompts."""
115
+ from .core import build_tree
116
+ from .render import to_html
117
+
118
+ tree1 = build_tree(v1, name="v1", tokenizer=tokenizer)
119
+ tree2 = build_tree(v2, name="v2", tokenizer=tokenizer)
120
+ diff_tree = build_diff_tree(tree1, tree2)
121
+ html = to_html(
122
+ diff_tree,
123
+ title=title or "Prompt Diff",
124
+ cost_per_token=cost_per_token,
125
+ width=width,
126
+ height=height,
127
+ )
128
+ if output:
129
+ with open(output, "w", encoding="utf-8") as f:
130
+ f.write(html)
131
+ return html