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
715 lines
27 KiB
Python
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
|