tokenprof 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.
- tokenprof/__init__.py +16 -0
- tokenprof/adapters/__init__.py +45 -0
- tokenprof/adapters/anthropic_messages.py +136 -0
- tokenprof/adapters/base.py +59 -0
- tokenprof/adapters/openai_chat.py +107 -0
- tokenprof/attribute.py +93 -0
- tokenprof/cache.py +108 -0
- tokenprof/cli.py +162 -0
- tokenprof/cost.py +74 -0
- tokenprof/diff.py +69 -0
- tokenprof/py.typed +0 -0
- tokenprof/record.py +135 -0
- tokenprof/report/__init__.py +11 -0
- tokenprof/report/json_out.py +82 -0
- tokenprof/report/table.py +222 -0
- tokenprof/tokenizer.py +72 -0
- tokenprof/types.py +134 -0
- tokenprof-0.1.0.dist-info/METADATA +202 -0
- tokenprof-0.1.0.dist-info/RECORD +22 -0
- tokenprof-0.1.0.dist-info/WHEEL +4 -0
- tokenprof-0.1.0.dist-info/entry_points.txt +2 -0
- tokenprof-0.1.0.dist-info/licenses/LICENSE +21 -0
tokenprof/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""tokenprof: a profiler for the context window."""
|
|
2
|
+
|
|
3
|
+
from tokenprof.attribute import profile_request
|
|
4
|
+
from tokenprof.diff import diff_turns
|
|
5
|
+
from tokenprof.types import Category, Profile, Segment, Turn
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Category",
|
|
11
|
+
"Profile",
|
|
12
|
+
"Segment",
|
|
13
|
+
"Turn",
|
|
14
|
+
"profile_request",
|
|
15
|
+
"diff_turns",
|
|
16
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Adapter registry.
|
|
2
|
+
|
|
3
|
+
Adding a framework means adding a module here and one line to ADAPTERS.
|
|
4
|
+
That is the intended contribution surface.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from tokenprof.adapters.anthropic_messages import AnthropicMessagesAdapter
|
|
12
|
+
from tokenprof.adapters.base import Adapter
|
|
13
|
+
from tokenprof.adapters.openai_chat import OpenAIChatAdapter
|
|
14
|
+
|
|
15
|
+
ADAPTERS: list[Adapter] = [
|
|
16
|
+
AnthropicMessagesAdapter(),
|
|
17
|
+
OpenAIChatAdapter(),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_adapter(name: str) -> Adapter:
|
|
22
|
+
for a in ADAPTERS:
|
|
23
|
+
if a.name == name:
|
|
24
|
+
return a
|
|
25
|
+
known = ", ".join(a.name for a in ADAPTERS)
|
|
26
|
+
raise KeyError(f"unknown adapter {name!r}. known adapters: {known}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def detect(request: dict[str, Any]) -> Adapter:
|
|
30
|
+
"""Pick an adapter by payload shape.
|
|
31
|
+
|
|
32
|
+
Order matters: Anthropic is checked first because its signals are
|
|
33
|
+
positive (top-level system, input_schema) while the OpenAI adapter
|
|
34
|
+
accepts the general case.
|
|
35
|
+
"""
|
|
36
|
+
for a in ADAPTERS:
|
|
37
|
+
if a.matches(request):
|
|
38
|
+
return a
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"could not detect a provider from this payload. "
|
|
41
|
+
"Pass --provider explicitly, or open an issue with a redacted sample."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = ["ADAPTERS", "Adapter", "detect", "get_adapter"]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Anthropic Messages API request payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from tokenprof.adapters.base import measure, stringify
|
|
9
|
+
from tokenprof.tokenizer import Tokenizer
|
|
10
|
+
from tokenprof.types import Category, Segment
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AnthropicMessagesAdapter:
|
|
14
|
+
name = "anthropic_messages"
|
|
15
|
+
|
|
16
|
+
def matches(self, request: dict[str, Any]) -> bool:
|
|
17
|
+
if not isinstance(request.get("messages"), list):
|
|
18
|
+
return False
|
|
19
|
+
if isinstance(request.get("system"), (str, list)):
|
|
20
|
+
return True
|
|
21
|
+
for spec in request.get("tools") or []:
|
|
22
|
+
if isinstance(spec, dict) and "input_schema" in spec:
|
|
23
|
+
return True
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
def model_of(self, request: dict[str, Any]) -> str:
|
|
27
|
+
return str(request.get("model", ""))
|
|
28
|
+
|
|
29
|
+
def segments(self, request: dict[str, Any], tok: Tokenizer) -> list[Segment]:
|
|
30
|
+
# Emitted in the order the provider actually assembles the prompt:
|
|
31
|
+
# tools, then system, then messages. Cache prefixes are computed over
|
|
32
|
+
# that order, so getting it wrong would put the breakpoint in the
|
|
33
|
+
# wrong place.
|
|
34
|
+
out: list[Segment] = []
|
|
35
|
+
marked: list[int] = []
|
|
36
|
+
|
|
37
|
+
for spec in request.get("tools") or []:
|
|
38
|
+
name = spec.get("name", "?") if isinstance(spec, dict) else "?"
|
|
39
|
+
t, c, dg = measure(stringify(spec), tok)
|
|
40
|
+
if isinstance(spec, dict) and spec.get("cache_control"):
|
|
41
|
+
marked.append(len(out))
|
|
42
|
+
out.append(Segment(Category.TOOL_SCHEMA, t, c, name=name, digest=dg))
|
|
43
|
+
|
|
44
|
+
system = request.get("system")
|
|
45
|
+
if system is not None:
|
|
46
|
+
if isinstance(system, list):
|
|
47
|
+
for i, block in enumerate(system):
|
|
48
|
+
text = block.get("text", "") if isinstance(block, dict) else str(block)
|
|
49
|
+
t, c, dg = measure(text, tok)
|
|
50
|
+
if not (t or c):
|
|
51
|
+
continue
|
|
52
|
+
if isinstance(block, dict) and block.get("cache_control"):
|
|
53
|
+
marked.append(len(out))
|
|
54
|
+
out.append(
|
|
55
|
+
Segment(Category.SYSTEM, t, c, name=f"block[{i}]", index=i, digest=dg)
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
58
|
+
t, c, dg = measure(str(system), tok)
|
|
59
|
+
if t or c:
|
|
60
|
+
out.append(Segment(Category.SYSTEM, t, c, name="system", digest=dg))
|
|
61
|
+
|
|
62
|
+
messages = request.get("messages") or []
|
|
63
|
+
last_user = _last_user_index(messages)
|
|
64
|
+
|
|
65
|
+
for i, msg in enumerate(messages):
|
|
66
|
+
if not isinstance(msg, dict):
|
|
67
|
+
continue
|
|
68
|
+
role = msg.get("role", "")
|
|
69
|
+
content = msg.get("content")
|
|
70
|
+
|
|
71
|
+
# A single user message can carry several tool_result blocks. They
|
|
72
|
+
# are attributed individually, because "which tool is flooding the
|
|
73
|
+
# context" is the question worth answering.
|
|
74
|
+
for cat, name, text, is_marked in _split_content(content, role):
|
|
75
|
+
t, c, dg = measure(text, tok)
|
|
76
|
+
if t == 0 and c == 0:
|
|
77
|
+
continue
|
|
78
|
+
if cat is None:
|
|
79
|
+
cat = _role_category(role, i == last_user)
|
|
80
|
+
if is_marked:
|
|
81
|
+
marked.append(len(out))
|
|
82
|
+
out.append(Segment(cat, t, c, name=name, index=i, digest=dg))
|
|
83
|
+
|
|
84
|
+
# A cache_control marker caches the whole prefix up to and including
|
|
85
|
+
# that block, so only the last marker matters for how much is cached.
|
|
86
|
+
if marked:
|
|
87
|
+
cutoff = max(marked)
|
|
88
|
+
out = [
|
|
89
|
+
dataclasses.replace(s, cached=True) if i <= cutoff else s for i, s in enumerate(out)
|
|
90
|
+
]
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _role_category(role: str, is_last_user: bool) -> Category:
|
|
95
|
+
if role == "user":
|
|
96
|
+
return Category.CURRENT_USER if is_last_user else Category.HISTORY_USER
|
|
97
|
+
if role == "assistant":
|
|
98
|
+
return Category.HISTORY_ASSISTANT
|
|
99
|
+
return Category.OTHER
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _last_user_index(messages: list[Any]) -> int:
|
|
103
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
104
|
+
m = messages[i]
|
|
105
|
+
if isinstance(m, dict) and m.get("role") == "user":
|
|
106
|
+
return i
|
|
107
|
+
return -1
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _split_content(content: Any, role: str) -> list[tuple[Category | None, str, str, bool]]:
|
|
111
|
+
"""Break message content into (category_override, name, text, cache_marked)."""
|
|
112
|
+
if content is None:
|
|
113
|
+
return []
|
|
114
|
+
if isinstance(content, str):
|
|
115
|
+
return [(None, "", content, False)]
|
|
116
|
+
if not isinstance(content, list):
|
|
117
|
+
return [(None, "", stringify(content), False)]
|
|
118
|
+
|
|
119
|
+
out: list[tuple[Category | None, str, str, bool]] = []
|
|
120
|
+
for block in content:
|
|
121
|
+
if not isinstance(block, dict):
|
|
122
|
+
out.append((None, "", str(block), False))
|
|
123
|
+
continue
|
|
124
|
+
marked = bool(block.get("cache_control"))
|
|
125
|
+
btype = block.get("type")
|
|
126
|
+
if btype == "tool_result":
|
|
127
|
+
name = str(block.get("tool_use_id") or "tool_result")
|
|
128
|
+
out.append((Category.TOOL_RESULT, name, stringify(block.get("content")), marked))
|
|
129
|
+
elif btype == "tool_use":
|
|
130
|
+
name = str(block.get("name") or "tool_use")
|
|
131
|
+
out.append((Category.HISTORY_ASSISTANT, name, stringify(block), marked))
|
|
132
|
+
elif btype == "text":
|
|
133
|
+
out.append((None, "", block.get("text", ""), marked))
|
|
134
|
+
else:
|
|
135
|
+
out.append((None, str(btype or ""), stringify(block), marked))
|
|
136
|
+
return out
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Adapter contract.
|
|
2
|
+
|
|
3
|
+
An adapter turns one provider's request payload into a list of Segments.
|
|
4
|
+
That is the whole interface. Keeping it this narrow is what makes a new
|
|
5
|
+
adapter an afternoon of work instead of a refactor, and it is why the core
|
|
6
|
+
has no dependency on any framework.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any, Protocol
|
|
14
|
+
|
|
15
|
+
from tokenprof.tokenizer import Tokenizer
|
|
16
|
+
from tokenprof.types import Segment
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Adapter(Protocol):
|
|
20
|
+
#: Short identifier used by --provider and in reports.
|
|
21
|
+
name: str
|
|
22
|
+
|
|
23
|
+
def matches(self, request: dict[str, Any]) -> bool:
|
|
24
|
+
"""True if this adapter understands the payload shape."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
def model_of(self, request: dict[str, Any]) -> str: ...
|
|
28
|
+
|
|
29
|
+
def segments(self, request: dict[str, Any], tok: Tokenizer) -> list[Segment]: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def measure(text: str, tok: Tokenizer) -> tuple[int, int, str]:
|
|
33
|
+
"""Return (tokens, chars, digest) for a string.
|
|
34
|
+
|
|
35
|
+
The digest is what makes cache analysis possible: two turns can be walked
|
|
36
|
+
segment by segment to find exactly where the prompt prefix stops being
|
|
37
|
+
byte-identical, which is the difference between a cache hit and paying
|
|
38
|
+
full price.
|
|
39
|
+
"""
|
|
40
|
+
return tok.count(text), len(text), digest_of(text)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def digest_of(text: str) -> str:
|
|
44
|
+
return hashlib.blake2b(text.encode("utf-8", "replace"), digest_size=8).hexdigest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def stringify(value: Any) -> str:
|
|
48
|
+
"""Flatten a value the way a provider serializes it into the prompt.
|
|
49
|
+
|
|
50
|
+
Tool schemas and structured content reach the model as JSON, so counting
|
|
51
|
+
the JSON is closer to the truth than counting a Python repr. Not exact,
|
|
52
|
+
since providers wrap this in their own formatting, and the wrapper is
|
|
53
|
+
small relative to the payload.
|
|
54
|
+
"""
|
|
55
|
+
if value is None:
|
|
56
|
+
return ""
|
|
57
|
+
if isinstance(value, str):
|
|
58
|
+
return value
|
|
59
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""OpenAI Chat Completions request payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from tokenprof.adapters.base import measure, stringify
|
|
8
|
+
from tokenprof.tokenizer import Tokenizer
|
|
9
|
+
from tokenprof.types import Category, Segment
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OpenAIChatAdapter:
|
|
13
|
+
name = "openai_chat"
|
|
14
|
+
|
|
15
|
+
def matches(self, request: dict[str, Any]) -> bool:
|
|
16
|
+
"""Discriminate against Anthropic, which also uses a "messages" list.
|
|
17
|
+
|
|
18
|
+
Two signals only one of them has: Anthropic puts the system prompt at
|
|
19
|
+
the top level, and its tool specs carry "input_schema". OpenAI wraps
|
|
20
|
+
tool specs in "function" and keeps the system prompt inside messages.
|
|
21
|
+
"""
|
|
22
|
+
if not isinstance(request.get("messages"), list):
|
|
23
|
+
return False
|
|
24
|
+
if isinstance(request.get("system"), (str, list)):
|
|
25
|
+
return False
|
|
26
|
+
for spec in request.get("tools") or []:
|
|
27
|
+
if isinstance(spec, dict):
|
|
28
|
+
if "input_schema" in spec:
|
|
29
|
+
return False
|
|
30
|
+
if "function" in spec:
|
|
31
|
+
return True
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
def model_of(self, request: dict[str, Any]) -> str:
|
|
35
|
+
return str(request.get("model", ""))
|
|
36
|
+
|
|
37
|
+
def segments(self, request: dict[str, Any], tok: Tokenizer) -> list[Segment]:
|
|
38
|
+
out: list[Segment] = []
|
|
39
|
+
|
|
40
|
+
for spec in request.get("tools") or []:
|
|
41
|
+
fn = spec.get("function", spec) if isinstance(spec, dict) else {}
|
|
42
|
+
name = fn.get("name", "?") if isinstance(fn, dict) else "?"
|
|
43
|
+
t, c, dg = measure(stringify(spec), tok)
|
|
44
|
+
out.append(Segment(Category.TOOL_SCHEMA, t, c, name=name, digest=dg))
|
|
45
|
+
|
|
46
|
+
messages = request.get("messages") or []
|
|
47
|
+
last_user = _last_user_index(messages)
|
|
48
|
+
|
|
49
|
+
for i, msg in enumerate(messages):
|
|
50
|
+
if not isinstance(msg, dict):
|
|
51
|
+
continue
|
|
52
|
+
role = msg.get("role", "")
|
|
53
|
+
text = _content_text(msg.get("content"))
|
|
54
|
+
|
|
55
|
+
if role == "assistant" and msg.get("tool_calls"):
|
|
56
|
+
text += stringify(msg["tool_calls"])
|
|
57
|
+
|
|
58
|
+
t, c, dg = measure(text, tok)
|
|
59
|
+
if t == 0 and c == 0:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
if role == "system" or role == "developer":
|
|
63
|
+
out.append(Segment(Category.SYSTEM, t, c, name=role, index=i, digest=dg))
|
|
64
|
+
elif role == "tool":
|
|
65
|
+
out.append(
|
|
66
|
+
Segment(
|
|
67
|
+
Category.TOOL_RESULT,
|
|
68
|
+
t,
|
|
69
|
+
c,
|
|
70
|
+
name=str(msg.get("name") or msg.get("tool_call_id") or "tool"),
|
|
71
|
+
index=i,
|
|
72
|
+
digest=dg,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
elif role == "assistant":
|
|
76
|
+
out.append(Segment(Category.HISTORY_ASSISTANT, t, c, index=i, digest=dg))
|
|
77
|
+
elif role == "user":
|
|
78
|
+
cat = Category.CURRENT_USER if i == last_user else Category.HISTORY_USER
|
|
79
|
+
out.append(Segment(cat, t, c, index=i, digest=dg))
|
|
80
|
+
else:
|
|
81
|
+
out.append(Segment(Category.OTHER, t, c, name=role, index=i, digest=dg))
|
|
82
|
+
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _last_user_index(messages: list[Any]) -> int:
|
|
87
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
88
|
+
m = messages[i]
|
|
89
|
+
if isinstance(m, dict) and m.get("role") == "user":
|
|
90
|
+
return i
|
|
91
|
+
return -1
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _content_text(content: Any) -> str:
|
|
95
|
+
if content is None:
|
|
96
|
+
return ""
|
|
97
|
+
if isinstance(content, str):
|
|
98
|
+
return content
|
|
99
|
+
if isinstance(content, list):
|
|
100
|
+
parts = []
|
|
101
|
+
for block in content:
|
|
102
|
+
if isinstance(block, dict):
|
|
103
|
+
parts.append(block.get("text") or stringify(block))
|
|
104
|
+
else:
|
|
105
|
+
parts.append(str(block))
|
|
106
|
+
return "".join(parts)
|
|
107
|
+
return stringify(content)
|
tokenprof/attribute.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Turn a raw request payload into a Turn."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterable, Iterator
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from tokenprof.adapters import Adapter, detect, get_adapter
|
|
10
|
+
from tokenprof.tokenizer import Tokenizer, get_tokenizer
|
|
11
|
+
from tokenprof.types import Profile, Turn
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def profile_request(
|
|
15
|
+
request: dict[str, Any],
|
|
16
|
+
*,
|
|
17
|
+
provider: str | None = None,
|
|
18
|
+
tokenizer: Tokenizer | None = None,
|
|
19
|
+
index: int = 0,
|
|
20
|
+
) -> Turn:
|
|
21
|
+
"""Profile a single request payload."""
|
|
22
|
+
adapter: Adapter = get_adapter(provider) if provider else detect(request)
|
|
23
|
+
model = adapter.model_of(request)
|
|
24
|
+
tok = tokenizer or get_tokenizer(model)
|
|
25
|
+
return Turn(
|
|
26
|
+
segments=adapter.segments(request, tok),
|
|
27
|
+
model=model,
|
|
28
|
+
provider=adapter.name,
|
|
29
|
+
index=index,
|
|
30
|
+
tokenizer=tok.name,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def profile_stream(
|
|
35
|
+
records: Iterable[dict[str, Any]],
|
|
36
|
+
*,
|
|
37
|
+
provider: str | None = None,
|
|
38
|
+
tokenizer: Tokenizer | None = None,
|
|
39
|
+
) -> Profile:
|
|
40
|
+
"""Profile a sequence of records into one Profile.
|
|
41
|
+
|
|
42
|
+
The tokenizer is resolved once from the first record and reused, because
|
|
43
|
+
building a tiktoken encoding per turn dominates runtime on long sessions.
|
|
44
|
+
"""
|
|
45
|
+
profile = Profile()
|
|
46
|
+
tok = tokenizer
|
|
47
|
+
for i, rec in enumerate(records):
|
|
48
|
+
request = unwrap(rec)
|
|
49
|
+
if tok is None:
|
|
50
|
+
adapter = get_adapter(provider) if provider else detect(request)
|
|
51
|
+
tok = get_tokenizer(adapter.model_of(request))
|
|
52
|
+
profile.turns.append(profile_request(request, provider=provider, tokenizer=tok, index=i))
|
|
53
|
+
return profile
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def unwrap(record: dict[str, Any]) -> dict[str, Any]:
|
|
57
|
+
"""Accept either a bare request payload or a wrapper around one.
|
|
58
|
+
|
|
59
|
+
Recorders tend to store {"request": {...}, "ts": ...}. Both shapes are
|
|
60
|
+
accepted so nobody has to reshape their logs before getting an answer.
|
|
61
|
+
"""
|
|
62
|
+
for key in ("request", "body", "payload", "kwargs"):
|
|
63
|
+
inner = record.get(key)
|
|
64
|
+
if isinstance(inner, dict) and "messages" in inner:
|
|
65
|
+
return inner
|
|
66
|
+
return record
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def read_jsonl(path: str) -> Iterator[dict[str, Any]]:
|
|
70
|
+
"""Read a .jsonl file, skipping blank lines.
|
|
71
|
+
|
|
72
|
+
A malformed line raises with its line number rather than being dropped
|
|
73
|
+
silently, because a profile that quietly ignored half your session is
|
|
74
|
+
worse than one that refuses to run.
|
|
75
|
+
"""
|
|
76
|
+
import sys
|
|
77
|
+
|
|
78
|
+
handle = sys.stdin if path == "-" else open(path, encoding="utf-8")
|
|
79
|
+
try:
|
|
80
|
+
for lineno, line in enumerate(handle, 1):
|
|
81
|
+
line = line.strip()
|
|
82
|
+
if not line:
|
|
83
|
+
continue
|
|
84
|
+
try:
|
|
85
|
+
obj = json.loads(line)
|
|
86
|
+
except json.JSONDecodeError as exc:
|
|
87
|
+
raise ValueError(f"{path}:{lineno}: invalid JSON: {exc}") from exc
|
|
88
|
+
if not isinstance(obj, dict):
|
|
89
|
+
raise ValueError(f"{path}:{lineno}: expected a JSON object")
|
|
90
|
+
yield obj
|
|
91
|
+
finally:
|
|
92
|
+
if handle is not sys.stdin:
|
|
93
|
+
handle.close()
|
tokenprof/cache.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Prompt cache analysis.
|
|
2
|
+
|
|
3
|
+
Providers cache a *prefix*. If the first N tokens of a request are
|
|
4
|
+
byte-identical to the previous one, you pay a fraction for them. One volatile
|
|
5
|
+
value near the front (a timestamp, a session id, a re-ordered tool list) moves
|
|
6
|
+
the break point to zero and you quietly pay full price on every turn while
|
|
7
|
+
your dashboard still reports that caching is enabled.
|
|
8
|
+
|
|
9
|
+
This module finds that break point by walking two turns segment by segment
|
|
10
|
+
and comparing content digests.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from tokenprof.types import Profile, Segment, Turn
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class PrefixBreak:
|
|
22
|
+
"""Where one turn stopped matching the previous one."""
|
|
23
|
+
|
|
24
|
+
before_index: int
|
|
25
|
+
after_index: int
|
|
26
|
+
#: Segments that matched, counted from the start of the prompt.
|
|
27
|
+
stable_segments: int
|
|
28
|
+
stable_tokens: int
|
|
29
|
+
total_tokens: int
|
|
30
|
+
#: The first segment that differed, from the later turn. None when the
|
|
31
|
+
#: whole earlier prompt is still a prefix of the later one, which is the
|
|
32
|
+
#: healthy case for an append-only conversation.
|
|
33
|
+
breaking: Segment | None = None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def stable_share(self) -> float:
|
|
37
|
+
return self.stable_tokens / self.total_tokens if self.total_tokens else 0.0
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def healthy(self) -> bool:
|
|
41
|
+
"""True when nothing in the shared region changed."""
|
|
42
|
+
return self.breaking is None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def prefix_break(before: Turn, after: Turn) -> PrefixBreak:
|
|
46
|
+
"""Compare two turns and find where the cacheable prefix ends."""
|
|
47
|
+
n = 0
|
|
48
|
+
breaking: Segment | None = None
|
|
49
|
+
for a, b in zip(before.segments, after.segments):
|
|
50
|
+
if a.digest and a.digest == b.digest:
|
|
51
|
+
n += 1
|
|
52
|
+
continue
|
|
53
|
+
breaking = b
|
|
54
|
+
break
|
|
55
|
+
|
|
56
|
+
stable = sum(s.tokens for s in after.segments[:n])
|
|
57
|
+
return PrefixBreak(
|
|
58
|
+
before_index=before.index,
|
|
59
|
+
after_index=after.index,
|
|
60
|
+
stable_segments=n,
|
|
61
|
+
stable_tokens=stable,
|
|
62
|
+
total_tokens=after.total_tokens,
|
|
63
|
+
breaking=breaking,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class CacheReport:
|
|
69
|
+
breaks: list[PrefixBreak]
|
|
70
|
+
#: Tokens the provider was explicitly told to cache, summed over turns.
|
|
71
|
+
marked_tokens: int = 0
|
|
72
|
+
#: Tokens that were re-sent unchanged and could have been cached.
|
|
73
|
+
reusable_tokens: int = 0
|
|
74
|
+
#: Tokens re-sent after the prefix broke, so full price every turn.
|
|
75
|
+
rebuilt_tokens: int = 0
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def worst(self) -> PrefixBreak | None:
|
|
79
|
+
unhealthy = [b for b in self.breaks if not b.healthy]
|
|
80
|
+
if not unhealthy:
|
|
81
|
+
return None
|
|
82
|
+
return min(unhealthy, key=lambda b: b.stable_share)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def thrashing(self) -> bool:
|
|
86
|
+
"""True when the prefix breaks early enough to lose most of the win."""
|
|
87
|
+
return any(not b.healthy and b.stable_share < 0.5 for b in self.breaks)
|
|
88
|
+
|
|
89
|
+
def offenders(self) -> dict[str, int]:
|
|
90
|
+
"""How often each segment is the one that broke the prefix."""
|
|
91
|
+
out: dict[str, int] = {}
|
|
92
|
+
for b in self.breaks:
|
|
93
|
+
if b.breaking is None:
|
|
94
|
+
continue
|
|
95
|
+
key = f"{b.breaking.category.value}:{b.breaking.name or '(unnamed)'}"
|
|
96
|
+
out[key] = out.get(key, 0) + 1
|
|
97
|
+
return dict(sorted(out.items(), key=lambda kv: kv[1], reverse=True))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def analyze(profile: Profile) -> CacheReport:
|
|
101
|
+
report = CacheReport(breaks=[])
|
|
102
|
+
for before, after in zip(profile.turns, profile.turns[1:]):
|
|
103
|
+
b = prefix_break(before, after)
|
|
104
|
+
report.breaks.append(b)
|
|
105
|
+
report.reusable_tokens += b.stable_tokens
|
|
106
|
+
report.rebuilt_tokens += b.total_tokens - b.stable_tokens
|
|
107
|
+
report.marked_tokens = sum(t.cached_tokens() for t in profile.turns)
|
|
108
|
+
return report
|