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 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