Skip to content

Commit 1280237

Browse files
Capture real token counts from backends and use for compaction (#33)
* Capture real token counts from llama-server and use for compaction LlamafileClient now records TokenUsage (prompt, completion, total) from every llama-server response. ContextManager uses these real counts instead of chars/4 heuristic when available, so compaction and context warnings fire based on actual KV cache pressure — critical for reasoning models where think tokens consume context invisibly. Closes #32 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add token usage capture to OllamaClient Same pattern as LlamafileClient — captures prompt_eval_count and eval_count from Ollama responses (both streaming and non-streaming) into last_usage dict. ContextManager picks these up via the existing _sync_token_count path in run_inference. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent eaee0e0 commit 1280237

6 files changed

Lines changed: 161 additions & 89 deletions

File tree

src/forge/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from forge.core.steps import StepTracker
1313
from forge.core.inference import InferenceResult, fold_and_serialize, run_inference
1414
from forge.core.runner import WorkflowRunner
15-
from forge.clients.base import ChunkType, LLMClient, StreamChunk
15+
from forge.clients.base import ChunkType, LLMClient, StreamChunk, TokenUsage
1616
from forge.clients.llamafile import LlamafileClient
1717
from forge.clients.ollama import OllamaClient
1818
from forge.context import (
@@ -82,6 +82,7 @@
8282
"LlamafileClient",
8383
"OllamaClient",
8484
"StreamChunk",
85+
"TokenUsage",
8586
# Context
8687
"CompactEvent",
8788
"CompactStrategy",

src/forge/clients/base.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@
1010
from forge.core.workflow import LLMResponse, ToolCall, TextResponse, ToolSpec
1111

1212

13+
@dataclass(frozen=True)
14+
class TokenUsage:
15+
"""Token counts from a single LLM response.
16+
17+
Populated from the server's ``usage`` field when available (e.g.
18+
llama-server). Backends that don't report usage leave the client's
19+
``last_usage`` empty and the context manager falls back to heuristic
20+
estimation.
21+
"""
22+
23+
prompt_tokens: int
24+
completion_tokens: int
25+
total_tokens: int
26+
27+
1328
# Both Ollama and llama-server use the OpenAI tool schema format today.
1429
# If a backend diverges, move this back into the relevant client module.
1530
def format_tool(spec: ToolSpec) -> dict[str, Any]:

src/forge/clients/llamafile.py

Lines changed: 98 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import httpx
1111

12-
from forge.clients.base import ChunkType, StreamChunk, format_tool
12+
from forge.clients.base import ChunkType, StreamChunk, TokenUsage, format_tool
1313
from forge.core.workflow import LLMResponse, TextResponse, ToolCall, ToolSpec
1414
from forge.errors import BackendError, ContextDiscoveryError
1515
from forge.prompts.templates import build_tool_prompt, extract_tool_call
@@ -144,6 +144,8 @@ def __init__(
144144
self._cache_prompt = cache_prompt
145145
self._slot_id = slot_id
146146

147+
self.last_usage: dict[int, TokenUsage] = {}
148+
147149
if mode in ("native", "prompt"):
148150
self.resolved_mode: str | None = mode
149151
else:
@@ -154,6 +156,18 @@ def _apply_slot_id(self, body: dict[str, Any]) -> None:
154156
if self._slot_id is not None:
155157
body["slot_id"] = self._slot_id
156158

159+
def _record_usage(self, data: dict[str, Any]) -> None:
160+
"""Extract usage from a response and store it keyed by slot ID."""
161+
usage = data.get("usage")
162+
if not usage:
163+
return
164+
slot = self._slot_id if self._slot_id is not None else 0
165+
self.last_usage[slot] = TokenUsage(
166+
prompt_tokens=usage.get("prompt_tokens", 0),
167+
completion_tokens=usage.get("completion_tokens", 0),
168+
total_tokens=usage.get("total_tokens", 0),
169+
)
170+
157171
def _resolve_reasoning(
158172
self, accumulated_reasoning: str, accumulated_content: str
159173
) -> str | None:
@@ -208,6 +222,7 @@ async def send_stream(
208222
"model": self.model,
209223
"temperature": self.temperature,
210224
"stream": True,
225+
"stream_options": {"include_usage": True},
211226
"cache_prompt": self._cache_prompt,
212227
}
213228
self._apply_slot_id(body)
@@ -231,6 +246,7 @@ async def send_stream(
231246

232247
accumulated_content = ""
233248
accumulated_reasoning = ""
249+
stream_intentional = False
234250
# Track multiple tool calls by index — OpenAI streaming sends
235251
# tool_calls[N] deltas with an index field.
236252
tool_call_parts: dict[int, dict[str, str]] = {} # idx -> {name, args}
@@ -252,93 +268,88 @@ async def send_stream(
252268
if not line or not line.startswith("data: "):
253269
continue
254270
data_str = line[6:]
255-
# llama-server sends "data: [DONE]" after the final chunk;
256-
# llamafile 0.9.x does not — it only sends finish_reason.
257271
if data_str == "[DONE]":
258-
stream_done = True
259-
else:
260-
chunk = json.loads(data_str)
261-
if "choices" not in chunk or not chunk["choices"]:
262-
continue # usage-only or error chunk
263-
choice = chunk["choices"][0]
264-
delta = choice.get("delta", {})
265-
266-
if "tool_calls" in delta:
267-
for tc_delta in delta["tool_calls"]:
268-
idx = tc_delta.get("index", 0)
269-
if idx not in tool_call_parts:
270-
tool_call_parts[idx] = {"name": "", "args": ""}
271-
func = tc_delta.get("function", {})
272-
if "name" in func:
273-
tool_call_parts[idx]["name"] = func["name"]
274-
if "arguments" in func:
275-
tool_call_parts[idx]["args"] += func["arguments"]
276-
yield StreamChunk(
277-
type=ChunkType.TOOL_CALL_DELTA,
278-
content=func["arguments"],
279-
)
280-
281-
reasoning_content = delta.get("reasoning_content") or ""
282-
if reasoning_content:
283-
accumulated_reasoning += reasoning_content
284-
285-
content = delta.get("content") or ""
286-
if content:
287-
accumulated_content += content
288-
yield StreamChunk(
289-
type=ChunkType.TEXT_DELTA, content=content
290-
)
291-
292-
stream_finish_reason = choice.get("finish_reason")
293-
stream_done = stream_finish_reason is not None
294-
295-
if stream_done:
296-
stream_intentional = stream_finish_reason == "stop"
297-
if tool_call_parts:
298-
reasoning = self._resolve_reasoning(
299-
accumulated_reasoning, accumulated_content
300-
)
301-
result_calls: list[ToolCall] = []
302-
bad_args = False
303-
for idx in sorted(tool_call_parts):
304-
part = tool_call_parts[idx]
305-
try:
306-
args = json.loads(part["args"]) if part["args"] else {}
307-
except json.JSONDecodeError:
308-
bad_args = True
309-
break
310-
result_calls.append(ToolCall(
311-
tool=part["name"],
312-
args=args,
313-
reasoning=reasoning if idx == 0 else None,
314-
))
315-
if bad_args:
316-
# Model emitted invalid JSON in tool args —
317-
# surface as TextResponse so the runner sends
318-
# a retry nudge instead of crashing.
319-
final: LLMResponse = TextResponse(
320-
content=accumulated_content or part["args"],
321-
)
322-
else:
323-
final = result_calls
324-
elif mode == "prompt" and tools:
325-
think_text, cleaned = _extract_think_tags(
326-
accumulated_content
327-
)
328-
tool_names = [t.name for t in tools]
329-
extracted = extract_tool_call(cleaned, tool_names)
330-
if extracted:
331-
extracted[0].reasoning = self._resolve_reasoning(
332-
accumulated_reasoning, think_text
333-
)
334-
final = extracted
335-
else:
336-
final = TextResponse(content=cleaned, intentional=stream_intentional)
337-
else:
338-
final = TextResponse(content=accumulated_content, intentional=stream_intentional)
339-
yield StreamChunk(type=ChunkType.FINAL, response=final)
340272
break
341273

274+
chunk = json.loads(data_str)
275+
if "choices" not in chunk or not chunk["choices"]:
276+
self._record_usage(chunk)
277+
continue
278+
choice = chunk["choices"][0]
279+
delta = choice.get("delta", {})
280+
281+
if "tool_calls" in delta:
282+
for tc_delta in delta["tool_calls"]:
283+
idx = tc_delta.get("index", 0)
284+
if idx not in tool_call_parts:
285+
tool_call_parts[idx] = {"name": "", "args": ""}
286+
func = tc_delta.get("function", {})
287+
if "name" in func:
288+
tool_call_parts[idx]["name"] = func["name"]
289+
if "arguments" in func:
290+
tool_call_parts[idx]["args"] += func["arguments"]
291+
yield StreamChunk(
292+
type=ChunkType.TOOL_CALL_DELTA,
293+
content=func["arguments"],
294+
)
295+
296+
reasoning_content = delta.get("reasoning_content") or ""
297+
if reasoning_content:
298+
accumulated_reasoning += reasoning_content
299+
300+
content = delta.get("content") or ""
301+
if content:
302+
accumulated_content += content
303+
yield StreamChunk(
304+
type=ChunkType.TEXT_DELTA, content=content
305+
)
306+
307+
finish_reason = choice.get("finish_reason")
308+
if finish_reason is not None:
309+
stream_intentional = finish_reason == "stop"
310+
311+
# Stream ended — build and yield FINAL response.
312+
if tool_call_parts:
313+
reasoning = self._resolve_reasoning(
314+
accumulated_reasoning, accumulated_content
315+
)
316+
result_calls: list[ToolCall] = []
317+
bad_args = False
318+
for idx in sorted(tool_call_parts):
319+
part = tool_call_parts[idx]
320+
try:
321+
args = json.loads(part["args"]) if part["args"] else {}
322+
except json.JSONDecodeError:
323+
bad_args = True
324+
break
325+
result_calls.append(ToolCall(
326+
tool=part["name"],
327+
args=args,
328+
reasoning=reasoning if idx == 0 else None,
329+
))
330+
if bad_args:
331+
final: LLMResponse = TextResponse(
332+
content=accumulated_content or part["args"],
333+
)
334+
else:
335+
final = result_calls
336+
elif mode == "prompt" and tools:
337+
think_text, cleaned = _extract_think_tags(
338+
accumulated_content
339+
)
340+
tool_names = [t.name for t in tools]
341+
extracted = extract_tool_call(cleaned, tool_names)
342+
if extracted:
343+
extracted[0].reasoning = self._resolve_reasoning(
344+
accumulated_reasoning, think_text
345+
)
346+
final = extracted
347+
else:
348+
final = TextResponse(content=cleaned, intentional=stream_intentional)
349+
else:
350+
final = TextResponse(content=accumulated_content, intentional=stream_intentional)
351+
yield StreamChunk(type=ChunkType.FINAL, response=final)
352+
342353
async def get_context_length(self) -> int | None:
343354
"""Query the Llamafile /props endpoint for configured context length.
344355
@@ -410,6 +421,7 @@ async def _send_native(
410421
if resp.status_code != 200:
411422
raise BackendError(resp.status_code, resp.text)
412423
data = resp.json()
424+
self._record_usage(data)
413425

414426
top_choice = data["choices"][0]
415427
choice = top_choice["message"]
@@ -470,6 +482,7 @@ async def _send_prompt(
470482
)
471483
resp.raise_for_status()
472484
data = resp.json()
485+
self._record_usage(data)
473486

474487
top_choice = data["choices"][0]
475488
content = top_choice["message"].get("content", "")

src/forge/clients/ollama.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import httpx
1010

11-
from forge.clients.base import ChunkType, StreamChunk, format_tool
11+
from forge.clients.base import ChunkType, StreamChunk, TokenUsage, format_tool
1212
from forge.core.workflow import LLMResponse, TextResponse, ToolCall, ToolSpec
1313
from forge.errors import BackendError, ThinkingNotSupportedError
1414

@@ -61,6 +61,7 @@ def __init__(
6161
model_lower = model.lower()
6262
self._think = any(kw in model_lower for kw in _THINK_HEURISTIC_KEYWORDS)
6363
self._think_resolved: bool = think is not None
64+
self.last_usage: dict[int, TokenUsage] = {}
6465

6566
def _build_options(self) -> dict[str, Any]:
6667
opts: dict[str, Any] = {"temperature": self.temperature}
@@ -82,6 +83,20 @@ def _resolve_reasoning(
8283
return None
8384
return thinking or content or None
8485

86+
def _record_usage(self, data: dict[str, Any]) -> None:
87+
"""Extract token usage from an Ollama response."""
88+
prompt = data.get("prompt_eval_count")
89+
completion = data.get("eval_count")
90+
if prompt is None and completion is None:
91+
return
92+
prompt = prompt or 0
93+
completion = completion or 0
94+
self.last_usage[0] = TokenUsage(
95+
prompt_tokens=prompt,
96+
completion_tokens=completion,
97+
total_tokens=prompt + completion,
98+
)
99+
85100
async def send(
86101
self,
87102
messages: list[dict[str, str]],
@@ -118,6 +133,7 @@ async def send(
118133
if resp.status_code != 200:
119134
raise BackendError(resp.status_code, resp.text)
120135
data = resp.json()
136+
self._record_usage(data)
121137

122138
if not self._think_resolved:
123139
self._think_resolved = True
@@ -219,6 +235,7 @@ async def _iter_stream(
219235
msg = data.get("message", {})
220236

221237
if data.get("done"):
238+
self._record_usage(data)
222239
tool_calls = msg.get("tool_calls") or pending_tool_calls
223240
if tool_calls:
224241
reasoning = self._resolve_reasoning(

src/forge/context/manager.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,21 @@ def __init__(
8888
self._context_thresholds = sorted(context_thresholds) if context_thresholds else []
8989
self._on_context_threshold = on_context_threshold
9090
self._fired_thresholds: set[float] = set()
91+
self._last_known_tokens: int | None = None
92+
93+
def update_token_count(self, total_tokens: int) -> None:
94+
"""Record actual token count from the backend.
95+
96+
Called after each LLM response when the backend reports usage.
97+
Subsequent calls to ``estimate_tokens`` will return this value
98+
until the next update.
99+
"""
100+
self._last_known_tokens = total_tokens
91101

92102
def estimate_tokens(self, messages: list[Message]) -> int:
93-
"""Estimate token count via char/4 heuristic."""
103+
"""Return actual token count if available, else char/4 heuristic."""
104+
if self._last_known_tokens is not None:
105+
return self._last_known_tokens
94106
return sum(len(m.content) for m in messages) // 4
95107

96108
def check_thresholds(self, messages: list[Message]) -> str | None:

src/forge/core/inference.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from dataclasses import dataclass, field
1414
from typing import Any
1515

16-
from forge.clients.base import ChunkType, LLMClient, StreamChunk
16+
from forge.clients.base import ChunkType, LLMClient, StreamChunk, TokenUsage
1717
from forge.context.manager import ContextManager
1818
from forge.core.messages import Message, MessageMeta, MessageRole, MessageType, ToolCallInfo
1919
from forge.core.workflow import LLMResponse, TextResponse, ToolCall, ToolSpec
@@ -28,6 +28,17 @@
2828
}
2929

3030

31+
def _sync_token_count(client: LLMClient, context_manager: ContextManager) -> None:
32+
"""Feed actual token count from the client into the context manager."""
33+
last_usage = getattr(client, "last_usage", None)
34+
if not isinstance(last_usage, dict):
35+
return
36+
slot_id = getattr(client, "_slot_id", None) or 0
37+
usage: TokenUsage | None = last_usage.get(slot_id)
38+
if usage is not None:
39+
context_manager.update_token_count(usage.total_tokens)
40+
41+
3142
@dataclass
3243
class InferenceResult:
3344
"""Result of a single inference call (may include transparent retries).
@@ -193,6 +204,9 @@ async def run_inference(
193204
else:
194205
response = await client.send(api_messages, tools=tool_specs)
195206

207+
# Update context manager with real token count if available.
208+
_sync_token_count(client, context_manager)
209+
196210
# Validate
197211
validation = validator.validate(response, trust_text_intent=trust_text_intent)
198212

0 commit comments

Comments
 (0)