turnloop 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.
Files changed (132) hide show
  1. turnloop/__init__.py +3 -0
  2. turnloop/__main__.py +4 -0
  3. turnloop/agent/__init__.py +0 -0
  4. turnloop/agent/factory.py +175 -0
  5. turnloop/agent/headless.py +150 -0
  6. turnloop/agent/loop.py +396 -0
  7. turnloop/agent/subagent.py +182 -0
  8. turnloop/agent/system_prompt.py +263 -0
  9. turnloop/cli.py +202 -0
  10. turnloop/commands/__init__.py +0 -0
  11. turnloop/commands/dispatch.py +428 -0
  12. turnloop/commands/loader.py +140 -0
  13. turnloop/config.py +425 -0
  14. turnloop/context/__init__.py +0 -0
  15. turnloop/context/budget.py +74 -0
  16. turnloop/context/compaction.py +441 -0
  17. turnloop/context/memory.py +120 -0
  18. turnloop/core/__init__.py +0 -0
  19. turnloop/core/events.py +145 -0
  20. turnloop/core/ids.py +29 -0
  21. turnloop/core/messages.py +217 -0
  22. turnloop/core/tokens.py +104 -0
  23. turnloop/diagnostics.py +233 -0
  24. turnloop/errors.py +64 -0
  25. turnloop/experiments/__init__.py +0 -0
  26. turnloop/experiments/configs/bakeoff.yaml +50 -0
  27. turnloop/experiments/configs/ceiling-check.yaml +31 -0
  28. turnloop/experiments/configs/context-glm.yaml +59 -0
  29. turnloop/experiments/configs/context.yaml +65 -0
  30. turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
  31. turnloop/experiments/configs/groq-free.yaml +28 -0
  32. turnloop/experiments/configs/hard-calibration.yaml +26 -0
  33. turnloop/experiments/configs/hard-glm.yaml +41 -0
  34. turnloop/experiments/configs/loop.yaml +34 -0
  35. turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
  36. turnloop/experiments/configs/reliability.yaml +44 -0
  37. turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
  38. turnloop/experiments/configs/smoke.yaml +22 -0
  39. turnloop/experiments/graders.py +209 -0
  40. turnloop/experiments/report.py +371 -0
  41. turnloop/experiments/runner.py +495 -0
  42. turnloop/experiments/suite/default.yaml +185 -0
  43. turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
  44. turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
  45. turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
  46. turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
  47. turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
  48. turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
  49. turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
  50. turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
  51. turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
  52. turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
  53. turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
  54. turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
  55. turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
  56. turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
  57. turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
  58. turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
  59. turnloop/experiments/suite/fixtures/empty/.keep +1 -0
  60. turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
  61. turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
  62. turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
  63. turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
  64. turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
  65. turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
  66. turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
  67. turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
  68. turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
  69. turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
  70. turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
  71. turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
  72. turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
  73. turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
  74. turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
  75. turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
  76. turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
  77. turnloop/experiments/suite/fixtures/rename/report.py +5 -0
  78. turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
  79. turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
  80. turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
  81. turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
  82. turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
  83. turnloop/experiments/suite/fixtures/util/util.py +9 -0
  84. turnloop/hooks/__init__.py +0 -0
  85. turnloop/hooks/runner.py +221 -0
  86. turnloop/mcp/__init__.py +0 -0
  87. turnloop/mcp/adapter.py +109 -0
  88. turnloop/mcp/client.py +299 -0
  89. turnloop/permissions/__init__.py +0 -0
  90. turnloop/permissions/engine.py +211 -0
  91. turnloop/permissions/rules.py +325 -0
  92. turnloop/providers/__init__.py +0 -0
  93. turnloop/providers/anthropic.py +318 -0
  94. turnloop/providers/base.py +329 -0
  95. turnloop/providers/gemini.py +217 -0
  96. turnloop/providers/mock.py +226 -0
  97. turnloop/providers/openai_compat.py +357 -0
  98. turnloop/providers/pricing.py +148 -0
  99. turnloop/providers/registry.py +75 -0
  100. turnloop/providers/sse.py +114 -0
  101. turnloop/sessions/__init__.py +0 -0
  102. turnloop/sessions/models.py +139 -0
  103. turnloop/sessions/store.py +271 -0
  104. turnloop/tools/__init__.py +0 -0
  105. turnloop/tools/ask.py +133 -0
  106. turnloop/tools/base.py +286 -0
  107. turnloop/tools/bash.py +423 -0
  108. turnloop/tools/builtin.py +63 -0
  109. turnloop/tools/edit.py +238 -0
  110. turnloop/tools/glob.py +235 -0
  111. turnloop/tools/grep.py +314 -0
  112. turnloop/tools/read.py +151 -0
  113. turnloop/tools/runner.py +284 -0
  114. turnloop/tools/shell.py +222 -0
  115. turnloop/tools/skill.py +89 -0
  116. turnloop/tools/task.py +161 -0
  117. turnloop/tools/textio.py +87 -0
  118. turnloop/tools/todo.py +140 -0
  119. turnloop/tools/webfetch.py +235 -0
  120. turnloop/tools/write.py +97 -0
  121. turnloop/tui/__init__.py +0 -0
  122. turnloop/tui/app.py +330 -0
  123. turnloop/tui/app.tcss +84 -0
  124. turnloop/tui/bridge.py +142 -0
  125. turnloop/tui/widgets/__init__.py +0 -0
  126. turnloop/tui/widgets/permission.py +131 -0
  127. turnloop/tui/widgets/status.py +110 -0
  128. turnloop/tui/widgets/transcript.py +146 -0
  129. turnloop-0.1.0.dist-info/METADATA +916 -0
  130. turnloop-0.1.0.dist-info/RECORD +132 -0
  131. turnloop-0.1.0.dist-info/WHEEL +4 -0
  132. turnloop-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,145 @@
