fix(streaming): fill text_tokens when reasoning is counted from stream content

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kerry 2026-09-16 23:13:05 +00:00
parent 4b70696afa
commit 02bccfd89f
2 changed files with 7 additions and 22 deletions

View file

@ -1100,7 +1100,8 @@ class ChunkProcessor:
if reasoning_tokens is not None:
if returned_usage.completion_tokens_details is None:
returned_usage.completion_tokens_details = CompletionTokensDetailsWrapper(
reasoning_tokens=reasoning_tokens
reasoning_tokens=reasoning_tokens,
text_tokens=max(0, returned_usage.completion_tokens - reasoning_tokens),
)
elif (
returned_usage.completion_tokens_details is not None

View file

@ -257,14 +257,6 @@ class TestAnthropicCursorBug:
@pytest.mark.parametrize("placeholder", [1, 3, 8])
def test_interrupted_reasoning_only_stream_estimates_from_reasoning(self, placeholder: int):
"""
message_start placeholders are not always 1 (live Anthropic streams
have been observed sending 1 and 8 for the same prompt), and a thinking
model cut off before message_delta has streamed only reasoning_content.
The recovered usage, including the completion_tokens_details the cost
calculator bills from, must come from that reasoning rather than from
the placeholder.
"""
message_start = _make_chunk(
usage=Usage(
prompt_tokens=100,
@ -292,13 +284,9 @@ class TestAnthropicCursorBug:
)
assert response.usage.total_tokens == response.usage.prompt_tokens + reasoning_tokens
details = response.usage.completion_tokens_details
assert (details.text_tokens or 0) + details.reasoning_tokens == response.usage.completion_tokens
assert details.text_tokens + details.reasoning_tokens == response.usage.completion_tokens
def test_fallback_counts_reasoning_and_text_together(self):
"""
With no usable provider count, the estimate covers everything the
provider generated: reasoning_content plus visible text, not text alone.
"""
reasoning = "First I should check whether the input is sorted. " * 10
text = "The list is already sorted, so no work is needed."
chunks = [_make_chunk(reasoning_content=reasoning), _make_chunk(content=text)]
@ -306,16 +294,12 @@ class TestAnthropicCursorBug:
response = litellm.stream_chunk_builder(chunks=chunks, messages=[{"role": "user", "content": "Sort it."}])
text_only = litellm.token_counter(model="claude-sonnet-4-6", text=text, count_response_tokens=True)
reasoning_tokens = response.usage.completion_tokens_details.reasoning_tokens
assert reasoning_tokens > 0
assert response.usage.completion_tokens == text_only + reasoning_tokens
details = response.usage.completion_tokens_details
assert details.reasoning_tokens > 0
assert response.usage.completion_tokens == text_only + details.reasoning_tokens
assert details.text_tokens == text_only
def test_lone_usage_event_with_finish_reason_is_trusted(self):
"""
Guardrails rebuild responses from the chunks yielded to the client,
which excludes the un-yielded message_start. A finished stream then has
exactly one usage event (message_delta) and it must be kept as-is.
"""
chunks = [
_make_chunk(content="Yes, "),
_make_chunk(content="that works."),