Files
microsoft--agent-lightning/tests/llm_proxy/test_stream.py
T
wehub-resource-sync 85742ab165
Deploy Documentation / deploy (push) Has been cancelled
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Others, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, latest, Python 3.13) (push) Has been cancelled
Dashboard / Chromatic (push) Has been cancelled
CPU Test / Lint - fast (push) Has been cancelled
CPU Test / Lint - next (push) Has been cancelled
CPU Test / Lint - slow (push) Has been cancelled
CPU Test / Lint - JavaScript (push) Has been cancelled
CPU Test / Build documentation (push) Has been cancelled
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Others, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Store, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (Weave, legacy, Python 3.10) (push) Has been cancelled
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Has been cancelled
CPU Test / Test (Store, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Utilities, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (Weave, stable, Python 3.12) (push) Has been cancelled
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Others, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Store, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (Utilities, latest, Python 3.13) (push) Has been cancelled
CPU Test / Test (JavaScript) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:44:17 +08:00

715 lines
27 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import json
from typing import Any, AsyncGenerator, Dict, Iterator, List, Optional, cast
import pytest
from agentlightning.llm_proxy import StreamConversionMiddleware
def merge_openai_streaming(chunks: Iterator[Dict[str, Any]]) -> Dict[str, Any]:
"""
Merge chunks from OpenAI chat completion streaming into a single message dict.
Returns a dict with keys:
- role: "assistant" (or whatever)
- content: full concatenated content string
- function_call: optional dict with keys name, arguments (string or JSON parsed)
"""
role: Optional[str] = None
content_parts: List[str] = []
function_name: Optional[str] = None
function_args_str: Optional[str] = None
for chunk in chunks:
choice = chunk.get("choices", [])[0]
delta = choice.get("delta", {})
if "role" in delta and delta["role"] is not None:
role = delta["role"]
if "content" in delta and delta["content"] is not None:
content_parts.append(delta["content"])
# existing format: function_call
if "function_call" in delta and delta["function_call"] is not None:
fn = delta["function_call"]
if function_name is None:
function_name = fn.get("name")
function_args_str = fn.get("arguments", "")
else:
function_args_str += fn.get("arguments", "")
# new format: tool_calls array
if "tool_calls" in delta and delta["tool_calls"]:
for tc in delta["tool_calls"]:
func = tc.get("function", {})
# set name if first time
if function_name is None and func.get("name"):
function_name = func["name"]
# accumulate arguments
if func.get("arguments") is not None:
if function_args_str is None:
function_args_str = func["arguments"]
else:
function_args_str += func["arguments"]
full_content = "".join(content_parts)
result: Dict[str, Any] = {"role": role or "assistant", "content": full_content}
if function_name is not None:
try:
function_args = json.loads(function_args_str or "") # type: ignore
except Exception:
function_args = function_args_str
result["function_call"] = {"name": function_name, "arguments": function_args}
return result
def merge_anthropic_streaming(chunks: Iterator[Dict[str, Any]]) -> Dict[str, Any]:
"""
Merge chunks from Anthropic streaming into a single message dict.
Returns a dict with keys:
- role: "assistant"
- content_text: full content text (concatenated)
- tool_calls: list of dicts { name, input } if any
"""
role: Optional[str] = None
content_text_parts: List[str] = []
tool_calls: List[Dict[str, Any]] = []
current_tool: Optional[Dict[str, Any]] = None
current_tool_input_str: Optional[str] = None
for chunk in chunks:
# role
if role is None and "role" in chunk:
role = chunk["role"]
# handle content_block style (fine-grained)
typ = chunk.get("type")
if typ == "content_block_start":
block = chunk.get("content_block", {})
if block.get("type") == "tool_use":
# finish previous tool if exists
if current_tool is not None:
try:
input_obj = json.loads(current_tool_input_str or "")
except Exception:
input_obj = current_tool_input_str
current_tool["input"] = input_obj
tool_calls.append(current_tool)
current_tool = {"name": block.get("name"), "id": block.get("id"), "input": None}
current_tool_input_str = ""
continue
if typ == "content_block_delta":
delta = chunk.get("delta", {})
dtyp = delta.get("type")
if dtyp == "input_json_delta":
current_tool_input_str = (current_tool_input_str or "") + delta.get("partial_json", "")
elif dtyp == "text_delta":
content_text_parts.append(delta.get("text", ""))
continue
if typ == "content_block_stop":
if current_tool is not None:
try:
input_obj = json.loads(current_tool_input_str or "")
except Exception:
input_obj = current_tool_input_str
current_tool["input"] = input_obj
tool_calls.append(current_tool)
current_tool = None
current_tool_input_str = None
continue
# handle normal content items
content_items = chunk.get("content", [])
for item in content_items:
t = item.get("type")
if t == "text":
content_text_parts.append(item.get("text", ""))
elif t == "tool_use":
tool_id = item.get("id")
name = item.get("name")
inp = item.get("input", {})
if current_tool and current_tool.get("id") == tool_id:
# merge into same tool
try:
existing = json.loads(current_tool_input_str or "{}")
except Exception:
existing: Dict[str, Any] = {}
if isinstance(existing, dict): # type: ignore
existing.update(inp)
current_tool_input_str = json.dumps(existing)
else:
# fallback: treat as string concatenation
current_tool_input_str += json.dumps(inp)
else:
# finish previous tool
if current_tool is not None:
try:
input_obj = json.loads(current_tool_input_str or "")
except Exception:
input_obj = current_tool_input_str
current_tool["input"] = input_obj
tool_calls.append(current_tool)
current_tool = {"name": name, "id": tool_id, "input": None}
current_tool_input_str = json.dumps(inp)
# else: ignore
# end loop
# finish any open tool
if current_tool is not None:
try:
input_obj = json.loads(current_tool_input_str or "")
except Exception:
input_obj = current_tool_input_str
current_tool["input"] = input_obj
tool_calls.append(current_tool)
full_text = "".join(content_text_parts)
result: Dict[str, Any] = {"role": role or "assistant", "content_text": full_text}
if tool_calls:
result["tool_calls"] = tool_calls
return result
def test_openai_text_only_short():
chunks = iter(
cast(
List[Dict[str, Any]],
[
{"choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}]},
{"choices": [{"index": 0, "delta": {"content": "Hello"}, "finish_reason": None}]},
{"choices": [{"index": 0, "delta": {"content": " world!"}, "finish_reason": None}]},
{"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]},
],
)
)
merged = merge_openai_streaming(chunks)
assert merged["role"] == "assistant"
assert merged["content"] == "Hello world!"
assert "function_call" not in merged
def test_openai_text_and_function_call_arguments_split():
# Mixed content + function_call arguments spread over multiple deltas
chunks = iter(
cast(
List[Dict[str, Any]],
[
{"choices": [{"index": 0, "delta": {"role": "assistant"}}]},
{"choices": [{"index": 0, "delta": {"content": "Starting… "}}]},
{
"choices": [
{"index": 0, "delta": {"function_call": {"name": "get_weather", "arguments": '{"city": "'}}}
]
},
{"choices": [{"index": 0, "delta": {"function_call": {"arguments": 'Singapore", "unit": "'}}}]},
{"choices": [{"index": 0, "delta": {"function_call": {"arguments": 'celsius"}'}}}]},
{"choices": [{"index": 0, "delta": {"content": "done."}}]},
{"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}]},
],
)
)
merged = merge_openai_streaming(chunks)
assert merged["content"] == "Starting… done."
assert merged["function_call"]["name"] == "get_weather"
assert merged["function_call"]["arguments"] == {"city": "Singapore", "unit": "celsius"}
def test_openai_tool_calls_via_tool_calls_field():
# Newer shape: delta.tool_calls with function.name/arguments segments
chunks = iter(
cast(
List[Dict[str, Any]],
[
{"choices": [{"index": 0, "delta": {"role": "assistant"}}]},
{
"choices": [
{
"index": 0,
"delta": {
"tool_calls": [
{"index": 0, "id": "call_1", "type": "function", "function": {"name": "search"}}
]
},
}
]
},
{
"choices": [
{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": '{"q": "'}}]}}
]
},
{
"choices": [
{
"index": 0,
"delta": {"tool_calls": [{"index": 0, "function": {"arguments": 'python streaming"}'}}]},
}
]
},
{"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}]},
],
)
)
merged = merge_openai_streaming(chunks)
assert merged["function_call"]["name"] == "search"
assert merged["function_call"]["arguments"] == {"q": "python streaming"}
def test_openai_invalid_json_arguments_falls_back_to_string():
chunks = iter(
cast(
List[Dict[str, Any]],
[
{"choices": [{"index": 0, "delta": {"role": "assistant"}}]},
{
"choices": [{"index": 0, "delta": {"function_call": {"name": "do", "arguments": '{"bad": '}}}]
}, # truncated
{"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}]},
],
)
)
merged = merge_openai_streaming(chunks)
assert merged["function_call"]["name"] == "do"
# Should be raw string because JSON parsing fails
assert isinstance(merged["function_call"]["arguments"], str)
assert merged["function_call"]["arguments"].startswith('{"bad": ')
def test_anthropic_text_only_multiple_blocks():
chunks = iter(
cast(
List[Dict[str, Any]],
[
{"role": "assistant", "content": [{"type": "text", "text": "Hello "}]},
{"content": [{"type": "text", "text": "world!"}]},
{"type": "message_delta", "delta": {"stop_reason": "end_turn"}},
],
)
)
merged = merge_anthropic_streaming(chunks)
assert merged["role"] == "assistant"
assert merged["content_text"] == "Hello world!"
assert "tool_calls" not in merged
def test_anthropic_tool_use_split_inputs_merge():
# Tool input is delivered as multiple content fragments that should be merged
chunks = iter(
[
{"role": "assistant", "content": [{"type": "text", "text": "Working… "}]},
{"content": [{"type": "tool_use", "id": "toolu_1", "name": "calculate", "input": {"a": 1}}]},
{"content": [{"type": "tool_use", "id": "toolu_1", "name": "calculate", "input": {"b": 2}}]},
{"content": [{"type": "text", "text": "done."}]},
{"type": "message_stop"},
]
)
merged = merge_anthropic_streaming(chunks)
assert merged["content_text"] == "Working… done."
assert merged["tool_calls"][0]["name"] == "calculate"
assert merged["tool_calls"][0]["input"] == {"a": 1, "b": 2}
def test_anthropic_fine_grained_input_json_delta():
# Simulate SSE-style events: content_block_start(tool_use) + multiple input_json_delta pieces
chunks = iter(
[
{
"type": "content_block_start",
"index": 1,
"content_block": {"type": "tool_use", "id": "toolu_x", "name": "fetch"},
},
{
"type": "content_block_delta",
"index": 1,
"delta": {"type": "input_json_delta", "partial_json": '{"url": "'},
"active_tool_id": "toolu_x",
},
{
"type": "content_block_delta",
"index": 1,
"delta": {"type": "input_json_delta", "partial_json": 'https://example.com"}'},
"active_tool_id": "toolu_x",
},
{"type": "content_block_stop", "index": 1},
{"type": "message_stop"},
]
)
merged = merge_anthropic_streaming(chunks)
[tool] = merged["tool_calls"]
assert tool["id"] == "toolu_x"
assert tool["name"] == "fetch"
assert tool["input"] == {"url": "https://example.com"}
def test_anthropic_text_and_tool_interleaved_with_text_deltas():
# Mix text via text_delta and plain text content items
chunks = iter(
[
{"role": "assistant", "content": [{"type": "text", "text": "Start "}]},
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "middle "}},
{"content": [{"type": "text", "text": "end."}]},
{"type": "message_stop"},
]
)
merged = merge_anthropic_streaming(chunks)
assert merged["content_text"] == "Start middle end."
def test_anthropic_partial_json_left_as_string_when_invalid():
# Provide malformed JSON parts; merger should keep raw string for tool input
chunks = iter(
[
{
"type": "content_block_start",
"index": 2,
"content_block": {"type": "tool_use", "id": "toolu_bad", "name": "ingest"},
},
{
"type": "content_block_delta",
"index": 2,
"delta": {"type": "input_json_delta", "partial_json": '{"alpha": 1, '},
"active_tool_id": "toolu_bad",
},
{
"type": "content_block_delta",
"index": 2,
"delta": {"type": "input_json_delta", "partial_json": '"beta": 2'},
"active_tool_id": "toolu_bad",
},
# missing closing brace
{"type": "content_block_stop", "index": 2},
{"type": "message_stop"},
]
)
merged = merge_anthropic_streaming(chunks)
[tool] = merged["tool_calls"]
assert tool["id"] == "toolu_bad"
assert isinstance(tool["input"], str)
assert tool["input"].startswith('{"alpha": 1, ')
@pytest.mark.parametrize("text_len", [1, 50, 500])
def test_openai_long_text_stream_rounds_up(text_len: int):
# Create a synthetic long content split into ~20-40 char pieces as the merger would see
text = "x" * text_len
# Simulate content arriving in three chunks
part1, part2, part3 = text[: text_len // 3], text[text_len // 3 : 2 * text_len // 3], text[2 * text_len // 3 :]
chunks = iter(
cast(
List[Dict[str, Any]],
[
{"choices": [{"index": 0, "delta": {"role": "assistant"}}]},
{"choices": [{"index": 0, "delta": {"content": part1}}]},
{"choices": [{"index": 0, "delta": {"content": part2}}]},
{"choices": [{"index": 0, "delta": {"content": part3}}]},
{"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]},
],
)
)
merged = merge_openai_streaming(chunks)
assert merged["content"] == text
async def collect_sse(gen: AsyncGenerator[str, Any]) -> List[str]:
"""Drain an async generator of SSE strings into a list."""
out: List[str] = []
async for s in gen:
assert isinstance(s, str)
out.append(s)
return out
def parse_openai_sse_to_json_events(sse_chunks: List[str]) -> List[Dict[str, Any]]:
"""From the OpenAI stream (which uses only 'data:' lines), return JSON events.
Filters out the literal DONE sentinel.
"""
events: List[Dict[str, Any]] = []
for chunk in sse_chunks:
# each chunk looks like 'data: {...}\n\n' OR 'data: [DONE]\n\n'
for line in chunk.splitlines():
line = line.strip()
if not line.startswith("data:"):
continue
payload = line[len("data:") :].strip()
if payload == "[DONE]":
continue
events.append(json.loads(payload))
return events
def parse_anthropic_sse_to_json_payloads(sse_chunks: List[str]) -> List[Dict[str, Any]]:
"""Extract the JSON payload from each Anthropic SSE event (ignore pings)."""
out: List[Dict[str, Any]] = []
for chunk in sse_chunks:
# chunks look like 'event: <name>\ndata: {json}\n\n'
if "data:" not in chunk:
continue
data_line = [ln for ln in chunk.splitlines() if ln.startswith("data:")]
if not data_line:
continue
payload = data_line[0][len("data:") :].strip()
obj = json.loads(payload)
if obj.get("type") == "ping":
continue
out.append(obj)
return out
@pytest.fixture
def mw() -> StreamConversionMiddleware:
# BaseHTTPMiddleware requires an ASGI app; we only need the instance for bound methods.
class _DummyApp:
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
pass
return StreamConversionMiddleware(_DummyApp())
@pytest.mark.asyncio
@pytest.mark.parametrize(
"text, finish_reason",
[
("Hello world.", "stop"),
("This answer was cut off on purpose.", "length"),
],
)
async def test_openai_content_only_stream_roundtrip(mw: StreamConversionMiddleware, text: str, finish_reason: str):
response_json = {
"id": "chatcmpl-test",
"object": "chat.completion",
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": text},
"finish_reason": finish_reason,
# include logprobs to ensure it doesn't interfere with streaming
"logprobs": None,
}
],
}
sse_chunks = await collect_sse(mw.openai_stream_generator(response_json))
# basic shape checks
assert any('"delta": {"role": ' in s for s in sse_chunks)
assert any("[DONE]" in s for s in sse_chunks)
events = parse_openai_sse_to_json_events(sse_chunks)
assert events, "Expected JSON events from stream"
# the last JSON event before [DONE] should contain the finish_reason
last = events[-1]
assert last["choices"][0]["finish_reason"] == finish_reason
merged = merge_openai_streaming(iter(events))
assert merged["role"] == "assistant"
assert merged["content"] == text
assert "function_call" not in merged
@pytest.mark.asyncio
async def test_openai_long_text_chunking_and_reassembly(mw: StreamConversionMiddleware):
long_text = """
This is a deliberately long sentence that should be broken into multiple streaming deltas by the
chunking logic so that we can verify reassembly yields the exact same content without loss. """.strip()
response_json = {
"id": "chatcmpl-long",
"object": "chat.completion",
"model": "gpt-4o-mini",
"choices": [{"index": 0, "message": {"role": "assistant", "content": long_text}, "finish_reason": "stop"}],
}
sse_chunks = await collect_sse(mw.openai_stream_generator(response_json))
events = parse_openai_sse_to_json_events(sse_chunks)
# ensure multiple content delta chunks were emitted
content_deltas = [ev for ev in events if ev["choices"][0]["delta"].get("content")]
assert len(content_deltas) > 1
merged = merge_openai_streaming(iter(events))
assert merged["content"] == long_text
@pytest.mark.asyncio
async def test_openai_tool_call_only_stream_roundtrip(mw: StreamConversionMiddleware):
response_json = {
"id": "chatcmpl-tool",
"object": "chat.completion",
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": json.dumps({"location": "Boston"}),
},
}
],
},
"finish_reason": "tool_calls",
}
],
}
sse_chunks = await collect_sse(mw.openai_stream_generator(response_json))
events = parse_openai_sse_to_json_events(sse_chunks)
# expect at least one tool_calls delta with name, followed by deltas with arguments
assert any(
(tc := ev["choices"][0]["delta"].get("tool_calls")) and tc[0].get("function", {}).get("name") == "get_weather"
for ev in events
)
assert any(
(tc := ev["choices"][0]["delta"].get("tool_calls")) and "arguments" in tc[0].get("function", {})
for ev in events
)
merged = merge_openai_streaming(iter(events))
assert merged["function_call"]["name"] == "get_weather"
assert merged["function_call"]["arguments"] == {"location": "Boston"}
@pytest.mark.asyncio
async def test_openai_content_and_tool_call_stream_roundtrip(mw: StreamConversionMiddleware):
response_json = {
"id": "chatcmpl-mixed",
"object": "chat.completion",
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "I'll call the weather tool now...",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": json.dumps({"location": "Singapore", "units": "metric"}),
},
}
],
},
"finish_reason": "tool_calls",
}
],
}
sse_chunks = await collect_sse(mw.openai_stream_generator(response_json))
events = parse_openai_sse_to_json_events(sse_chunks)
merged = merge_openai_streaming(iter(events))
assert merged["content"].startswith("I'll call the weather tool")
assert merged["function_call"]["name"] == "get_weather"
assert merged["function_call"]["arguments"] == {"location": "Singapore", "units": "metric"}
@pytest.mark.asyncio
async def test_anthropic_text_only_stream_roundtrip(mw: StreamConversionMiddleware):
original_response = {
"id": "msg_123",
"model": "claude-3.5-sonnet",
"content": [
{"type": "text", "text": "Hello there from Claude."},
],
"usage": {"input_tokens": 0, "output_tokens": 7},
"stop_reason": "end_turn",
}
sse_chunks = await collect_sse(mw.anthropic_stream_generator(original_response))
# sanity: stream contains lifecycle events
assert any("event: message_start" in s for s in sse_chunks)
assert any("event: message_stop" in s for s in sse_chunks)
payloads = parse_anthropic_sse_to_json_payloads(sse_chunks)
merged = merge_anthropic_streaming(iter(payloads))
assert merged["role"] == "assistant"
assert merged["content_text"] == "Hello there from Claude."
assert "tool_calls" not in merged
@pytest.mark.asyncio
async def test_anthropic_tool_use_only_stream_roundtrip(mw: StreamConversionMiddleware):
original_response = {
"id": "msg_tool",
"model": "claude-3.5-sonnet",
"content": [
{
"type": "tool_use",
"id": "toolu_1",
"name": "get_weather",
"input": {"location": "Boston"},
}
],
"usage": {"input_tokens": 0, "output_tokens": 0},
"stop_reason": "end_turn",
}
sse_chunks = await collect_sse(mw.anthropic_stream_generator(original_response))
payloads = parse_anthropic_sse_to_json_payloads(sse_chunks)
merged = merge_anthropic_streaming(iter(payloads))
assert merged["tool_calls"][0]["name"] == "get_weather"
assert merged["tool_calls"][0]["id"] == "toolu_1"
assert merged["tool_calls"][0]["input"] == {"location": "Boston"}
@pytest.mark.asyncio
async def test_anthropic_mixed_text_and_tool_use_roundtrip(mw: StreamConversionMiddleware):
# tool input is long to ensure multiple input_json_delta chunks
long_input = {
"location": "Singapore",
"units": "metric",
"details": {"hourly": True, "with_forecast": True, "days": 5},
}
original_response = {
"id": "msg_mixed",
"model": "claude-3.5-sonnet",
"content": [
{"type": "text", "text": "I'll check the weather tool for you."},
{"type": "tool_use", "id": "toolu_2", "name": "get_weather", "input": long_input},
],
"usage": {"input_tokens": 0, "output_tokens": 0},
"stop_reason": "end_turn",
}
sse_chunks = await collect_sse(mw.anthropic_stream_generator(original_response))
payloads = parse_anthropic_sse_to_json_payloads(sse_chunks)
# Verify we saw content_block_start/stop and deltas for both text and tool input
types = [p.get("type") for p in payloads]
assert "content_block_start" in types
assert "content_block_delta" in types
assert "content_block_stop" in types
assert any(p.get("delta", {}).get("type") == "text_delta" for p in payloads)
assert any(p.get("delta", {}).get("type") == "input_json_delta" for p in payloads)
merged = merge_anthropic_streaming(iter(payloads))
assert merged["content_text"].startswith("I'll check the weather tool")
tool = merged["tool_calls"][0]
assert tool["name"] == "get_weather"
assert tool["id"] == "toolu_2"
assert tool["input"] == long_input