lightfoot 1.0.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.
Files changed (58) hide show
  1. lightfoot/__init__.py +3 -0
  2. lightfoot/__main__.py +7 -0
  3. lightfoot/adapters/__init__.py +49 -0
  4. lightfoot/adapters/anthropic.py +259 -0
  5. lightfoot/adapters/base.py +224 -0
  6. lightfoot/adapters/chat.py +190 -0
  7. lightfoot/adapters/gemini.py +195 -0
  8. lightfoot/adapters/responses.py +201 -0
  9. lightfoot/agentplugins.py +151 -0
  10. lightfoot/agentskills.py +75 -0
  11. lightfoot/audit.py +146 -0
  12. lightfoot/cachesim.py +192 -0
  13. lightfoot/capture.py +109 -0
  14. lightfoot/cli.py +674 -0
  15. lightfoot/codexlog.py +197 -0
  16. lightfoot/config.py +386 -0
  17. lightfoot/dashboard.py +312 -0
  18. lightfoot/data/models.toml +165 -0
  19. lightfoot/data/skills/lightfoot-condensed-context/SKILL.md +21 -0
  20. lightfoot/data/skills/lightfoot-stats/SKILL.md +10 -0
  21. lightfoot/data/skills/lightfoot-why/SKILL.md +10 -0
  22. lightfoot/estimate.py +665 -0
  23. lightfoot/evaluate.py +462 -0
  24. lightfoot/fastjson.py +38 -0
  25. lightfoot/geminilog.py +286 -0
  26. lightfoot/guards.py +101 -0
  27. lightfoot/handoff.py +377 -0
  28. lightfoot/hookmode.py +243 -0
  29. lightfoot/idlehandoff.py +206 -0
  30. lightfoot/images.py +117 -0
  31. lightfoot/ingest.py +235 -0
  32. lightfoot/integrate.py +145 -0
  33. lightfoot/integrations.py +54 -0
  34. lightfoot/launch.py +589 -0
  35. lightfoot/maintenance.py +144 -0
  36. lightfoot/opencodehook.py +136 -0
  37. lightfoot/opencodelog.py +242 -0
  38. lightfoot/pricing.py +278 -0
  39. lightfoot/profiles.py +352 -0
  40. lightfoot/providers.py +136 -0
  41. lightfoot/prune.py +626 -0
  42. lightfoot/reports.py +612 -0
  43. lightfoot/server.py +834 -0
  44. lightfoot/statusline.py +298 -0
  45. lightfoot/store.py +587 -0
  46. lightfoot/subagents.py +103 -0
  47. lightfoot/support.py +92 -0
  48. lightfoot/text.py +276 -0
  49. lightfoot/transform.py +781 -0
  50. lightfoot/usage.py +219 -0
  51. lightfoot/validate.py +120 -0
  52. lightfoot/why.py +144 -0
  53. lightfoot-1.0.0.dist-info/METADATA +196 -0
  54. lightfoot-1.0.0.dist-info/RECORD +58 -0
  55. lightfoot-1.0.0.dist-info/WHEEL +5 -0
  56. lightfoot-1.0.0.dist-info/entry_points.txt +2 -0
  57. lightfoot-1.0.0.dist-info/licenses/LICENSE +21 -0
  58. lightfoot-1.0.0.dist-info/top_level.txt +1 -0
