litellm/tests/integration/streaming/test_fake_stream_reasoning_wire.py
Mateo Wang 7ddc9f1c96
fix(bedrock)!: keep reasoning, thinking blocks and the json_mode finish_reason on fake streams (#46863)
* fix(bedrock): keep reasoning, thinking blocks and the json_mode finish_reason on fake streams

A Bedrock Converse model without native structured output fakes the stream
when response_format carries a json_schema. The fake stream transformed the
response without json_mode and rebuilt a chunk from text, tool_use and token
counts only, so it dropped reasoning_content, the signed thinking_blocks and
the reasoning token count, and ended on finish_reason tool_calls.

The three fake-stream sites now pass json_mode to _transform_response and
stream the resulting ModelResponse through the shared
convert_model_response_to_streaming converter, which now numbers each
streamed thinking block with its index the way real streams do

* fix(bedrock): parse text tool calls on Converse fake streams like the non-stream answer

* fix(responses): stream the answer text when one chat chunk carries reasoning and the answer

* fix(streaming): keep every field on the OpenAI and openai_like fake streams and split multi-block thinking

Azure o-series and openai_like fake streams now use the shared MockResponseIterator, so extra tool calls,
reasoning and cached and reasoning tokens survive; the Bedrock-local iterator is deleted.

The /v1/responses bridge also splits a chunk whose thinking block is signed or redacted with no reasoning
text, and the sync iterator closes the reasoning item before the answer opens, like the async one.

The /v1/messages bridge gives each thinking block in a chunk its own content block, starts a new block when
the thinking block index changes, and streams redacted thinking as redacted_thinking.

Snowflake replays drop the stream index from thinking blocks.

* fix(anthropic): stream each thinking block's text once on /v1/messages

An unsigned thinking chunk now opens its content block with empty text, since its thinking_delta carries the text. A multi-block chunk keeps each piece's thinking block, so an unsigned block followed by a signed one keeps its own text and index.

* docs(anthropic): describe which thinking block start carries its text

* fix(streaming): close merged reasoning before the answer when one chunk carries both

* test(streaming): cover fake-stream reasoning across the proxy bridges and type the Snowflake replay test
2026-10-16 17:27:57 +00:00

874 lines
34 KiB
Python

import base64
import json
import math
import os
import uuid
from collections.abc import Callable, Generator, Mapping, Sequence
from contextlib import contextmanager
from dataclasses import dataclass
from importlib.resources import files
from itertools import chain
from typing import Final
from urllib.parse import unquote
import anthropic
import openai
import pytest
from integration._support.client import Gateway, Scenario, eventually
from integration._support.database import read_rows
from integration._support.upstream import (
_aws_event_frame, # pyright: ignore[reportPrivateUsage] # the scripted Bedrock peer's only event-stream encoder
)
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter
from redis import Redis
_TOKEN: Final = "synthetic-bedrock-bearer"
_NATIVE_REDACTED: Final = base64.b64encode(b"\x12\x1esynthetic redacted reasoning payload").decode()
_CONVERSE_REDACTED: Final = base64.b64encode(_NATIVE_REDACTED.encode()).decode()
_SIGNATURE: Final = "EqoBCkgIBxABGAIiQsyntheticSignature"
_ANSWER: Final[dict[str, JsonValue]] = {"city": "Tokyo", "population_millions": 14.0}
_THINKING_TOKENS: Final = 87
_EVENT_STREAM: Final = "application/vnd.amazon.eventstream"
_NO_CACHE: Final[dict[str, JsonValue]] = {"no-cache": True}
_THINKING: Final[dict[str, JsonValue]] = {"type": "enabled", "budget_tokens": 1024}
_SCHEMA: Final[dict[str, JsonValue]] = {
"type": "object",
"properties": {"city": {"type": "string"}, "population_millions": {"type": "number"}},
"required": ["city", "population_millions"],
"additionalProperties": False,
}
_RESPONSE_FORMAT: Final[dict[str, JsonValue]] = {
"type": "json_schema",
"json_schema": {"name": "city_fact", "schema": _SCHEMA},
}
_CONVERSE_USAGE: Final[dict[str, JsonValue]] = {
"inputTokens": 40,
"outputTokens": 120,
"cacheReadInputTokens": 50,
"totalTokens": 210,
}
_LLAMA_MODEL_ID: Final = "us.meta.llama3-3-70b-instruct-v1:0"
_VLLM_BACKEND: Final = "qwen3-fake-stream-audit"
_VLLM_KEY: Final = "synthetic-hosted-vllm-key"
_AZURE_DEPLOYMENT: Final = "reasoning-deployment"
_AZURE_API_VERSION: Final = "2025-03-01-preview"
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_DISCOVERY_PROBE: Final = ("GET", "/v1/models")
def _tool(name: str) -> dict[str, JsonValue]:
return {
"type": "function",
"function": {
"name": name,
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
},
}
class _Frozen(BaseModel):
model_config = ConfigDict(frozen=True)
class _CostEntry(_Frozen):
litellm_provider: str | None = None
supports_native_structured_output: bool | None = None
supports_reasoning: bool | None = None
class _ToolFunction(_Frozen):
name: str | None = None
arguments: str | None = None
class _ToolCall(_Frozen):
index: int = 0
function: _ToolFunction
class _PromptDetails(_Frozen):
cached_tokens: int | None = None
class _CompletionDetails(_Frozen):
reasoning_tokens: int | None = None
class _Usage(_Frozen):
prompt_tokens: int
completion_tokens: int
total_tokens: int
prompt_tokens_details: _PromptDetails | None = None
completion_tokens_details: _CompletionDetails | None = None
class _Message(_Frozen):
content: str | None = None
reasoning_content: str | None = None
thinking_blocks: tuple[dict[str, JsonValue], ...] | None = None
tool_calls: tuple[_ToolCall, ...] | None = None
class _Choice(_Frozen):
message: _Message
finish_reason: str | None = None
class _Completion(_Frozen):
choices: tuple[_Choice, ...]
usage: _Usage
class _StreamChoice(_Frozen):
delta: _Message
finish_reason: str | None = None
class _Chunk(_Frozen):
choices: tuple[_StreamChoice, ...] = ()
usage: _Usage | None = None
class _ConverseMessage(_Frozen):
role: str
content: tuple[dict[str, JsonValue], ...]
class _ConverseRequest(_Frozen):
messages: tuple[_ConverseMessage, ...]
@dataclass(frozen=True, slots=True)
class _Answer:
content: str
reasoning: str
thinking_blocks: tuple[dict[str, JsonValue], ...]
tool_calls: tuple[tuple[str, JsonValue], ...]
finish_reason: str | None
usage: _Usage | None
_COST_MAP: Final = TypeAdapter(dict[str, _CostEntry])
def _fakes_structured_output_stream(model: str, entry: _CostEntry) -> bool:
return (
model.startswith("us.anthropic.claude")
and entry.litellm_provider == "bedrock_converse"
and entry.supports_native_structured_output is False
and entry.supports_reasoning is True
)
def _claude_without_native_structured_output() -> str:
entries: Final = _COST_MAP.validate_json(
(files("litellm") / "model_prices_and_context_window_backup.json").read_bytes()
)
return next(model for model, entry in entries.items() if _fakes_structured_output_stream(model, entry))
def _converse_reply(content: Sequence[JsonValue], stop_reason: str) -> Reply:
return Reply(
body=json.dumps(
{
"output": {"message": {"role": "assistant", "content": list(content)}},
"stopReason": stop_reason,
"usage": _CONVERSE_USAGE,
"additionalModelResponseFields": {
"usage": {"output_tokens_details": {"thinking_tokens": _THINKING_TOKENS}}
},
"metrics": {"latencyMs": 1},
}
).encode()
)
def _reasoned_json_answer(reasoning: str) -> list[JsonValue]:
return [
{"reasoningContent": {"reasoningText": {"text": reasoning, "signature": _SIGNATURE}}},
{"reasoningContent": {"redactedContent": _CONVERSE_REDACTED}},
{"toolUse": {"toolUseId": "tooluse_json_1", "name": "json_tool_call", "input": _ANSWER}},
]
def _is_replay(request: Request) -> bool:
return any(message.role == "assistant" for message in _ConverseRequest.model_validate_json(request.body).messages)
def _converse_peer(reasoning: str) -> Callable[[Request], Reply]:
def respond(request: Request) -> Reply:
assert unquote(request.target).endswith("/converse"), request.target
if _is_replay(request):
return _converse_reply([{"text": "replayed"}], "end_turn")
return _converse_reply(_reasoned_json_answer(reasoning), "tool_use")
return respond
def _converse_targets(wire: Wire) -> list[str]:
return [unquote(request.target).rsplit("/", 1)[-1] for request in wire.drain()]
def _claude_model(scenario: Scenario, wire: Wire) -> str:
return scenario.model(
model=f"bedrock/converse/{_claude_without_native_structured_output()}",
api_key=_TOKEN,
aws_region_name="us-east-1",
aws_bedrock_runtime_endpoint=wire.url,
)
def _openai_client(gateway: Gateway) -> openai.OpenAI:
return openai.OpenAI(base_url=str(gateway.client.base_url) + "/v1", api_key=gateway.key, max_retries=0)
def _parsed_arguments(arguments: str | None) -> JsonValue:
return _JSON_OBJECT.validate_json(arguments or "{}")
def _completion_answer(completion: _Completion) -> _Answer:
choice: Final = completion.choices[0]
return _Answer(
content=choice.message.content or "",
reasoning=choice.message.reasoning_content or "",
thinking_blocks=choice.message.thinking_blocks or (),
tool_calls=tuple(
(call.function.name or "", _parsed_arguments(call.function.arguments))
for call in choice.message.tool_calls or ()
),
finish_reason=choice.finish_reason,
usage=completion.usage,
)
def _joined_call(calls: Sequence[_ToolCall], index: int) -> tuple[str, JsonValue]:
pieces: Final = tuple(call.function for call in calls if call.index == index)
return (
"".join(piece.name or "" for piece in pieces),
_parsed_arguments("".join(piece.arguments or "" for piece in pieces)),
)
def _streamed_answer(chunks: Sequence[_Chunk]) -> _Answer:
choices: Final = tuple(chain.from_iterable(chunk.choices for chunk in chunks))
deltas: Final = tuple(choice.delta for choice in choices)
calls: Final = tuple(chain.from_iterable(delta.tool_calls or () for delta in deltas))
finishes: Final = tuple(choice.finish_reason for choice in choices if choice.finish_reason)
usages: Final = tuple(chunk.usage for chunk in chunks if chunk.usage is not None)
return _Answer(
content="".join(delta.content or "" for delta in deltas),
reasoning="".join(delta.reasoning_content or "" for delta in deltas),
thinking_blocks=tuple(chain.from_iterable(delta.thinking_blocks or () for delta in deltas)),
tool_calls=tuple(_joined_call(calls, index) for index in sorted({call.index for call in calls})),
finish_reason=finishes[-1] if finishes else None,
usage=usages[-1] if usages else None,
)
def _chat(gateway: Gateway, body: Mapping[str, JsonValue]) -> _Answer:
completion: Final = _openai_client(gateway).chat.completions.create(
model=str(body["model"]),
messages=[{"role": "user", "content": str(body["question"])}],
extra_body={key: value for key, value in body.items() if key not in ("model", "question")},
)
return _completion_answer(_Completion.model_validate(completion.model_dump(mode="json")))
def _chat_stream(gateway: Gateway, body: Mapping[str, JsonValue]) -> _Answer:
stream: Final = _openai_client(gateway).chat.completions.create(
model=str(body["model"]),
messages=[{"role": "user", "content": str(body["question"])}],
stream=True,
stream_options={"include_usage": True},
extra_body={key: value for key, value in body.items() if key not in ("model", "question")},
)
return _streamed_answer(tuple(_Chunk.model_validate(chunk.model_dump(mode="json")) for chunk in stream))
def _unindexed(blocks: Sequence[dict[str, JsonValue]]) -> tuple[dict[str, JsonValue], ...]:
return tuple({key: value for key, value in block.items() if key != "index"} for block in blocks)
def _json_question(model: str, marker: str, cache: Mapping[str, JsonValue] = _NO_CACHE) -> dict[str, JsonValue]:
return {
"model": model,
"question": f"How many people live in Tokyo, in millions? {marker}",
"response_format": _RESPONSE_FORMAT,
"thinking": _THINKING,
"max_tokens": 2048,
"cache": dict(cache),
}
def test_fake_streamed_json_schema_chat_answer_matches_the_non_streamed_answer(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
reasoning: Final = f"Tokyo proper has about 14 million people, marker {marker}."
with wire_server(_converse_peer(reasoning)) as wire, gateway.scenario() as scenario:
model: Final = _claude_model(scenario, wire)
question: Final = _json_question(model, marker)
answer: Final = _chat(gateway, question)
streamed: Final = _chat_stream(gateway, question)
assert _converse_targets(wire) == ["converse", "converse"]
assert (answer.reasoning, answer.finish_reason, answer.tool_calls) == (reasoning, "stop", ())
assert _JSON_OBJECT.validate_json(answer.content) == _ANSWER
assert answer.thinking_blocks == (
{"type": "thinking", "thinking": reasoning, "signature": _SIGNATURE},
{"type": "redacted_thinking", "data": _NATIVE_REDACTED},
)
assert (streamed.reasoning, streamed.finish_reason, streamed.tool_calls) == (reasoning, "stop", ())
assert _JSON_OBJECT.validate_json(streamed.content) == _ANSWER
assert _unindexed(streamed.thinking_blocks) == answer.thinking_blocks
assert streamed.usage == answer.usage
assert answer.usage is not None and answer.usage.completion_tokens_details is not None
assert answer.usage.completion_tokens_details.reasoning_tokens == _THINKING_TOKENS
replayed: Final = gateway.request(
"POST",
"/v1/chat/completions",
{
"model": model,
"thinking": _THINKING,
"max_tokens": 2048,
"cache": _NO_CACHE,
"messages": [
{"role": "user", "content": str(question["question"])},
{
"role": "assistant",
"content": streamed.content,
"thinking_blocks": list(streamed.thinking_blocks),
},
{"role": "user", "content": "And Osaka?"},
],
},
)
assert replayed.status_code == 200, replayed.text
sent: Final = _ConverseRequest.model_validate_json(wire.drain()[-1].body)
assert next(message for message in sent.messages if message.role == "assistant").content == (
{"reasoningContent": {"reasoningText": {"text": reasoning, "signature": _SIGNATURE}}},
{"reasoningContent": {"redactedContent": _CONVERSE_REDACTED}},
{"text": streamed.content},
)
def _spend_rows(model: str, count: int) -> list[dict[str, JsonValue]]:
return eventually(
lambda: read_rows(
'SELECT spend, prompt_tokens, completion_tokens FROM "LiteLLM_SpendLogs" WHERE model_group=%s', (model,)
),
lambda found: len(found) >= count,
seconds=70,
)
def test_fake_streamed_cached_prompt_is_billed_like_the_non_streamed_answer(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_converse_peer(f"Billing check {marker}.")) as wire, gateway.scenario() as scenario:
model: Final = _claude_model(scenario, wire)
question: Final = _json_question(model, marker)
_chat(gateway, question)
_chat_stream(gateway, question)
rows: Final = _spend_rows(model, 2)
assert _converse_targets(wire) == ["converse", "converse"]
assert len(rows) == 2, rows
first, second = rows
assert (first["prompt_tokens"], first["completion_tokens"]) == (
second["prompt_tokens"],
second["completion_tokens"],
)
assert float(str(first["spend"])) > 0
assert math.isclose(float(str(second["spend"])), float(str(first["spend"])), rel_tol=1e-9), rows
def _cache_holds(needle: str) -> bool:
with Redis(host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"])) as store:
return any(needle.encode() in _bytes(store.get(key)) for key in store.scan_iter(count=1000, _type="STRING"))
def _bytes(value: object) -> bytes:
return value if isinstance(value, bytes) else b""
def test_cached_fake_stream_replays_the_answer_without_reaching_bedrock(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
reasoning: Final = f"Cached reasoning {marker}."
with wire_server(_converse_peer(reasoning)) as wire, gateway.scenario() as scenario:
model: Final = _claude_model(scenario, wire)
question: Final = _json_question(model, marker, cache={})
first: Final = _chat_stream(gateway, question)
assert first.reasoning == reasoning
eventually(lambda: _cache_holds(marker), bool, seconds=30)
assert _converse_targets(wire) == ["converse"]
replay: Final = _chat_stream(gateway, question)
assert _converse_targets(wire) == []
assert (replay.content, replay.finish_reason, replay.tool_calls) == (first.content, "stop", ())
assert _unindexed(replay.thinking_blocks) in ((), _unindexed(first.thinking_blocks))
def _responses_reasoning_and_answer(
events: Sequence[dict[str, JsonValue]],
) -> tuple[list[tuple[str, str, JsonValue]], str, str]:
lifecycle: Final = [
(str(event["type"]), str(_JSON_OBJECT.validate_python(event["item"])["type"]), event["output_index"])
for event in events
if event["type"] in ("response.output_item.added", "response.output_item.done")
]
summary: Final = "".join(
str(event["delta"]) for event in events if event["type"] == "response.reasoning_summary_text.delta"
)
text: Final = "".join(str(event["delta"]) for event in events if event["type"] == "response.output_text.delta")
return lifecycle, summary, text
_REASONING_THEN_MESSAGE: Final = [
("response.output_item.added", "reasoning", 0),
("response.output_item.done", "reasoning", 0),
("response.output_item.added", "message", 1),
("response.output_item.done", "message", 1),
]
def _raw_responses_events(gateway: Gateway, body: Mapping[str, JsonValue]) -> list[dict[str, JsonValue]]:
with gateway.client.stream(
"POST",
"/v1/responses",
json={**body, "stream": True, "store": False, "cache": _NO_CACHE},
headers={"Authorization": f"Bearer {gateway.key}"},
) as response:
assert response.status_code == 200, response.read()
return [
_JSON_OBJECT.validate_json(line.removeprefix("data: "))
for line in response.iter_lines()
if line.startswith("data: {")
]
def _completed_usage(events: Sequence[dict[str, JsonValue]]) -> dict[str, JsonValue]:
completed: Final = [event for event in events if event["type"] == "response.completed"]
assert len(completed) == 1, [event["type"] for event in events]
return _JSON_OBJECT.validate_python(_JSON_OBJECT.validate_python(completed[0]["response"])["usage"])
def test_fake_streamed_json_schema_responses_stream_closes_the_reasoning_before_the_answer(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
reasoning: Final = f"Responses reasoning {marker}."
with wire_server(_converse_peer(reasoning)) as wire, gateway.scenario() as scenario:
model: Final = _claude_model(scenario, wire)
events: Final = _raw_responses_events(
gateway,
{
"model": model,
"input": f"How many people live in Tokyo, in millions? {marker}",
"reasoning": {"effort": "high"},
"max_output_tokens": 2048,
"text": {"format": {"type": "json_schema", "name": "city_fact", "schema": _SCHEMA}},
},
)
lifecycle, summary, text = _responses_reasoning_and_answer(events)
assert _converse_targets(wire) == ["converse"]
assert lifecycle == _REASONING_THEN_MESSAGE, [event["type"] for event in events]
assert summary == reasoning
assert _JSON_OBJECT.validate_json(text) == _ANSWER
usage: Final = _completed_usage(events)
assert _JSON_OBJECT.validate_python(usage["input_tokens_details"])["cached_tokens"] == 50
assert _JSON_OBJECT.validate_python(usage["output_tokens_details"])["reasoning_tokens"] == _THINKING_TOKENS
def _anthropic_client(gateway: Gateway) -> anthropic.Anthropic:
return anthropic.Anthropic(base_url=str(gateway.client.base_url), api_key=gateway.key, max_retries=0)
def _block_fields(block: anthropic.types.ContentBlock) -> tuple[str, str, str]:
match block.type:
case "thinking":
return (block.type, block.thinking, block.signature)
case "redacted_thinking":
return (block.type, block.data, "")
case "text":
return (block.type, block.text, "")
case _:
return (block.type, "", "")
def _message_blocks(message: anthropic.types.Message) -> list[tuple[str, str, str]]:
return [_block_fields(block) for block in message.content]
def test_fake_streamed_json_schema_messages_stream_opens_one_block_per_thinking_block(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
reasoning: Final = f"Messages reasoning {marker}."
with wire_server(_converse_peer(reasoning)) as wire, gateway.scenario() as scenario:
model: Final = _claude_model(scenario, wire)
with _anthropic_client(gateway).messages.stream(
model=model,
max_tokens=2048,
thinking={"type": "enabled", "budget_tokens": 1024},
messages=[{"role": "user", "content": f"How many people live in Tokyo, in millions? {marker}"}],
extra_body={"output_format": {"type": "json_schema", "schema": _SCHEMA}, "cache": _NO_CACHE},
) as stream:
final: Final = stream.get_final_message()
assert _converse_targets(wire) == ["converse"]
blocks: Final = _message_blocks(final)
assert blocks[:2] == [
("thinking", reasoning, _SIGNATURE),
("redacted_thinking", _NATIVE_REDACTED, ""),
], blocks
assert [block[0] for block in blocks[2:]] == ["text"], blocks
assert _JSON_OBJECT.validate_json(blocks[2][1]) == _ANSWER
assert final.stop_reason == "end_turn"
def _frame(event_type: str, payload: Mapping[str, JsonValue]) -> bytes:
return _aws_event_frame(event_type, payload, "sc", "u")
def _converse_stream_with_thinking(reasoning_parts: Sequence[str], answer: str) -> Reply:
return Reply(
content_type=_EVENT_STREAM,
body=b"".join(
(
_frame("messageStart", {"role": "assistant"}),
*(
_frame(
"contentBlockDelta",
{"delta": {"reasoningContent": {"text": part}}, "contentBlockIndex": 0},
)
for part in reasoning_parts
),
_frame(
"contentBlockDelta",
{"delta": {"reasoningContent": {"signature": _SIGNATURE}}, "contentBlockIndex": 0},
),
_frame("contentBlockStop", {"contentBlockIndex": 0}),
_frame("contentBlockDelta", {"delta": {"text": answer}, "contentBlockIndex": 1}),
_frame("contentBlockStop", {"contentBlockIndex": 1}),
_frame("messageStop", {"stopReason": "end_turn"}),
_frame("metadata", {"usage": {"inputTokens": 11, "outputTokens": 9, "totalTokens": 20}}),
)
),
)
def test_native_converse_stream_thinking_reaches_the_anthropic_sdk_once_with_its_signature(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
parts: Final = ("Native thinking ", f"marker {marker}.")
answer: Final = "It is sunny in Paris."
def respond(request: Request) -> Reply:
assert unquote(request.target).endswith("/converse-stream"), request.target
return _converse_stream_with_thinking(parts, answer)
with wire_server(respond) as wire, gateway.scenario() as scenario:
model: Final = _claude_model(scenario, wire)
with _anthropic_client(gateway).messages.stream(
model=model,
max_tokens=2048,
thinking={"type": "enabled", "budget_tokens": 1024},
messages=[{"role": "user", "content": f"Weather in Paris? {marker}"}],
extra_body={"cache": _NO_CACHE},
) as stream:
final: Final = stream.get_final_message()
assert _converse_targets(wire) == ["converse-stream"]
assert _message_blocks(final) == [("thinking", "".join(parts), _SIGNATURE), ("text", answer, "")]
def _llama_peer(content: list[JsonValue], stop_reason: str) -> Callable[[Request], Reply]:
def respond(request: Request) -> Reply:
assert unquote(request.target).endswith("/converse"), request.target
return Reply(
body=json.dumps(
{
"output": {"message": {"role": "assistant", "content": content}},
"stopReason": stop_reason,
"usage": {"inputTokens": 175, "outputTokens": 23, "totalTokens": 198},
"metrics": {"latencyMs": 1},
}
).encode()
)
return respond
_PARIS: Final[dict[str, JsonValue]] = {"city": "Paris"}
@pytest.mark.parametrize(
"content, stop_reason, expected_calls",
[
pytest.param(
[
{"toolUse": {"toolUseId": "tooluse_weather", "name": "get_weather", "input": _PARIS}},
{"toolUse": {"toolUseId": "tooluse_time", "name": "get_time", "input": _PARIS}},
],
"tool_use",
(("get_weather", _PARIS), ("get_time", _PARIS)),
id="two-tool-use-blocks",
),
pytest.param(
[{"text": json.dumps({"type": "function", "name": "get_weather", "parameters": _PARIS})}],
"end_turn",
(("get_weather", _PARIS),),
id="tool-call-written-as-text",
),
],
)
def test_fake_streamed_llama_tool_calls_match_the_non_streamed_answer(
gateway: Gateway,
content: list[JsonValue],
stop_reason: str,
expected_calls: tuple[tuple[str, JsonValue], ...],
) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(_llama_peer(content, stop_reason)) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(
model=f"bedrock/converse/{_LLAMA_MODEL_ID}",
api_key=_TOKEN,
aws_region_name="us-east-1",
aws_bedrock_runtime_endpoint=wire.url,
)
question: Final[dict[str, JsonValue]] = {
"model": model,
"question": f"Weather and time in Paris? {marker}",
"tools": [_tool("get_weather"), _tool("get_time")],
"cache": _NO_CACHE,
}
answer: Final = _chat(gateway, question)
streamed: Final = _chat_stream(gateway, question)
assert _converse_targets(wire) == ["converse", "converse"]
assert (answer.tool_calls, answer.finish_reason) == (expected_calls, "tool_calls")
assert (streamed.tool_calls, streamed.finish_reason, streamed.content) == (
answer.tool_calls,
"tool_calls",
answer.content,
)
def _azure_o_series_reply() -> bytes:
return json.dumps(
{
"id": f"chatcmpl-{uuid.uuid4().hex}",
"object": "chat.completion",
"created": 1,
"model": "o4-mini",
"choices": [
{
"index": 0,
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_weather",
"type": "function",
"function": {"name": "get_weather", "arguments": json.dumps(_PARIS)},
},
{
"id": "call_time",
"type": "function",
"function": {"name": "get_time", "arguments": json.dumps(_PARIS)},
},
],
},
}
],
"usage": {
"prompt_tokens": 85,
"completion_tokens": 1955,
"total_tokens": 2040,
"prompt_tokens_details": {"cached_tokens": 64},
"completion_tokens_details": {"reasoning_tokens": 1920},
},
}
).encode()
def test_azure_o_series_fake_stream_keeps_every_tool_call_and_token_detail(gateway: Gateway) -> None:
marker: Final = uuid.uuid4().hex
with wire_server(lambda _: Reply(body=_azure_o_series_reply())) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(
model=f"azure/o_series/{_AZURE_DEPLOYMENT}",
api_base=wire.url,
api_key="synthetic-azure-key",
api_version=_AZURE_API_VERSION,
)
streamed: Final = _chat_stream(
gateway,
{
"model": model,
"question": f"Weather and time in Paris? {marker}",
"tools": [_tool("get_weather"), _tool("get_time")],
"cache": _NO_CACHE,
},
)
received: Final = wire.drain()
assert [unquote(request.target).split("?")[0] for request in received] == [
f"/openai/deployments/{_AZURE_DEPLOYMENT}/chat/completions"
]
assert _JSON_OBJECT.validate_json(received[0].body).get("stream") is not True
assert (streamed.tool_calls, streamed.finish_reason) == (
(("get_weather", _PARIS), ("get_time", _PARIS)),
"tool_calls",
)
assert streamed.usage is not None
assert streamed.usage.prompt_tokens_details == _PromptDetails(cached_tokens=64)
assert streamed.usage.completion_tokens_details == _CompletionDetails(reasoning_tokens=1920)
def _vllm_frame(identity: str, delta: Mapping[str, JsonValue], finish_reason: str | None = None) -> bytes:
usage: Final = (
{"usage": {"prompt_tokens": 30, "completion_tokens": 12, "total_tokens": 42}} if finish_reason else {}
)
body: Final = {
"id": identity,
"object": "chat.completion.chunk",
"created": 1,
"model": _VLLM_BACKEND,
"choices": [{"index": 0, "delta": dict(delta), "finish_reason": finish_reason}],
**usage,
}
return b"data: " + json.dumps(body).encode() + b"\n\n"
def _vllm_sse(deltas: Sequence[Mapping[str, JsonValue]], finish_reason: str) -> Reply:
identity: Final = f"chatcmpl-audit-{uuid.uuid4().hex}"
return Reply(
content_type="text/event-stream",
chunks=(
_vllm_frame(identity, {"role": "assistant", "content": ""}),
*(_vllm_frame(identity, delta) for delta in deltas),
_vllm_frame(identity, {}, finish_reason),
b"data: [DONE]\n\n",
),
)
def _is_discovery_probe(request: Request) -> bool:
return (request.method, request.target) == _DISCOVERY_PROBE
@contextmanager
def _vllm_server(reply: Reply) -> Generator[Wire, None, None]:
with wire_server(
lambda request: Reply(body=b'{"object":"list","data":[]}') if _is_discovery_probe(request) else reply
) as wire:
yield wire
def _streamed_chats(wire: Wire) -> list[JsonValue]:
posted: Final = [request for request in wire.drain() if not _is_discovery_probe(request)]
assert [(request.method, request.target) for request in posted] == [("POST", "/v1/chat/completions")]
return [_JSON_OBJECT.validate_json(request.body).get("stream") for request in posted]
@pytest.mark.parametrize(
"deltas, merged",
[
pytest.param(
({"reasoning_content": "R1 "}, {"reasoning_content": "R2 ", "content": "A1 "}, {"content": "A2"}),
"<think>R1 R2 </think>A1 A2",
id="answer-starts-in-the-last-reasoning-chunk",
),
pytest.param(
({"reasoning_content": "R ", "content": "A "}, {"content": "B"}),
"<think>R </think>A B",
id="reasoning-and-answer-in-the-first-chunk",
),
pytest.param(
({"reasoning_content": "R1 "}, {"reasoning_content": "R2 "}, {"content": "A1 "}, {"content": "A2"}),
"<think>R1 R2 </think>A1 A2",
id="reasoning-and-answer-in-separate-chunks",
),
],
)
def test_merged_reasoning_content_closes_the_think_tag_before_the_answer(
gateway: Gateway, deltas: tuple[dict[str, JsonValue], ...], merged: str
) -> None:
with _vllm_server(_vllm_sse(deltas, "stop")) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(
model=f"hosted_vllm/{_VLLM_BACKEND}",
api_base=wire.url + "/v1",
api_key=_VLLM_KEY,
merge_reasoning_content_in_choices=True,
)
streamed: Final = _chat_stream(
gateway, {"model": model, "question": f"Hello {uuid.uuid4().hex}", "cache": _NO_CACHE}
)
assert _streamed_chats(wire) == [True]
assert (streamed.content, streamed.reasoning, streamed.finish_reason) == (merged, "", "stop")
_VLLM_TOOL_CALL: Final[dict[str, JsonValue]] = {
"tool_calls": [
{
"index": 0,
"id": "call_bridge_weather",
"type": "function",
"function": {"name": "get_weather", "arguments": json.dumps(_PARIS)},
}
]
}
@pytest.mark.parametrize(
"answer_delta, finish_reason, answer_item",
[
pytest.param({"content": "It is sunny."}, "stop", "message", id="text"),
pytest.param(_VLLM_TOOL_CALL, "tool_calls", "function_call", id="tool-call"),
],
)
def test_responses_stream_closes_the_reasoning_item_before_an_answer_from_the_same_chunk(
gateway: Gateway, answer_delta: dict[str, JsonValue], finish_reason: str, answer_item: str
) -> None:
marker: Final = uuid.uuid4().hex
reasoning: Final = f"Bridge reasoning {marker}."
reply: Final = _vllm_sse(({"reasoning_content": reasoning, **answer_delta},), finish_reason)
with _vllm_server(reply) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(
model=f"hosted_vllm/{_VLLM_BACKEND}",
api_base=wire.url + "/v1",
api_key=_VLLM_KEY,
use_chat_completions_api=True,
)
events: Final = _raw_responses_events(
gateway,
{
"model": model,
"input": f"Weather in Paris? {marker}",
"tools": [
{
"type": "function",
"name": "get_weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
}
],
},
)
lifecycle, summary, text = _responses_reasoning_and_answer(events)
assert _streamed_chats(wire) == [True]
assert lifecycle == [
("response.output_item.added", "reasoning", 0),
("response.output_item.done", "reasoning", 0),
("response.output_item.added", answer_item, 1),
("response.output_item.done", answer_item, 1),
], [event["type"] for event in events]
assert summary == reasoning
assert text == str(answer_delta.get("content", ""))