llms think tags handling

frdel committed Aug 29, 2025 at 19:55 UTC 7ef7ddbef7a9cd3edadad1562606b3fad27b34fe
2 files changed +248 -55
models.py
+225 -55
@@ -53,7 +53,8 @@ def turn_off_logging():
53 # init
54 load_dotenv()
55 turn_off_logging()
56 -litellm.modify_params = True # helps fix anthropic tool calls by browser-use
56 +litellm.modify_params = True # helps fix anthropic tool calls by browser-use
57 +
58
59 class ModelType(Enum):
60 CHAT = "Chat"
@@ -82,14 +83,116 @@ class ModelConfig:
83
84 class ChatChunk(TypedDict):
85 """Simplified response chunk for chat models."""
85 -
86 response_delta: str
87 reasoning_delta: str
88
89 +class ChatGenerationResult:
90 + """Chat generation result object"""
91 + def __init__(self, chunk: ChatChunk|None = None):
92 + self.reasoning = ""
93 + self.response = ""
94 + self.thinking = False
95 + self.thinking_tag = ""
96 + self.unprocessed = ""
97 + self.native_reasoning = False
98 + self.thinking_pairs = [("<think>", "</think>"), ("<reasoning>", "</reasoning>")]
99 + if chunk:
100 + self.add_chunk(chunk)
101 +
102 + def add_chunk(self, chunk: ChatChunk) -> ChatChunk:
103 + if chunk["reasoning_delta"]:
104 + self.native_reasoning = True
105 +
106 + # if native reasoning detection works, there's no need to worry about thinking tags
107 + if self.native_reasoning:
108 + processed_chunk = ChatChunk(response_delta=chunk["response_delta"], reasoning_delta=chunk["reasoning_delta"])
109 + else:
110 + # if the model outputs thinking tags, we ned to parse them manually as reasoning
111 + processed_chunk = self._process_thinking_chunk(chunk)
112 +
113 + self.reasoning += processed_chunk["reasoning_delta"]
114 + self.response += processed_chunk["response_delta"]
115 +
116 + return processed_chunk
117 +
118 + def _process_thinking_chunk(self, chunk: ChatChunk) -> ChatChunk:
119 + response_delta = self.unprocessed + chunk["response_delta"]
120 + self.unprocessed = ""
121 + return self._process_thinking_tags(response_delta, chunk["reasoning_delta"])
122 +
123 + def _process_thinking_tags(self, response: str, reasoning: str) -> ChatChunk:
124 + if self.thinking:
125 + close_pos = response.find(self.thinking_tag)
126 + if close_pos != -1:
127 + reasoning += response[:close_pos]
128 + response = response[close_pos + len(self.thinking_tag):]
129 + self.thinking = False
130 + self.thinking_tag = ""
131 + else:
132 + if self._is_partial_closing_tag(response):
133 + self.unprocessed = response
134 + response = ""
135 + else:
136 + reasoning += response
137 + response = ""
138 + else:
139 + for opening_tag, closing_tag in self.thinking_pairs:
140 + if response.startswith(opening_tag):
141 + response = response[len(opening_tag):]
142 + self.thinking = True
143 + self.thinking_tag = closing_tag
144 +
145 + close_pos = response.find(closing_tag)
146 + if close_pos != -1:
147 + reasoning += response[:close_pos]
148 + response = response[close_pos + len(closing_tag):]
149 + self.thinking = False
150 + self.thinking_tag = ""
151 + else:
152 + if self._is_partial_closing_tag(response):
153 + self.unprocessed = response
154 + response = ""
155 + else:
156 + reasoning += response
157 + response = ""
158 + break
159 + elif len(response) < len(opening_tag) and self._is_partial_opening_tag(response, opening_tag):
160 + self.unprocessed = response
161 + response = ""
162 + break
163 +
164 + return ChatChunk(response_delta=response, reasoning_delta=reasoning)
165 +
166 + def _is_partial_opening_tag(self, text: str, opening_tag: str) -> bool:
167 + for i in range(1, len(opening_tag)):
168 + if text == opening_tag[:i]:
169 + return True
170 + return False
171 +
172 + def _is_partial_closing_tag(self, text: str) -> bool:
173 + if not self.thinking_tag or not text:
174 + return False
175 + max_check = min(len(text), len(self.thinking_tag) - 1)
176 + for i in range(1, max_check + 1):
177 + if text.endswith(self.thinking_tag[:i]):
178 + return True
179 + return False
180 +
181 + def output(self) -> ChatChunk:
182 + response = self.response
183 + reasoning = self.reasoning
184 + if self.unprocessed:
185 + if reasoning and not response:
186 + reasoning += self.unprocessed
187 + else:
188 + response += self.unprocessed
189 + return ChatChunk(response_delta=response, reasoning_delta=reasoning)
190 +
191
192 rate_limiters: dict[str, RateLimiter] = {}
193 api_keys_round_robin: dict[str, int] = {}
194
195 +
196 def get_api_key(service: str) -> str:
197 # get api key for the service
198 key = (
@@ -116,7 +219,14 @@ def get_rate_limiter(
219 limiter.limits["output"] = output or 0
220 return limiter
221
119 -async def apply_rate_limiter(model_config: ModelConfig|None, input_text: str, rate_limiter_callback: Callable[[str, str, int, int], Awaitable[bool]] | None = None):
222 +
223 +async def apply_rate_limiter(
224 + model_config: ModelConfig | None,
225 + input_text: str,
226 + rate_limiter_callback: (
227 + Callable[[str, str, int, int], Awaitable[bool]] | None
228 + ) = None,
229 +):
230 if not model_config:
231 return
232 limiter = get_rate_limiter(
@@ -131,25 +241,41 @@ async def apply_rate_limiter(model_config: ModelConfig|None, input_text: str, ra
241 await limiter.wait(rate_limiter_callback)
242 return limiter
243
134 -def apply_rate_limiter_sync(model_config: ModelConfig|None, input_text: str, rate_limiter_callback: Callable[[str, str, int, int], Awaitable[bool]] | None = None):
244 +
245 +def apply_rate_limiter_sync(
246 + model_config: ModelConfig | None,
247 + input_text: str,
248 + rate_limiter_callback: (
249 + Callable[[str, str, int, int], Awaitable[bool]] | None
250 + ) = None,
251 +):
252 if not model_config:
253 return
254 import asyncio, nest_asyncio
255 +
256 nest_asyncio.apply()
139 - return asyncio.run(apply_rate_limiter(model_config, input_text, rate_limiter_callback))
257 + return asyncio.run(
258 + apply_rate_limiter(model_config, input_text, rate_limiter_callback)
259 + )
260
261
262 class LiteLLMChatWrapper(SimpleChatModel):
263 model_name: str
264 provider: str
265 kwargs: dict = {}
146 -
266 +
267 class Config:
268 arbitrary_types_allowed = True
269 extra = "allow" # Allow extra attributes
270 validate_assignment = False # Don't validate on assignment
271
152 - def __init__(self, model: str, provider: str, model_config: Optional[ModelConfig] = None, **kwargs: Any):
272 + def __init__(
273 + self,
274 + model: str,
275 + provider: str,
276 + model_config: Optional[ModelConfig] = None,
277 + **kwargs: Any,
278 + ):
279 model_value = f"{provider}/{model}"
280 super().__init__(model_name=model_value, provider=provider, kwargs=kwargs) # type: ignore
281 # Set A0 model config as instance attribute after parent init
@@ -158,7 +284,7 @@ class LiteLLMChatWrapper(SimpleChatModel):
284 @property
285 def _llm_type(self) -> str:
286 return "litellm-chat"
161 -
287 +
288 def _convert_messages(self, messages: List[BaseMessage]) -> List[dict]:
289 result = []
290 # Map LangChain message types to LiteLLM roles
@@ -215,12 +341,12 @@ class LiteLLMChatWrapper(SimpleChatModel):
341 **kwargs: Any,
342 ) -> str:
343 import asyncio
218 -
344 +
345 msgs = self._convert_messages(messages)
220 -
346 +
347 # Apply rate limiting if configured
348 apply_rate_limiter_sync(self.a0_model_conf, str(msgs))
223 -
349 +
350 # Call the model
351 resp = completion(
352 model=self.model_name, messages=msgs, stop=stop, **{**self.kwargs, **kwargs}
@@ -228,7 +354,8 @@ class LiteLLMChatWrapper(SimpleChatModel):
354
355 # Parse output
356 parsed = _parse_chunk(resp)
231 - return parsed["response_delta"]
357 + output = ChatGenerationResult(parsed).output()
358 + return output["response_delta"]
359
360 def _stream(
361 self,
@@ -238,12 +365,14 @@ class LiteLLMChatWrapper(SimpleChatModel):
365 **kwargs: Any,
366 ) -> Iterator[ChatGenerationChunk]:
367 import asyncio
241 -
368 +
369 msgs = self._convert_messages(messages)
243 -
370 +
371 # Apply rate limiting if configured
372 apply_rate_limiter_sync(self.a0_model_conf, str(msgs))
246 -
373 +
374 + result = ChatGenerationResult()
375 +
376 for chunk in completion(
377 model=self.model_name,
378 messages=msgs,
@@ -251,11 +380,14 @@ class LiteLLMChatWrapper(SimpleChatModel):
380 stop=stop,
381 **{**self.kwargs, **kwargs},
382 ):
254 - parsed = _parse_chunk(chunk)
383 + # parse chunk
384 + parsed = _parse_chunk(chunk) # chunk parsing
385 + output = result.add_chunk(parsed) # chunk processing
386 +
387 # Only yield chunks with non-None content
256 - if parsed["response_delta"]:
388 + if output["response_delta"]:
389 yield ChatGenerationChunk(
258 - message=AIMessageChunk(content=parsed["response_delta"])
390 + message=AIMessageChunk(content=output["response_delta"])
391 )
392
393 async def _astream(
@@ -266,11 +398,12 @@ class LiteLLMChatWrapper(SimpleChatModel):
398 **kwargs: Any,
399 ) -> AsyncIterator[ChatGenerationChunk]:
400 msgs = self._convert_messages(messages)
269 -
401 +
402 # Apply rate limiting if configured
403 await apply_rate_limiter(self.a0_model_conf, str(msgs))
272 -
273 -
404 +
405 + result = ChatGenerationResult()
406 +
407 response = await acompletion(
408 model=self.model_name,
409 messages=msgs,
@@ -279,11 +412,14 @@ class LiteLLMChatWrapper(SimpleChatModel):
412 **{**self.kwargs, **kwargs},
413 )
414 async for chunk in response: # type: ignore
282 - parsed = _parse_chunk(chunk)
415 + # parse chunk
416 + parsed = _parse_chunk(chunk) # chunk parsing
417 + output = result.add_chunk(parsed) # chunk processing
418 +
419 # Only yield chunks with non-None content
284 - if parsed["response_delta"]:
420 + if output["response_delta"]:
421 yield ChatGenerationChunk(
286 - message=AIMessageChunk(content=parsed["response_delta"])
422 + message=AIMessageChunk(content=output["response_delta"])
423 )
424
425 async def unified_call(
@@ -294,7 +430,9 @@ class LiteLLMChatWrapper(SimpleChatModel):
430 response_callback: Callable[[str, str], Awaitable[None]] | None = None,
431 reasoning_callback: Callable[[str, str], Awaitable[None]] | None = None,
432 tokens_callback: Callable[[str, int], Awaitable[None]] | None = None,
297 - rate_limiter_callback: Callable[[str, str, int, int], Awaitable[bool]] | None = None,
433 + rate_limiter_callback: (
434 + Callable[[str, str, int, int], Awaitable[bool]] | None
435 + ) = None,
436 **kwargs: Any,
437 ) -> Tuple[str, str]:
438
@@ -312,7 +450,9 @@ class LiteLLMChatWrapper(SimpleChatModel):
450 msgs_conv = self._convert_messages(messages)
451
452 # Apply rate limiting if configured
315 - limiter = await apply_rate_limiter(self.a0_model_conf, str(msgs_conv), rate_limiter_callback)
453 + limiter = await apply_rate_limiter(
454 + self.a0_model_conf, str(msgs_conv), rate_limiter_callback
455 + )
456
457 # call model
458 _completion = await acompletion(
@@ -323,41 +463,41 @@ class LiteLLMChatWrapper(SimpleChatModel):
463 )
464
465 # results
326 - reasoning = ""
327 - response = ""
466 + result = ChatGenerationResult()
467
468 # iterate over chunks
469 async for chunk in _completion: # type: ignore
470 + # parse chunk
471 parsed = _parse_chunk(chunk)
472 + output = result.add_chunk(parsed)
473 +
474 # collect reasoning delta and call callbacks
333 - if parsed["reasoning_delta"]:
334 - reasoning += parsed["reasoning_delta"]
475 + if output["reasoning_delta"]:
476 if reasoning_callback:
336 - await reasoning_callback(parsed["reasoning_delta"], reasoning)
477 + await reasoning_callback(output["reasoning_delta"], result.reasoning)
478 if tokens_callback:
479 await tokens_callback(
339 - parsed["reasoning_delta"],
340 - approximate_tokens(parsed["reasoning_delta"]),
480 + output["reasoning_delta"],
481 + approximate_tokens(output["reasoning_delta"]),
482 )
483 # Add output tokens to rate limiter if configured
484 if limiter:
344 - limiter.add(output=approximate_tokens(parsed["reasoning_delta"]))
485 + limiter.add(output=approximate_tokens(output["reasoning_delta"]))
486 # collect response delta and call callbacks
346 - if parsed["response_delta"]:
347 - response += parsed["response_delta"]
487 + if output["response_delta"]:
488 if response_callback:
349 - await response_callback(parsed["response_delta"], response)
489 + await response_callback(output["response_delta"], result.response)
490 if tokens_callback:
491 await tokens_callback(
352 - parsed["response_delta"],
353 - approximate_tokens(parsed["response_delta"]),
492 + output["response_delta"],
493 + approximate_tokens(output["response_delta"]),
494 )
495 # Add output tokens to rate limiter if configured
496 if limiter:
357 - limiter.add(output=approximate_tokens(parsed["response_delta"]))
497 + limiter.add(output=approximate_tokens(output["response_delta"]))
498
499 # return complete results
360 - return response, reasoning
500 + return result.response, result.reasoning
501
502
503 class BrowserCompatibleChatWrapper(LiteLLMChatWrapper):
@@ -400,15 +540,21 @@ class LiteLLMEmbeddingWrapper(Embeddings):
540 kwargs: dict = {}
541 a0_model_conf: Optional[ModelConfig] = None
542
403 - def __init__(self, model: str, provider: str, model_config: Optional[ModelConfig] = None, **kwargs: Any):
543 + def __init__(
544 + self,
545 + model: str,
546 + provider: str,
547 + model_config: Optional[ModelConfig] = None,
548 + **kwargs: Any,
549 + ):
550 self.model_name = f"{provider}/{model}" if provider != "openai" else model
551 self.kwargs = kwargs
552 self.a0_model_conf = model_config
407 -
553 +
554 def embed_documents(self, texts: List[str]) -> List[List[float]]:
555 # Apply rate limiting if configured
556 apply_rate_limiter_sync(self.a0_model_conf, " ".join(texts))
411 -
557 +
558 resp = embedding(model=self.model_name, input=texts, **self.kwargs)
559 return [
560 item.get("embedding") if isinstance(item, dict) else item.embedding # type: ignore
@@ -418,7 +564,7 @@ class LiteLLMEmbeddingWrapper(Embeddings):
564 def embed_query(self, text: str) -> List[float]:
565 # Apply rate limiting if configured
566 apply_rate_limiter_sync(self.a0_model_conf, text)
421 -
567 +
568 resp = embedding(model=self.model_name, input=[text], **self.kwargs)
569 item = resp.data[0] # type: ignore
570 return item.get("embedding") if isinstance(item, dict) else item.embedding # type: ignore
@@ -427,7 +573,13 @@ class LiteLLMEmbeddingWrapper(Embeddings):
573 class LocalSentenceTransformerWrapper(Embeddings):
574 """Local wrapper for sentence-transformers models to avoid HuggingFace API calls"""
575
430 - def __init__(self, provider: str, model: str, model_config: Optional[ModelConfig] = None, **kwargs: Any):
576 + def __init__(
577 + self,
578 + provider: str,
579 + model: str,
580 + model_config: Optional[ModelConfig] = None,
581 + **kwargs: Any,
582 + ):
583 # Clean common user-input mistakes
584 model = model.strip().strip('"').strip("'")
585
@@ -449,18 +601,18 @@ class LocalSentenceTransformerWrapper(Embeddings):
601 self.model = SentenceTransformer(model, **st_kwargs)
602 self.model_name = model
603 self.a0_model_conf = model_config
452 -
604 +
605 def embed_documents(self, texts: List[str]) -> List[List[float]]:
606 # Apply rate limiting if configured
607 apply_rate_limiter_sync(self.a0_model_conf, " ".join(texts))
456 -
608 +
609 embeddings = self.model.encode(texts, convert_to_tensor=False) # type: ignore
610 return embeddings.tolist() if hasattr(embeddings, "tolist") else embeddings # type: ignore
611
612 def embed_query(self, text: str) -> List[float]:
613 # Apply rate limiting if configured
614 apply_rate_limiter_sync(self.a0_model_conf, text)
463 -
615 +
616 embedding = self.model.encode([text], convert_to_tensor=False) # type: ignore
617 result = (
618 embedding[0].tolist() if hasattr(embedding[0], "tolist") else embedding[0]
@@ -485,10 +637,17 @@ def _get_litellm_chat(
637 provider_name, model_name, kwargs = _adjust_call_args(
638 provider_name, model_name, kwargs
639 )
488 - return cls(provider=provider_name, model=model_name, model_config=model_config, **kwargs)
640 + return cls(
641 + provider=provider_name, model=model_name, model_config=model_config, **kwargs
642 + )
643
644
491 -def _get_litellm_embedding(model_name: str, provider_name: str, model_config: Optional[ModelConfig] = None, **kwargs: Any):
645 +def _get_litellm_embedding(
646 + model_name: str,
647 + provider_name: str,
648 + model_config: Optional[ModelConfig] = None,
649 + **kwargs: Any,
650 +):
651 # Check if this is a local sentence-transformers model
652 if provider_name == "huggingface" and model_name.startswith(
653 "sentence-transformers/"
@@ -498,7 +657,10 @@ def _get_litellm_embedding(model_name: str, provider_name: str, model_config: Op
657 provider_name, model_name, kwargs
658 )
659 return LocalSentenceTransformerWrapper(
501 - provider=provider_name, model=model_name, model_config=model_config, **kwargs
660 + provider=provider_name,
661 + model=model_name,
662 + model_config=model_config,
663 + **kwargs,
664 )
665
666 # use api key from kwargs or env
@@ -511,7 +673,9 @@ def _get_litellm_embedding(model_name: str, provider_name: str, model_config: Op
673 provider_name, model_name, kwargs = _adjust_call_args(
674 provider_name, model_name, kwargs
675 )
514 - return LiteLLMEmbeddingWrapper(model=model_name, provider=provider_name, model_config=model_config, **kwargs)
676 + return LiteLLMEmbeddingWrapper(
677 + model=model_name, provider=provider_name, model_config=model_config, **kwargs
678 + )
679
680
681 def _parse_chunk(chunk: Any) -> ChatChunk:
@@ -533,9 +697,11 @@ def _parse_chunk(chunk: Any) -> ChatChunk:
697 if isinstance(delta, dict)
698 else getattr(delta, "reasoning_content", "")
699 )
700 +
701 return ChatChunk(reasoning_delta=reasoning_delta, response_delta=response_delta)
702
703
704 +
705 def _adjust_call_args(provider_name: str, model_name: str, kwargs: dict):
706 # for openrouter add app reference
707 if provider_name == "openrouter":
@@ -599,10 +765,14 @@ def _merge_provider_defaults(
765 return provider_name, kwargs
766
767
602 -def get_chat_model(provider: str, name: str, model_config: Optional[ModelConfig] = None, **kwargs: Any) -> LiteLLMChatWrapper:
768 +def get_chat_model(
769 + provider: str, name: str, model_config: Optional[ModelConfig] = None, **kwargs: Any
770 +) -> LiteLLMChatWrapper:
771 orig = provider.lower()
772 provider_name, kwargs = _merge_provider_defaults("chat", orig, kwargs)
605 - return _get_litellm_chat(LiteLLMChatWrapper, name, provider_name, model_config, **kwargs)
773 + return _get_litellm_chat(
774 + LiteLLMChatWrapper, name, provider_name, model_config, **kwargs
775 + )
776
777
778 def get_browser_model(
tests/chunk_parser_test.py new
+23
@@ -0,0 +1,23 @@
1 +import sys, os
2 +
3 +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
4 +import models
5 +
6 +ex1 = "<think>reasoning goes here</think>response goes here"
7 +ex2 = "<think>reasoning goes here</thi"
8 +
9 +
10 +def test_example(example: str):
11 + res = models.ChatGenerationResult()
12 + for i in range(len(example)):
13 + char = example[i]
14 + chunk = res.add_chunk({"response_delta": char, "reasoning_delta": ""})
15 + print(i, ":", chunk)
16 +
17 + print("output", res.output())
18 +
19 +
20 +if __name__ == "__main__":
21 + # test_example(ex1)
22 + test_example(ex2)
23 +
\ No newline at end of file