lightfoot/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """A local, cache-aware token-saving proxy for coding agents."""
2
+
3
+ __version__ = "1.0.0"
lightfoot/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """`python -m lightfoot`: the same as the `lightfoot` command."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
@@ -0,0 +1,49 @@
1
+ """Protocol adapters, chosen by request path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from .anthropic import AnthropicAdapter
8
+ from .base import Adapter, Index, Result, ToolCall, apply_changes, count_cache_markers
9
+ from .chat import ChatAdapter
10
+ from .gemini import GeminiAdapter
11
+ from .responses import ResponsesAdapter
12
+
13
+ ANTHROPIC = AnthropicAdapter()
14
+ RESPONSES = ResponsesAdapter()
15
+ GEMINI = GeminiAdapter()
16
+ CHAT = ChatAdapter()
17
+
18
+ GEMINI_PATH_RE = re.compile(r"(?:^|/)models/[^/]+:(?:stream)?generateContent$", re.I)
19
+
20
+
21
+ def for_path(method: str, path: str) -> Adapter | None:
22
+ """The adapter that rewrites this request, or None for pure passthrough."""
23
+ if method != "POST":
24
+ return None
25
+ path = path.rstrip("/")
26
+ if path.endswith("v1/messages"):
27
+ return ANTHROPIC
28
+ if path.endswith("/responses") or path == "responses":
29
+ return RESPONSES
30
+ if GEMINI_PATH_RE.search(path):
31
+ return GEMINI
32
+ if path.endswith("chat/completions"):
33
+ return CHAT
34
+ return None
35
+
36
+
37
+ __all__ = [
38
+ "ANTHROPIC",
39
+ "CHAT",
40
+ "GEMINI",
41
+ "RESPONSES",
42
+ "Adapter",
43
+ "Index",
44
+ "Result",
45
+ "ToolCall",
46
+ "apply_changes",
47
+ "count_cache_markers",
48
+ "for_path",
49
+ ]
@@ -0,0 +1,259 @@
1
+ """Anthropic Messages API (POST /v1/messages): Claude Code and Claude Agent SDK apps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+
7
+ from ..images import image_tokens, is_base64_image
8
+ from ..text import tokens
9
+ from ..usage import AnthropicUsage
10
+ from .base import Adapter, Index, Result, ToolCall, canonical, encode
11
+
12
+ THINKING_TYPES = {"thinking", "redacted_thinking"}
13
+
14
+
15
+ def plain_text(content) -> str:
16
+ """The text of a string or a list of content blocks; non-text blocks are skipped."""
17
+ if isinstance(content, str):
18
+ return content
19
+ if isinstance(content, list):
20
+ return "".join(
21
+ b.get("text", "")
22
+ for b in content
23
+ if isinstance(b, dict) and b.get("type") == "text" and isinstance(b.get("text"), str)
24
+ )
25
+ return ""
26
+
27
+
28
+ class AnthropicAdapter(Adapter):
29
+ """Anthropic Messages: the only adapter verified for history edits (epochs, prunes, drop_block)."""
30
+
31
+ name = "anthropic"
32
+ units_key = "messages"
33
+ epochs_supported = True
34
+ supports_prune = True
35
+ supports_drop_block = True
36
+
37
+ def index(self, body, profile):
38
+ """Index an Anthropic Messages request: tool_result blocks are the rewritable content,
39
+ thinking and redacted_thinking blocks mark the reasoning units.
40
+ """
41
+ msgs = body["messages"]
42
+ calls: dict[str, ToolCall] = {}
43
+ pending = []
44
+ unit_turn: list[int] = []
45
+ reasoning: set[int] = set()
46
+ turn = 0
47
+ for mi, message in enumerate(msgs):
48
+ role = message.get("role") if isinstance(message, dict) else None
49
+ if role == "user":
50
+ turn += 1
51
+ unit_turn.append(turn)
52
+ content = message.get("content") if isinstance(message, dict) else None
53
+ if not isinstance(content, list):
54
+ continue
55
+ for bi, block in enumerate(content):
56
+ if not isinstance(block, dict):
57
+ continue
58
+ kind = block.get("type")
59
+ if role == "assistant" and kind in THINKING_TYPES:
60
+ reasoning.add(mi)
61
+ if kind == "tool_use" and role == "assistant" and isinstance(block.get("id"), str):
62
+ inp = block.get("input") if isinstance(block.get("input"), dict) else {}
63
+ name = str(block.get("name") or "")
64
+ calls[block["id"]] = ToolCall(name, inp, profile.kind(name, inp), mi)
65
+ elif kind == "tool_result" and role == "user":
66
+ pending.append((mi, bi, block, turn))
67
+
68
+ results = []
69
+ for mi, bi, block, t in pending:
70
+ tid = block.get("tool_use_id") if isinstance(block.get("tool_use_id"), str) else ""
71
+ call = calls.get(tid)
72
+ shape, text = self.extract(block)
73
+ is_error = bool(block.get("is_error"))
74
+ media = [c for c in block["content"] if is_base64_image(c)] if shape == "image" else []
75
+ results.append(
76
+ Result(
77
+ unit=mi,
78
+ path=("messages", mi, "content", bi, "content"),
79
+ tid=tid,
80
+ container=block,
81
+ name=call.name if call else "",
82
+ inp=call.inp if call else {},
83
+ kind=call.kind if call else "other",
84
+ turn=t,
85
+ shape=shape,
86
+ orig_text=text,
87
+ is_error=is_error,
88
+ media=media,
89
+ )
90
+ )
91
+
92
+ # Trailing system messages (Claude Code 2.1.310+) don't end the user's turn: skip them.
93
+ last = len(msgs) - 1
94
+ while last >= 0 and isinstance(msgs[last], dict) and msgs[last].get("role") == "system":
95
+ last -= 1
96
+ ingest_from = (
97
+ last if last >= 0 and isinstance(msgs[last], dict) and msgs[last].get("role") == "user" else len(msgs)
98
+ )
99
+ model_from = ingest_from - 1
100
+ while model_from >= 0 and isinstance(msgs[model_from], dict) and msgs[model_from].get("role") == "system":
101
+ model_from -= 1
102
+ if model_from < 0:
103
+ model_from = len(msgs)
104
+ return Index(msgs, results, calls, unit_turn, turn, ingest_from, model_from, reasoning)
105
+
106
+ def extract(self, container):
107
+ """Shape and text of a tool_result's content, or (None, None) when it can't be rewritten.
108
+
109
+ "str" and "list" are plain text. "image" is text blocks plus at least one inline image;
110
+ only the images rule rewrites it. Documents, URL images, extra block fields or more than
111
+ one cache marker make the content non-rewritable.
112
+ """
113
+ content = container.get("content")
114
+ if isinstance(content, str):
115
+ return ("str", content) if content else (None, None)
116
+ if isinstance(content, list) and content:
117
+ texts, markers, has_image = [], 0, False
118
+ for inner in content:
119
+ if is_base64_image(inner) and not set(inner) - {"type", "source", "cache_control"}:
120
+ has_image = True
121
+ elif (
122
+ isinstance(inner, dict)
123
+ and inner.get("type") == "text"
124
+ and isinstance(inner.get("text"), str)
125
+ and not set(inner) - {"type", "text", "cache_control"}
126
+ ):
127
+ texts.append(inner["text"])
128
+ else:
129
+ return None, None
130
+ markers += "cache_control" in inner
131
+ if markers > 1:
132
+ return None, None
133
+ return ("image" if has_image else "list"), "\n".join(texts)
134
+ return None, None
135
+
136
+ def content_value(self, container, shape, text):
137
+ """A string for a "str" result; otherwise one text block that keeps the client's cache marker."""
138
+ if shape == "str":
139
+ return text
140
+ inner = {"type": "text", "text": text}
141
+ for old in container.get("content") or []:
142
+ if isinstance(old, dict) and "cache_control" in old:
143
+ inner["cache_control"] = old["cache_control"] # markers follow the client's placement
144
+ return [inner]
145
+
146
+ def content_text(self, value):
147
+ """Text of a string or of a list of text blocks."""
148
+ if isinstance(value, str):
149
+ return value
150
+ if isinstance(value, list) and all(isinstance(b, dict) and b.get("type") == "text" for b in value):
151
+ return "\n".join(str(b.get("text", "")) for b in value)
152
+ return None
153
+
154
+ def block_tokens(self, block: dict) -> int:
155
+ """Tokens of one content block, with images at their real cost rather than their base64 length."""
156
+ kind = block.get("type")
157
+ if kind == "text":
158
+ return tokens(str(block.get("text", "")))
159
+ if kind in THINKING_TYPES:
160
+ return tokens(str(block.get("thinking", "")))
161
+ if kind == "image":
162
+ return image_tokens(block)
163
+ if kind == "tool_result":
164
+ content = block.get("content")
165
+ if isinstance(content, list):
166
+ return sum(self.block_tokens(c) for c in content if isinstance(c, dict))
167
+ return tokens(content) if isinstance(content, str) else 0
168
+ return tokens(canonical(block))
169
+
170
+ def unit_text(self, ui, unit, current):
171
+ """Text, tool inputs and tool results of one message, with rewritten results taken from `current`."""
172
+ content = unit.get("content")
173
+ if isinstance(content, str):
174
+ return content
175
+ parts = []
176
+ for bi, block in enumerate(content or []):
177
+ if not isinstance(block, dict):
178
+ continue
179
+ kind = block.get("type")
180
+ if kind == "text":
181
+ parts.append(str(block.get("text", "")))
182
+ elif kind == "tool_use":
183
+ parts.append(canonical(block.get("input")))
184
+ elif kind == "tool_result":
185
+ path = ("messages", ui, "content", bi, "content")
186
+ parts.append(current.get(path, plain_text(block.get("content"))) or "")
187
+ return "\n".join(parts)
188
+
189
+ def unit_tokens(self, ui, unit, current):
190
+ """Tokens of one message, with rewritten results taken from `current`."""
191
+ content = unit.get("content")
192
+ if isinstance(content, str):
193
+ return tokens(content)
194
+ total = 0
195
+ for bi, block in enumerate(content or []):
196
+ if not isinstance(block, dict):
197
+ continue
198
+ path = ("messages", ui, "content", bi, "content")
199
+ if path in current:
200
+ total += tokens(current[path] or "")
201
+ else:
202
+ total += self.block_tokens(block)
203
+ return total
204
+
205
+ def session_key(self, body):
206
+ """System prompt + first user message: stable for a conversation, distinct per subagent."""
207
+ system = body.get("system")
208
+ system_text = system if isinstance(system, str) else plain_text(system)
209
+ first = next((m for m in body.get("messages", []) if isinstance(m, dict) and m.get("role") == "user"), None)
210
+ first_text = plain_text(first.get("content")) if first else ""
211
+ return hashlib.sha256(encode(system_text + "\x00" + first_text)).hexdigest()[:16]
212
+
213
+ def cache_ttl(self, body):
214
+ """300 for the default cache, 3600 if the client marks blocks with a 1-hour TTL."""
215
+
216
+ def scan(blocks) -> bool:
217
+ if isinstance(blocks, list):
218
+ for b in blocks:
219
+ if isinstance(b, dict):
220
+ cc = b.get("cache_control")
221
+ if isinstance(cc, dict) and cc.get("ttl") == "1h":
222
+ return True
223
+ if scan(b.get("content")):
224
+ return True
225
+ return False
226
+
227
+ top = body.get("cache_control")
228
+ if isinstance(top, dict) and top.get("ttl") == "1h":
229
+ return 3600
230
+ candidates = [body.get("system"), body.get("tools")]
231
+ candidates += [m.get("content") for m in body.get("messages", [])[-3:] if isinstance(m, dict)]
232
+ return 3600 if any(scan(c) for c in candidates) else 300
233
+
234
+ def with_drop_block(self, body):
235
+ """Set `thinking.block_binding.prefix_mismatch_behavior` to `drop_block`.
236
+
237
+ The client's other thinking settings are kept.
238
+ """
239
+ thinking = dict(body["thinking"])
240
+ binding = dict(thinking.get("block_binding") or {})
241
+ binding["prefix_mismatch_behavior"] = "drop_block"
242
+ thinking["block_binding"] = binding
243
+ out = dict(body)
244
+ out["thinking"] = thinking
245
+ return out
246
+
247
+ def usage_parser(self, content_type, request_body):
248
+ """Usage from `message_start` and `message_delta`, or from a non-streamed body."""
249
+ return AnthropicUsage(content_type)
250
+
251
+ def prefix(self, body):
252
+ """The system prompt's texts and each tool definition by name."""
253
+ system = body.get("system")
254
+ if isinstance(system, str):
255
+ texts = [system]
256
+ else:
257
+ texts = [str(b.get("text", "")) for b in system or [] if isinstance(b, dict)]
258
+ tools = [(str(t.get("name", "")), t) for t in body.get("tools") or [] if isinstance(t, dict)]
259
+ return texts, tools
@@ -0,0 +1,224 @@
1
+ """Protocol-neutral view of a request, shared by every adapter.
2
+
3
+ An adapter turns a provider's request body into an `Index`: the conversation's units
4
+ (Anthropic messages, OpenAI input items), the tool calls, and every tool output with
5
+ the JSON path of its content. The core engine only ever replaces content at those
6
+ paths, so one generic `apply_changes` and one generic validator serve all protocols.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from dataclasses import dataclass, field
14
+
15
+ from ..images import image_tokens
16
+ from ..text import tokens
17
+
18
+ Path = tuple # JSON path into the request body, e.g. ("messages", 4, "content", 0, "content")
19
+
20
+
21
+ def encode(text: str) -> bytes:
22
+ """UTF-8 bytes of `text`, keeping lone surrogates that some clients send."""
23
+ return text.encode("utf-8", "surrogatepass")
24
+
25
+
26
+ def canonical(value) -> str:
27
+ """Deterministic JSON for hashing and comparison: sorted keys, ASCII only, no spaces."""
28
+ return json.dumps(value, sort_keys=True, ensure_ascii=True, separators=(",", ":"))
29
+
30
+
31
+ def content_hash(shape: str, is_error: bool, text: str) -> str:
32
+ """Hash of what a tool output says, ignoring cache_control placement (markers move between turns)."""
33
+ return hashlib.sha256(f"{shape}|{int(is_error)}|".encode() + encode(text)).hexdigest()
34
+
35
+
36
+ @dataclass
37
+ class ToolCall:
38
+ """A tool call the model made, by id; results look up their call to learn name, input and kind."""
39
+
40
+ name: str
41
+ inp: dict
42
+ kind: str
43
+ unit: int
44
+
45
+
46
+ @dataclass
47
+ class Result:
48
+ """One tool output in the request: the only kind of content a rule may rewrite.
49
+
50
+ `orig_text` is what the client sent; `text` is set when a stored or new rewrite applies.
51
+ Rewrites are keyed by `tid` and checked against `orig_hash`, so a replay never lands on
52
+ output that has changed since it was frozen.
53
+ """
54
+
55
+ unit: int
56
+ path: Path # where the content lives
57
+ tid: str # stable id used for frozen replay (tool_use_id / call_id)
58
+ container: dict # the dict whose content field is replaced
59
+ name: str
60
+ inp: dict
61
+ kind: str # read | search | shell | edit | subagent | graph_query | other
62
+ turn: int
63
+ shape: str | None # "str" | "list" | None (not rewritable)
64
+ orig_text: str | None
65
+ is_error: bool
66
+ text: str | None = None # current text when a rewrite applies
67
+ rule: str | None = None
68
+ note_path: str | None = None
69
+ source: str | None = None # "stored" | "new"
70
+ media: list = field(default_factory=list) # inline image blocks (shape "image" only)
71
+ _hash: str | None = field(default=None, repr=False)
72
+
73
+ @property
74
+ def orig_hash(self) -> str | None:
75
+ """Content hash, computed only when needed (replay checks, new rewrites, repeat detection)."""
76
+ if self._hash is None and self.shape is not None:
77
+ text = self.orig_text or ""
78
+ if self.media:
79
+ # Screenshots often carry no text at all, so the image data must be part of the hash.
80
+ text += "\x00" + "".join(hashlib.sha256(encode(b["source"]["data"])).hexdigest() for b in self.media)
81
+ self._hash = content_hash(self.shape, self.is_error, text)
82
+ return self._hash
83
+
84
+ @property
85
+ def current(self) -> str | None:
86
+ """The text this result will carry: the rewrite if one applies, else the original."""
87
+ return self.text if self.text is not None else self.orig_text
88
+
89
+ @property
90
+ def orig_tokens(self) -> int:
91
+ """Tokens of the original content, images included at their real cost."""
92
+ return tokens(self.orig_text or "") + sum(image_tokens(b) for b in self.media)
93
+
94
+ @property
95
+ def current_tokens(self) -> int:
96
+ """Tokens of `current`, images included at their real cost while unrewritten."""
97
+ return tokens(self.text) if self.text is not None else self.orig_tokens
98
+
99
+ @property
100
+ def rewritable(self) -> bool:
101
+ """Whether any rule may touch this result: a known shape, not an error, and a stable id."""
102
+ return self.shape is not None and not self.is_error and bool(self.tid)
103
+
104
+
105
+ @dataclass
106
+ class Index:
107
+ """Everything the planner needs to know about a request, independent of its wire format.
108
+
109
+ `ingest_from` splits history (seen by the model, so editing it is a history edit) from
110
+ new content (free to rewrite). `reasoning_units` lets the planner tell whether a history
111
+ edit would land before a thinking block, whose signature binds everything before it.
112
+ """
113
+
114
+ units: list
115
+ results: list[Result]
116
+ calls: dict[str, ToolCall]
117
+ unit_turn: list[int]
118
+ turns: int
119
+ ingest_from: int # first unit the model has not seen yet
120
+ model_from: int # first unit of the newest model turn (for signals)
121
+ reasoning_units: set[int] = field(default_factory=set)
122
+ pruned_before: int = 0 # units before this are replaced by a stored prune summary
123
+
124
+ def reasoning_after(self, unit: int) -> bool:
125
+ """Whether a thinking or reasoning unit comes after `unit`, so editing `unit` would break its signature."""
126
+ return any(u > unit for u in self.reasoning_units)
127
+
128
+
129
+ def apply_changes(body: dict, changes: dict[Path, object]) -> dict:
130
+ """Copy of `body` with each path replaced; everything not on a changed path is shared."""
131
+ out = dict(body)
132
+ copied: dict[tuple, object] = {(): out}
133
+ for path, value in changes.items():
134
+ node = out
135
+ for depth in range(len(path) - 1):
136
+ prefix = path[: depth + 1]
137
+ if prefix not in copied:
138
+ child = node[path[depth]]
139
+ child = list(child) if isinstance(child, list) else dict(child)
140
+ node[path[depth]] = child
141
+ copied[prefix] = child
142
+ node = copied[prefix]
143
+ node[path[-1]] = value
144
+ return out
145
+
146
+
147
+ def count_cache_markers(value) -> int:
148
+ """How many `cache_control` markers `value` holds, at any depth.
149
+
150
+ The validator requires the same count after a rewrite.
151
+ """
152
+ if isinstance(value, dict):
153
+ return ("cache_control" in value) + sum(
154
+ count_cache_markers(v) for k, v in value.items() if k != "cache_control"
155
+ )
156
+ if isinstance(value, list):
157
+ return sum(count_cache_markers(v) for v in value)
158
+ return 0
159
+
160
+
161
+ class Adapter:
162
+ """Interface every protocol adapter implements."""
163
+
164
+ name = "base"
165
+ units_key = ""
166
+ epochs_supported = False # history edits verified safe for this provider
167
+ supports_prune = False # old-turn pruning (a history edit, so it needs the same verification)
168
+ supports_drop_block = False
169
+ experimental = False
170
+
171
+ def accepts(self, body) -> bool:
172
+ """Whether this adapter can index `body`: a dict whose `units_key` holds a list."""
173
+ return isinstance(body, dict) and isinstance(body.get(self.units_key), list)
174
+
175
+ def index(self, body: dict, profile) -> Index:
176
+ """Build the protocol-neutral `Index` of a request, using `profile` to give each tool call its kind."""
177
+ raise NotImplementedError
178
+
179
+ def extract(self, container: dict) -> tuple[str | None, str | None]:
180
+ """Shape and plain text of a tool output's content, or (None, None) when it can't be rewritten."""
181
+ raise NotImplementedError
182
+
183
+ def content_value(self, container: dict, shape: str, text: str):
184
+ """The value to store in `container`'s content field for rewritten `text`, in the original's `shape`."""
185
+ raise NotImplementedError
186
+
187
+ def content_text(self, value) -> str | None:
188
+ """Plain text of a stored content value, or None when it isn't plain text."""
189
+ raise NotImplementedError
190
+
191
+ def unit_text(self, ui: int, unit: dict, current: dict[Path, str]) -> str:
192
+ """Plain text of unit `ui` for "referenced since" checks; `current` maps result path -> text."""
193
+ raise NotImplementedError
194
+
195
+ def unit_tokens(self, ui: int, unit: dict, current: dict[Path, str]) -> int:
196
+ """Tokens of unit `ui`, counting rewritten results from `current` (result path -> text)."""
197
+ raise NotImplementedError
198
+
199
+ def session_key(self, body: dict) -> str:
200
+ """A stable id for the conversation `body` belongs to, the same on every one of its turns."""
201
+ raise NotImplementedError
202
+
203
+ def cache_ttl(self, body: dict) -> int:
204
+ """Seconds the provider keeps this request's prompt cache; 300 unless the protocol says otherwise."""
205
+ return 300
206
+
207
+ def model(self, body: dict) -> str | None:
208
+ """The model named in the request body, if any."""
209
+ return body.get("model") if isinstance(body.get("model"), str) else None
210
+
211
+ def with_drop_block(self, body: dict) -> dict:
212
+ """`body` with the provider told to drop thinking that no longer matches its prefix.
213
+
214
+ A no-op for providers without that option.
215
+ """
216
+ return body
217
+
218
+ def usage_parser(self, content_type: str | None, request_body: dict | None):
219
+ """A parser that reads token usage from this protocol's response, streamed or not."""
220
+ raise NotImplementedError
221
+
222
+ def prefix(self, body: dict) -> tuple[list[str], list[tuple[str, object]]]:
223
+ """The fixed prefix every request of a session repeats: system texts, and (name, definition) per tool."""
224
+ return [], []