Coverage for src/lilbee/server/chat_dispatch/reasoning_cap.py: 100%
79 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-17 10:02 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-17 10:02 +0000
1"""Applies ``cfg.max_reasoning_chars`` to the canonical chat surfaces.
3Counting and the stop-thinking nudge live in :mod:`lilbee.retrieval.reasoning`.
4The cap notice is written into the still-open ``<think>`` block rather than
5handled per surface, so every downstream translator presents a capped turn.
6Under ``inline`` that block is the answer text, so the notice reads there.
7"""
9from __future__ import annotations
11import contextlib
12import dataclasses
13from collections.abc import AsyncGenerator, AsyncIterator
15from lilbee.providers.base import THINK_CLOSE_TAG, THINK_OPEN_TAG
16from lilbee.retrieval.reasoning import (
17 CAP_CONTINUATION_PROMPT,
18 CAP_NOTICE_TEMPLATE,
19 TagParser,
20 split_reasoning,
21)
22from lilbee.server.chat_dispatch.canonical import (
23 CanonicalChatRequest,
24 CanonicalMessage,
25 CanonicalResponse,
26 CanonicalStreamEvent,
27 CanonicalUsage,
28 ContentBlock,
29 ContentBlockDelta,
30 ContentBlockStart,
31 ContentBlockStop,
32 MessageDelta,
33 MessageStart,
34 TextBlock,
35 TextDelta,
36 ToolUseBlock,
37)
38from lilbee.server.chat_dispatch.dispatch import dispatch_chat, dispatch_chat_stream
40CHARS_PER_TOKEN = 4
41"""Approximate chars per token, for reading ``budget_tokens`` as a char cap."""
44def budget_capped_chars(cap_chars: int, budget_tokens: int | None) -> int:
45 """Tighten *cap_chars* with a per-request token budget; never loosen it.
47 ``0`` means unlimited on both sides. A budget of zero or less is no budget,
48 not a request for unlimited thinking.
49 """
50 if budget_tokens is None or budget_tokens <= 0:
51 return cap_chars
52 budget_chars = budget_tokens * CHARS_PER_TOKEN
53 if cap_chars <= 0:
54 return budget_chars
55 return min(cap_chars, budget_chars)
58def nudged_request(req: CanonicalChatRequest) -> CanonicalChatRequest:
59 """Append the cap-continuation user prompt; every other request field is kept."""
60 return dataclasses.replace(
61 req,
62 messages=[
63 *req.messages,
64 CanonicalMessage.from_string(role="user", text=CAP_CONTINUATION_PROMPT),
65 ],
66 )
69def _cap_notice(cap_chars: int) -> str:
70 """The notice written into the thinking block when the cap fires."""
71 return CAP_NOTICE_TEMPLATE.format(chars=cap_chars)
74async def _aclose(stream: AsyncIterator[CanonicalStreamEvent]) -> None:
75 """Best-effort close; only an async generator has ``aclose``."""
76 if isinstance(stream, AsyncGenerator):
77 with contextlib.suppress(Exception):
78 await stream.aclose()
81def _reindexed(event: CanonicalStreamEvent, offset: int) -> CanonicalStreamEvent:
82 """Shift a continuation event's block index past the first stream's blocks.
84 Without it the continuation reopens index 0 and translators merge it into
85 the block the cap just closed.
86 """
87 if isinstance(event, ContentBlockStart | ContentBlockDelta | ContentBlockStop):
88 return dataclasses.replace(event, index=event.index + offset)
89 return event
92async def cap_aware_chat_stream(
93 stream: AsyncIterator[CanonicalStreamEvent],
94 req: CanonicalChatRequest,
95 *,
96 canonical_model: str,
97 cap_chars: int,
98) -> AsyncIterator[CanonicalStreamEvent]:
99 """Forward *stream*, stopping the reasoning at *cap_chars* and forcing an answer.
101 On cap-fire: close upstream, write the notice into the open thinking block,
102 splice in the continuation. The continuation is not capped again.
103 ``cap_chars <= 0`` forwards the stream verbatim.
104 """
105 parser = TagParser(show=True)
106 cap_fired = False
107 max_index = -1
108 open_index = 0
109 spent = None
110 try:
111 async for event in stream:
112 if isinstance(event, MessageDelta) and event.usage is not None:
113 spent = event.usage
114 if isinstance(event, ContentBlockStart | ContentBlockDelta | ContentBlockStop):
115 max_index = max(max_index, event.index)
116 if isinstance(event, ContentBlockDelta) and isinstance(event.delta, TextDelta):
117 open_index = event.index
118 parser.feed(event.delta.text)
119 yield event
120 # Only cap while still inside <think>; a closed block already answered.
121 if cap_chars > 0 and parser.in_thinking and parser.reasoning_chars > cap_chars:
122 cap_fired = True
123 break
124 finally:
125 if cap_fired:
126 await _aclose(stream)
128 if not cap_fired:
129 return
131 yield ContentBlockDelta(
132 index=open_index,
133 delta=TextDelta(text=f"{_cap_notice(cap_chars)}{THINK_CLOSE_TAG}"),
134 )
135 offset = max_index + 1
136 async for event in dispatch_chat_stream(nudged_request(req), canonical_model=canonical_model):
137 # The message already started; a second prelude would restart it.
138 if isinstance(event, MessageStart):
139 continue
140 yield _reindexed(_with_spent(event, spent), offset)
143def _with_spent(event: CanonicalStreamEvent, spent: CanonicalUsage | None) -> CanonicalStreamEvent:
144 """Add the capped call's tokens to the continuation's usage.
146 The caller paid for both generations, so reporting only the continuation's
147 hides the reasoning the cap just stopped. A provider that reports usage
148 once at the end has none to add: the cap closes the first stream before
149 that event, and those tokens go unreported.
150 """
151 if spent is None or not isinstance(event, MessageDelta) or event.usage is None:
152 return event
153 return dataclasses.replace(
154 event,
155 usage=CanonicalUsage(
156 input_tokens=event.usage.input_tokens + spent.input_tokens,
157 output_tokens=event.usage.output_tokens + spent.output_tokens,
158 ),
159 )
162def cap_aware_chat(
163 req: CanonicalChatRequest, *, canonical_model: str, cap_chars: int
164) -> CanonicalResponse:
165 """Run a non-streaming chat call, re-issuing a turn that only reasoned.
167 One finished result arrives, so the first call's reasoning cannot be stopped
168 mid-flight. A turn over the cap with no answer and no tool call is re-issued
169 with the nudge; a turn that answered is kept.
170 """
171 resp = dispatch_chat(req, canonical_model=canonical_model)
172 if cap_chars <= 0:
173 return resp
174 text = "".join(b.text for b in resp.content if isinstance(b, TextBlock))
175 reasoning, answer = split_reasoning(text)
176 tool_uses = [b for b in resp.content if isinstance(b, ToolUseBlock)]
177 if len(reasoning) <= cap_chars or answer.strip() or tool_uses:
178 return resp
180 continuation = dispatch_chat(nudged_request(req), canonical_model=canonical_model)
181 return _merged_response(resp, continuation, cap_chars=cap_chars, reasoning=reasoning)
184def _merged_response(
185 capped: CanonicalResponse,
186 continuation: CanonicalResponse,
187 *,
188 cap_chars: int,
189 reasoning: str,
190) -> CanonicalResponse:
191 """Fold a capped turn and its continuation into one response.
193 Reasoning is truncated to the cap; both calls' token counts are summed.
194 """
195 kept = f"{THINK_OPEN_TAG}{reasoning[:cap_chars]}{_cap_notice(cap_chars)}{THINK_CLOSE_TAG}"
196 tail = "".join(b.text for b in continuation.content if isinstance(b, TextBlock))
197 content: list[ContentBlock] = [TextBlock(text=f"{kept}{tail}")]
198 content.extend(b for b in continuation.content if isinstance(b, ToolUseBlock))
199 return dataclasses.replace(
200 continuation,
201 content=content,
202 usage=CanonicalUsage(
203 input_tokens=capped.usage.input_tokens + continuation.usage.input_tokens,
204 output_tokens=capped.usage.output_tokens + continuation.usage.output_tokens,
205 ),
206 )