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.
- lightfoot/__init__.py +3 -0
- lightfoot/__main__.py +7 -0
- lightfoot/adapters/__init__.py +49 -0
- lightfoot/adapters/anthropic.py +259 -0
- lightfoot/adapters/base.py +224 -0
- lightfoot/adapters/chat.py +190 -0
- lightfoot/adapters/gemini.py +195 -0
- lightfoot/adapters/responses.py +201 -0
- lightfoot/agentplugins.py +151 -0
- lightfoot/agentskills.py +75 -0
- lightfoot/audit.py +146 -0
- lightfoot/cachesim.py +192 -0
- lightfoot/capture.py +109 -0
- lightfoot/cli.py +674 -0
- lightfoot/codexlog.py +197 -0
- lightfoot/config.py +386 -0
- lightfoot/dashboard.py +312 -0
- lightfoot/data/models.toml +165 -0
- lightfoot/data/skills/lightfoot-condensed-context/SKILL.md +21 -0
- lightfoot/data/skills/lightfoot-stats/SKILL.md +10 -0
- lightfoot/data/skills/lightfoot-why/SKILL.md +10 -0
- lightfoot/estimate.py +665 -0
- lightfoot/evaluate.py +462 -0
- lightfoot/fastjson.py +38 -0
- lightfoot/geminilog.py +286 -0
- lightfoot/guards.py +101 -0
- lightfoot/handoff.py +377 -0
- lightfoot/hookmode.py +243 -0
- lightfoot/idlehandoff.py +206 -0
- lightfoot/images.py +117 -0
- lightfoot/ingest.py +235 -0
- lightfoot/integrate.py +145 -0
- lightfoot/integrations.py +54 -0
- lightfoot/launch.py +589 -0
- lightfoot/maintenance.py +144 -0
- lightfoot/opencodehook.py +136 -0
- lightfoot/opencodelog.py +242 -0
- lightfoot/pricing.py +278 -0
- lightfoot/profiles.py +352 -0
- lightfoot/providers.py +136 -0
- lightfoot/prune.py +626 -0
- lightfoot/reports.py +612 -0
- lightfoot/server.py +834 -0
- lightfoot/statusline.py +298 -0
- lightfoot/store.py +587 -0
- lightfoot/subagents.py +103 -0
- lightfoot/support.py +92 -0
- lightfoot/text.py +276 -0
- lightfoot/transform.py +781 -0
- lightfoot/usage.py +219 -0
- lightfoot/validate.py +120 -0
- lightfoot/why.py +144 -0
- lightfoot-1.0.0.dist-info/METADATA +196 -0
- lightfoot-1.0.0.dist-info/RECORD +58 -0
- lightfoot-1.0.0.dist-info/WHEEL +5 -0
- lightfoot-1.0.0.dist-info/entry_points.txt +2 -0
- lightfoot-1.0.0.dist-info/licenses/LICENSE +21 -0
- lightfoot-1.0.0.dist-info/top_level.txt +1 -0
lightfoot/__init__.py
ADDED
lightfoot/__main__.py
ADDED
|
@@ -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 [], []
|