Repository navigation
test(e2e): cover /chat/completions on Azure OpenAI, streaming and non-streaming #33468
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
e7c3f7b
55f58cd
294e01f
187cab5
3156841
fdb3e1a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -121,6 +121,7 @@ class StreamingResponse(BaseModel): | |
| headers: dict[str, str] = {} | ||
| body: str | ||
| chunks: int = 0 # streamed events (0 for non-streaming) | ||
| events: tuple[str, ...] = () # SSE "data:" payloads, prefix stripped (streaming only) | ||
| # First in-stream error event, if any. A streamed call commits its HTTP 200 | ||
| # before the upstream completes, so upstream failures (e.g. insufficient | ||
| # quota) arrive as SSE error events inside an otherwise-successful response; | ||
|
|
@@ -293,28 +294,35 @@ def _streaming_outcome(resp: requests.Response, stream: bool) -> StreamingRespon | |
| headers=headers, | ||
| body=resp.text, | ||
| ) | ||
| lines = cast("Iterator[bytes]", resp.iter_lines()) | ||
| chunks = 0 | ||
| stream_error: str | None = None | ||
| for line in lines: | ||
| if not line: | ||
| continue | ||
| chunks += 1 | ||
| if stream_error is None and ( | ||
| line.startswith(b"event: error") | ||
| or b'"type":"error"' in line | ||
| or b'"type": "error"' in line | ||
| or line.startswith(b'data: {"error"') | ||
| ): | ||
| stream_error = line.decode(errors="replace")[:300] | ||
| raw_lines = tuple( | ||
| line.decode(errors="replace") | ||
| for line in cast("Iterator[bytes]", resp.iter_lines()) | ||
| if line | ||
| ) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The previous implementation iterated lazily; the new code materialises all decoded lines into a Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| stream_error = next( | ||
| ( | ||
| line[:300] | ||
| for line in raw_lines | ||
| if line.startswith("event: error") | ||
| or '"type":"error"' in line | ||
| or '"type": "error"' in line | ||
| or line.startswith('data: {"error"') | ||
| ), | ||
| None, | ||
| ) | ||
| return StreamingResponse( | ||
| status_code=resp.status_code, | ||
| call_id=call_id, | ||
| response_cost=response_cost, | ||
| content_type=content_type, | ||
| headers=headers, | ||
| body="<streamed>", | ||
| chunks=chunks, | ||
| chunks=len(raw_lines), | ||
| events=tuple( | ||
| line.removeprefix("data:").strip() | ||
| for line in raw_lines | ||
| if line.startswith("data:") | ||
| ), | ||
| stream_error=stream_error, | ||
| ) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,9 +4,11 @@ | |
| releases: a clean 200 came back but with no real completion. A status check | ||
| alone would not have caught it, so each case here asserts the product promise - | ||
| a non-empty assistant message and a real model name in the body - across the | ||
| three providers wired into the gateway config (OpenAI, Anthropic, Gemini). A | ||
| regression that empties the completion for any provider fails that provider's | ||
| row here. | ||
| providers wired into the gateway config (OpenAI, Anthropic, Gemini, Azure | ||
| OpenAI). A regression that empties the completion for any provider fails that | ||
| provider's row here. The Azure OpenAI streaming case applies the same standard | ||
| to the SSE path: every data event must parse as a chat.completion.chunk and the | ||
| deltas must reassemble into real text, not just count as a 200 with chunks. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
@@ -15,15 +17,18 @@ | |
|
|
||
| from e2e_config import unique_marker | ||
| from e2e_http import unwrap | ||
| from models import ChatBody, ChatMessage | ||
| from models import ChatBody, ChatMessage, ChatStreamChunk | ||
| from passthrough_client import PassthroughClient | ||
|
|
||
| pytestmark = pytest.mark.e2e | ||
|
|
||
| AZURE_CHAT_MODEL = "azure-gpt-5.4-mini" | ||
|
|
||
| CHAT_MODELS: tuple[tuple[str, str], ...] = ( | ||
| ("gpt-5.5", "openai"), | ||
| ("claude-haiku-4-5", "anthropic"), | ||
| ("gemini-2.5-flash", "gemini"), | ||
| (AZURE_CHAT_MODEL, "azure_openai"), | ||
| ) | ||
|
|
||
|
|
||
|
|
@@ -37,6 +42,7 @@ class TestChatCompletionsRegression: | |
| "llm.chat_completions.openai.basic.nonstream.works", | ||
| "llm.chat_completions.anthropic.basic.nonstream.works", | ||
| "llm.chat_completions.vertex.basic.nonstream.works", | ||
| "llm.chat_completions.azure_openai.basic.nonstream.works", | ||
| exercised_on=[], | ||
| ) | ||
| def test_chat_returns_real_completion( | ||
|
|
@@ -68,3 +74,62 @@ def test_chat_returns_real_completion( | |
| assert ( | ||
| message is not None and message.content and message.content.strip() | ||
| ), f"{model} ({route}): 200 with an empty completion (#28991): {response}" | ||
|
|
||
| @pytest.mark.covers("llm.chat_completions.azure_openai.basic.stream.works") | ||
| def test_azure_openai_stream_returns_real_completion( | ||
| self, client: PassthroughClient, scoped_key: str | ||
| ) -> None: | ||
| result = client.gateway.chat_stream( | ||
| scoped_key, | ||
| ChatBody( | ||
| model=AZURE_CHAT_MODEL, | ||
| messages=[ | ||
| ChatMessage( | ||
| role="user", | ||
| content=f"reply with one word {unique_marker()}", | ||
| ) | ||
| ], | ||
| max_tokens=512, | ||
| stream=True, | ||
| ), | ||
| ) | ||
|
|
||
| assert result.ok, ( | ||
| f"{AZURE_CHAT_MODEL}: stream failed with status " | ||
| f"{result.status_code}: {result.body[:300]}" | ||
| ) | ||
| assert result.is_streaming, ( | ||
| f"{AZURE_CHAT_MODEL}: expected text/event-stream, got " | ||
| f"{result.content_type}: {result.body[:300]}" | ||
| ) | ||
| assert result.stream_error is None, ( | ||
| f"{AZURE_CHAT_MODEL}: 200 stream carried an error event: " | ||
| f"{result.stream_error}" | ||
| ) | ||
| assert result.events, f"{AZURE_CHAT_MODEL}: stream carried no SSE data events" | ||
| assert result.events[-1] == "[DONE]", ( | ||
| f"{AZURE_CHAT_MODEL}: stream did not terminate with [DONE]: " | ||
| f"{result.events[-1][:200]}" | ||
| ) | ||
|
|
||
| chunks = [ | ||
| ChatStreamChunk.model_validate_json(event) for event in result.events[:-1] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If any |
||
| ] | ||
| assert chunks, f"{AZURE_CHAT_MODEL}: stream held only the [DONE] sentinel" | ||
| assert all( | ||
| chunk.object == "chat.completion.chunk" for chunk in chunks | ||
| ), f"{AZURE_CHAT_MODEL}: malformed chunk object types: {result.events[:5]}" | ||
| assert any( | ||
| chunk.model for chunk in chunks | ||
| ), f"{AZURE_CHAT_MODEL}: no chunk carried a model name: {result.events[:5]}" | ||
|
|
||
| content = "".join( | ||
| choice.delta.content or "" | ||
| for chunk in chunks | ||
| for choice in chunk.choices | ||
| if choice.delta is not None | ||
| ) | ||
| assert content.strip(), ( | ||
| f"{AZURE_CHAT_MODEL}: stream chunks reassembled to an empty " | ||
| f"completion (#28991): {result.events[:5]}" | ||
| ) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
api_version. LiteLLM's Azure provider falls back to an internal default when none is set; if the team'sgpt-5.4-minideployment requires a newer preview API version (e.g.2025-01-01-preview), CI runs on other machines will silently getapi_versionmismatch errors that look like model-not-found failures rather than a config problem. Adding it explicitly here makes the requirement visible and matches the QA runbook's intent of a fully reproducible environment.