Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions src/amrita_core/adapters/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ async def call_api(
or getattr(completion, "_request_id", None)
)
async for chunk in completion:
# The final chunk of an OpenAI-compatible stream carries the
# usage with an empty ``choices`` list, so it has to be read
# before touching ``choices[0]`` — otherwise the IndexError
# below ends the loop and the usage is lost.
if chunk.usage:
uni_usage = UniResponseUsage.model_validate(
chunk.usage, from_attributes=True
)
try:
if chunk.choices[0].finish_reason is not None:
model_name = chunk.model
Expand All @@ -171,10 +179,6 @@ async def call_api(
else None
),
)
if chunk.usage:
uni_usage = UniResponseUsage.model_validate(
chunk.usage, from_attributes=True
)

if (
reas_chunk := getattr(
Expand All @@ -197,7 +201,9 @@ async def call_api(
yield chunk
debug_log(chunk)
except IndexError:
break
# A chunk without choices (e.g. the usage-only tail) is
# not the end of the stream; keep draining it.
continue

else:
if isinstance(completion, ChatCompletion):
Expand Down
47 changes: 47 additions & 0 deletions tests/test_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from openai.types.chat.chat_completion_message_tool_call import (
Function as ToolCallFunction,
)
from openai.types.completion_usage import CompletionUsage

from amrita_core.adapters import anthropic as anthropic_module
from amrita_core.adapters.anthropic import AnthropicAdapter
Expand Down Expand Up @@ -315,6 +316,52 @@ async def test_call_api_streaming_index_error(self, adapter, mock_messages):
assert isinstance(results[0], UniResponse)
assert results[0].content == ""

@pytest.mark.asyncio
async def test_call_api_streaming_usage_in_tail_chunk(self, adapter, mock_messages):
"""usage 挂在 choices 为空的尾块上时不应丢失"""
adapter.preset.config.stream = True

body_chunk = ChatCompletionChunk(
id="chatcmpl-1",
choices=[
{
"index": 0,
"delta": {"content": "hi"},
"finish_reason": None,
}
],
created=1,
model="gpt-4o",
object="chat.completion.chunk",
)
tail_chunk = ChatCompletionChunk(
id="chatcmpl-1",
choices=[],
created=1,
model="gpt-4o",
object="chat.completion.chunk",
usage=CompletionUsage(
prompt_tokens=100, completion_tokens=20, total_tokens=120
),
)

with patch("amrita_core.adapters.openai.openai.AsyncOpenAI") as mock_openai:
mock_client = AsyncMock()
mock_stream = MockAsyncStream([body_chunk, tail_chunk])
mock_client.chat.completions.create.return_value = mock_stream
mock_openai.return_value = mock_client

results = []
async for result in adapter.call_api(mock_messages):
results.append(result)

final = results[-1]
assert isinstance(final, UniResponse)
assert final.usage is not None
assert final.usage.prompt_tokens == 100
assert final.usage.completion_tokens == 20
assert final.usage.total_tokens == 120

@pytest.mark.asyncio
async def test_call_tools_with_string_tool_choice(self, adapter, mock_messages):
"""Test call_tools with string tool choice to cover client creation path"""
Expand Down
Loading