1
+ """Streaming events.
2
+
3
+ One event union covers model output, provider status, and tool progress, so the
4
+ TUI needs exactly one inbound pathway. In particular a 29-minute GLM cold boot
5
+ reports progress through `ProviderStatus` on the same channel as tokens.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Literal, TypeAlias
12
+
13
+ from turnloop.core.messages import Message, Usage
14
+
15
+ # --- model output ----------------------------------------------------------
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class TextDelta:
20
+ text: str
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class ThinkingDelta:
25
+ text: str
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class ToolUseStart:
30
+ index: int
31
+ id: str
32
+ name: str
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class ToolUseArgsDelta:
37
+ """A fragment of the JSON argument string.
38
+
39
+ Both Anthropic (`input_json_delta`) and OpenAI-compatible endpoints stream
40
+ tool arguments as unparseable partial JSON. Fragments are accumulated per
41
+ `index` and parsed only once the block closes.
42
+ """
43
+
44
+ index: int
45
+ fragment: str
46
+
47
+
48
+ @dataclass(slots=True)
49
+ class MessageDone:
50
+ message: Message
51
+ usage: Usage
52
+ stop_reason: str
53
+
54
+
55
+ # --- provider / transport status -------------------------------------------
56
+
57
+ StatusPhase: TypeAlias = Literal["connecting", "cold_boot", "retrying", "ready", "streaming"]
58
+
59
+
60
+ @dataclass(slots=True)
61
+ class ProviderStatus:
62
+ text: str
63
+ phase: StatusPhase = "connecting"
64
+ elapsed_s: float = 0.0
65
+
66
+
67
+ # --- tool execution -------------------------------------------------------
68
+
69
+
70
+ @dataclass(slots=True)
71
+ class ToolStarted:
72
+ tool_use_id: str
73
+ name: str
74
+ summary: str # one-line human description, e.g. "Read src/app.py"
75
+ subagent_id: str | None = None
76
+
77
+
78
+ @dataclass(slots=True)
79
+ class ToolProgress:
80
+ """Incremental output from a running tool (bash lines, subagent turns)."""
81
+
82
+ tool_use_id: str
83
+ text: str
84
+ subagent_id: str | None = None
85
+
86
+
87
+ @dataclass(slots=True)
88
+ class ToolFinished:
89
+ tool_use_id: str
90
+ name: str
91
+ is_error: bool
92
+ display: str | None = None
93
+ duration_s: float = 0.0
94
+ subagent_id: str | None = None
95
+
96
+
97
+ # --- session-level UI updates ---------------------------------------------
98
+
99
+
100
+ @dataclass(slots=True)
101
+ class StatusUpdate:
102
+ """Pushed after every request so the status bar never computes anything."""
103
+
104
+ context_tokens: int = 0
105
+ context_max: int = 0
106
+ cost_usd: float = 0.0
107
+ usage: Usage = field(default_factory=Usage)
108
+ gpu_seconds: float | None = None # self-hosted, wall-clock-billed providers
109
+ note: str = ""
110
+
111
+
112
+ @dataclass(slots=True)
113
+ class TurnStarted:
114
+ turn: int
115
+
116
+
117
+ @dataclass(slots=True)
118
+ class TurnFinished:
119
+ turn: int
120
+ stop_reason: str
121
+
122
+
123
+ @dataclass(slots=True)
124
+ class CompactionHappened:
125
+ # "hard" is the last-resort tier: a single message larger than the whole
126
+ # window, truncated in place because no cut point can help.
127
+ tier: Literal["micro", "thinking", "full", "hard"]
128
+ tokens_before: int
129
+ tokens_after: int
130
+
131
+
132
+ StreamEvent: TypeAlias = (
133
+ TextDelta | ThinkingDelta | ToolUseStart | ToolUseArgsDelta | MessageDone | ProviderStatus
134
+ )
135
+
136
+ UIEvent: TypeAlias = (
137
+ StreamEvent
138
+ | ToolStarted
139
+ | ToolProgress
140
+ | ToolFinished
141
+ | StatusUpdate
142
+ | TurnStarted
143
+ | TurnFinished
144
+ | CompactionHappened
145
+ )
turnloop/core/ids.py ADDED
@@ -0,0 +1,29 @@
1
+ """Identifier generation.
2
+
3
+ Indirected through a module-level counter so golden-transcript tests can make
4
+ ids deterministic by calling `use_deterministic_ids()`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import itertools
10
+ from uuid import uuid4
11
+
12
+ _counter: itertools.count | None = None
13
+
14
+
15
+ def new_id(prefix: str) -> str:
16
+ if _counter is not None:
17
+ return f"{prefix}_{next(_counter):08d}"
18
+ return f"{prefix}_{uuid4().hex[:16]}"
19
+
20
+
21
+ def use_deterministic_ids(start: int = 1) -> None:
22
+ """Make new_id() emit prefix_00000001, prefix_00000002, ... (tests only)."""
23
+ global _counter
24
+ _counter = itertools.count(start)
25
+
26
+
27
+ def use_random_ids() -> None:
28
+ global _counter
29
+ _counter = None
@@ -0,0 +1,217 @@
1
+ """The internal message model.
2
+
3
+ Deliberately Anthropic-shaped: content blocks with an explicit `thinking` type
4
+ and tool results carrying an `is_error` flag. That shape is a strict superset of
5
+ what OpenAI-compatible and Gemini endpoints express, so every adapter is
6
+ lossy-on-the-way-in and lossless-on-the-way-out rather than the reverse.
7
+
8
+ Nothing in this module knows about HTTP or about any provider.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Annotated, Literal, TypeAlias
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+ StopReason: TypeAlias = Literal[
18
+ "end_turn", # model finished talking
19
+ "tool_use", # model wants tools run; the loop continues
20
+ "max_tokens", # output cap hit mid-sentence
21
+ "stop_sequence",
22
+ "refusal",
23
+ "error", # our own marker for a truncated/failed stream
24
+ ]
25
+
26
+
27
+ class Usage(BaseModel):
28
+ input_tokens: int = 0
29
+ output_tokens: int = 0
30
+ cache_read_tokens: int = 0
31
+ cache_write_tokens: int = 0
32
+
33
+ def __add__(self, other: Usage) -> Usage:
34
+ return Usage(
35
+ input_tokens=self.input_tokens + other.input_tokens,
36
+ output_tokens=self.output_tokens + other.output_tokens,
37
+ cache_read_tokens=self.cache_read_tokens + other.cache_read_tokens,
38
+ cache_write_tokens=self.cache_write_tokens + other.cache_write_tokens,
39
+ )
40
+
41
+ @property
42
+ def total(self) -> int:
43
+ return self.input_tokens + self.output_tokens
44
+
45
+
46
+ class TextBlock(BaseModel):
47
+ type: Literal["text"] = "text"
48
+ text: str = ""
49
+
50
+
51
+ class ThinkingBlock(BaseModel):
52
+ """Reasoning output.
53
+
54
+ `signature` is Anthropic's cryptographic signature over the thinking text;
55
+ it must be echoed verbatim if the block is ever sent back. Endpoints that
56
+ surface reasoning as plain text (GLM's `reasoning_content`, Groq) have no
57
+ signature, so those blocks are display/trace only and are never resent —
58
+ see providers/openai_compat.py.
59
+ """
60
+
61
+ type: Literal["thinking"] = "thinking"
62
+ text: str = ""
63
+ signature: str | None = None
64
+ provider: str | None = None # provenance, e.g. "glm:reasoning_content"
65
+
66
+ @property
67
+ def resendable(self) -> bool:
68
+ return self.signature is not None
69
+
70
+
71
+ class ToolUseBlock(BaseModel):
72
+ type: Literal["tool_use"] = "tool_use"
73
+ id: str
74
+ name: str
75
+ args: dict = Field(default_factory=dict)
76
+
77
+
78
+ class ToolResultBlock(BaseModel):
79
+ type: Literal["tool_result"] = "tool_result"
80
+ tool_use_id: str
81
+ content: str = ""
82
+ is_error: bool = False
83
+ # Rich rendering for the TUI. Never crosses the wire — keeping it out of
84
+ # `content` is what lets a tool show a colored diff to the human while
85
+ # sending the model a plain summary.
86
+ display: str | None = Field(default=None, exclude=False)
87
+ truncated: bool = False # set by micro-compaction so it only happens once
88
+
89
+
90
+ ContentBlock: TypeAlias = Annotated[
91
+ TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock,
92
+ Field(discriminator="type"),
93
+ ]
94
+
95
+
96
+ class Message(BaseModel):
97
+ role: Literal["user", "assistant"]
98
+ content: list[ContentBlock] = Field(default_factory=list)
99
+ usage: Usage | None = None
100
+ stop_reason: StopReason | None = None
101
+ # provider, model, latency_ms, ttft_ms, compaction markers, subagent_id
102
+ meta: dict = Field(default_factory=dict)
103
+
104
+ # --- convenience accessors used all over the loop -----------------------
105
+
106
+ @property
107
+ def text(self) -> str:
108
+ return "".join(b.text for b in self.content if isinstance(b, TextBlock))
109
+
110
+ @property
111
+ def tool_uses(self) -> list[ToolUseBlock]:
112
+ return [b for b in self.content if isinstance(b, ToolUseBlock)]
113
+
114
+ @property
115
+ def tool_results(self) -> list[ToolResultBlock]:
116
+ return [b for b in self.content if isinstance(b, ToolResultBlock)]
117
+
118
+ @property
119
+ def thinking(self) -> list[ThinkingBlock]:
120
+ return [b for b in self.content if isinstance(b, ThinkingBlock)]
121
+
122
+ @property
123
+ def is_empty(self) -> bool:
124
+ return not any(
125
+ (isinstance(b, TextBlock | ThinkingBlock) and b.text.strip())
126
+ or isinstance(b, ToolUseBlock | ToolResultBlock)
127
+ for b in self.content
128
+ )
129
+
130
+ @classmethod
131
+ def user_text(cls, text: str, **meta) -> Message:
132
+ return cls(role="user", content=[TextBlock(text=text)], meta=meta)
133
+
134
+ @classmethod
135
+ def assistant_text(cls, text: str, **meta) -> Message:
136
+ return cls(role="assistant", content=[TextBlock(text=text)], meta=meta)
137
+
138
+
139
+ class ToolSpec(BaseModel):
140
+ """A tool as presented to a model. Built from a Tool class by the registry."""
141
+
142
+ name: str
143
+ description: str
144
+ input_schema: dict
145
+
146
+ def to_anthropic(self) -> dict:
147
+ return {
148
+ "name": self.name,
149
+ "description": self.description,
150
+ "input_schema": self.input_schema,
151
+ }
152
+
153
+ def to_openai(self) -> dict:
154
+ return {
155
+ "type": "function",
156
+ "function": {
157
+ "name": self.name,
158
+ "description": self.description,
159
+ "parameters": self.input_schema,
160
+ },
161
+ }
162
+
163
+ def to_gemini(self) -> dict:
164
+ return {
165
+ "name": self.name,
166
+ "description": self.description,
167
+ "parameters": strip_unsupported_schema(self.input_schema),
168
+ }
169
+
170
+
171
+ _GEMINI_UNSUPPORTED = {
172
+ "additionalProperties",
173
+ "$schema",
174
+ "$defs",
175
+ "definitions",
176
+ "discriminator",
177
+ "exclusiveMinimum",
178
+ "exclusiveMaximum",
179
+ "const",
180
+ "examples",
181
+ "default",
182
+ "title",
183
+ }
184
+
185
+
186
+ def strip_unsupported_schema(schema: dict, defs: dict | None = None) -> dict:
187
+ """Inline $refs and drop keywords Gemini's function-declaration parser rejects.
188
+
189
+ Gemini returns a 400 for a schema containing `$defs`/`additionalProperties`,
190
+ which pydantic emits routinely for nested models (e.g. Edit's list[EditOp]).
191
+ """
192
+ defs = defs or schema.get("$defs") or schema.get("definitions") or {}
193
+ out: dict = {}
194
+ for key, value in schema.items():
195
+ if key == "$ref":
196
+ target = str(value).rsplit("/", 1)[-1]
197
+ resolved = defs.get(target, {})
198
+ out.update(strip_unsupported_schema(resolved, defs))
199
+ continue
200
+ if key in _GEMINI_UNSUPPORTED:
201
+ continue
202
+ if key == "anyOf":
203
+ # Gemini has no union type. Take the first non-null branch; the
204
+ # description is expected to carry the nuance.
205
+ branches = [b for b in value if b.get("type") != "null"]
206
+ if branches:
207
+ out.update(strip_unsupported_schema(branches[0], defs))
208
+ continue
209
+ if isinstance(value, dict):
210
+ out[key] = strip_unsupported_schema(value, defs)
211
+ elif isinstance(value, list):
212
+ out[key] = [
213
+ strip_unsupported_schema(v, defs) if isinstance(v, dict) else v for v in value
214
+ ]
215
+ else:
216
+ out[key] = value
217
+ return out
@@ -0,0 +1,104 @@
1
+ """Token estimation without a tokenizer dependency.
2
+
3
+ Why no tiktoken: it is wrong for GLM and Gemini anyway, it is a 2 MB+ download
4
+ with a vocab fetch at first use, and every provider reports exact usage in its
5
+ response. So: estimate cheaply, then calibrate the estimator against reported
6
+ usage with an EWMA. It converges within two turns and costs nothing.
7
+
8
+ This matters concretely. A flat 4-chars-per-token guess is ~20% off on GLM's
9
+ tokenizer, and 20% of a 65k window is 13k tokens — either wasted headroom or a
10
+ context-overflow 400 mid-session.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from turnloop.core.messages import (
16
+ ContentBlock,
17
+ Message,
18
+ TextBlock,
19
+ ThinkingBlock,
20
+ ToolResultBlock,
21
+ ToolSpec,
22
+ ToolUseBlock,
23
+ )
24
+
25
+ # Code and JSON tokenize denser than prose. Measured against several tokenizers
26
+ # on mixed source-code corpora this sits closer than the usual 4.0.
27
+ CHARS_PER_TOKEN = 3.6
28
+
29
+ # Fixed overhead the wire format adds per message and per tool-call envelope.
30
+ PER_MESSAGE_OVERHEAD = 4
31
+ PER_TOOL_CALL_OVERHEAD = 12
32
+
33
+
34
+ def rough_tokens(text: str) -> int:
35
+ if not text:
36
+ return 0
37
+ return max(1, int(len(text) / CHARS_PER_TOKEN))
38
+
39
+
40
+ def block_tokens(block: ContentBlock) -> int:
41
+ if isinstance(block, TextBlock | ThinkingBlock):
42
+ return rough_tokens(block.text)
43
+ if isinstance(block, ToolUseBlock):
44
+ import json
45
+
46
+ return rough_tokens(block.name) + rough_tokens(json.dumps(block.args)) + PER_TOOL_CALL_OVERHEAD
47
+ if isinstance(block, ToolResultBlock):
48
+ return rough_tokens(block.content) + PER_TOOL_CALL_OVERHEAD
49
+ return 0
50
+
51
+
52
+ def message_tokens(message: Message) -> int:
53
+ return PER_MESSAGE_OVERHEAD + sum(block_tokens(b) for b in message.content)
54
+
55
+
56
+ def history_tokens(messages: list[Message]) -> int:
57
+ return sum(message_tokens(m) for m in messages)
58
+
59
+
60
+ def tool_spec_tokens(specs: list[ToolSpec]) -> int:
61
+ import json
62
+
63
+ return sum(
64
+ rough_tokens(s.name) + rough_tokens(s.description) + rough_tokens(json.dumps(s.input_schema))
65
+ for s in specs
66
+ )
67
+
68
+
69
+ class TokenEstimator:
70
+ """A self-calibrating estimator, one instance per provider.
71
+
72
+ `observe()` is called with the estimate that was made for a request and the
73
+ input_tokens the provider actually reported. The ratio is folded into a
74
+ correction factor with an EWMA, so `estimate()` tightens over a session.
75
+ """
76
+
77
+ def __init__(self, alpha: float = 0.4):
78
+ self.alpha = alpha
79
+ self.factor = 1.0
80
+ self.samples = 0
81
+
82
+ def estimate(self, raw: int) -> int:
83
+ return max(1, int(raw * self.factor))
84
+
85
+ def observe(self, estimated_raw: int, actual: int) -> None:
86
+ if estimated_raw <= 0 or actual <= 0:
87
+ return
88
+ observed = actual / estimated_raw
89
+ # Guard against a wild first sample (e.g. a cache-heavy request where
90
+ # reported input_tokens excludes cache reads).
91
+ if not 0.25 <= observed <= 4.0:
92
+ return
93
+ if self.samples == 0:
94
+ self.factor = observed
95
+ else:
96
+ self.factor = (1 - self.alpha) * self.factor + self.alpha * observed
97
+ self.samples += 1
98
+
99
+ def estimate_request(
100
+ self, messages: list[Message], system: str | list[str], specs: list[ToolSpec]
101
+ ) -> int:
102
+ system_text = system if isinstance(system, str) else "\n".join(system)
103
+ raw = history_tokens(messages) + rough_tokens(system_text) + tool_spec_tokens(specs)
104
+ return self.estimate(raw)