99
1010import httpx
1111
12- from forge .clients .base import ChunkType , StreamChunk , format_tool
12+ from forge .clients .base import ChunkType , StreamChunk , TokenUsage , format_tool
1313from forge .core .workflow import LLMResponse , TextResponse , ToolCall , ToolSpec
1414from forge .errors import BackendError , ContextDiscoveryError
1515from 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" , "" )
0 commit comments