From 0f287b57d21002c94acb6bb6993b2d743f45fbfa Mon Sep 17 00:00:00 2001 From: frigazzz Date: Tue, 11 Aug 2026 13:53:28 +0200 Subject: [PATCH] fix: stream Azure Responses function-call arguments --- .../azure_responses_streaming/README.md | 114 +++++++++++ .../azure_responses_streaming/__init__.py | 15 ++ .../models/azure_responses_streaming/agent.py | 172 ++++++++++++++++ .../models/azure_responses_streaming/run.py | 94 +++++++++ .../adk/labs/openai/_openai_responses_llm.py | 183 ++++++++++++++++-- .../labs/openai/test_openai_responses_llm.py | 110 ++++++++++- 6 files changed, 665 insertions(+), 23 deletions(-) create mode 100644 contributing/samples/models/azure_responses_streaming/README.md create mode 100644 contributing/samples/models/azure_responses_streaming/__init__.py create mode 100644 contributing/samples/models/azure_responses_streaming/agent.py create mode 100644 contributing/samples/models/azure_responses_streaming/run.py diff --git a/contributing/samples/models/azure_responses_streaming/README.md b/contributing/samples/models/azure_responses_streaming/README.md new file mode 100644 index 00000000000..b243eea5854 --- /dev/null +++ b/contributing/samples/models/azure_responses_streaming/README.md @@ -0,0 +1,114 @@ +# Azure Responses Partial Function-Call Streaming + +## Overview + +This sample provides a small document-writing agent for testing streamed +function-call arguments. Azure OpenAI Responses is the default provider. The +`create_document` tool has a nested, deliberately detailed Pydantic input +schema, so the model sends enough JSON for partial function-call events to be +visible in the Dev UI and in `run.py`. + +The tool writes the generated document to `generated_docs/` inside this sample +directory. It sanitizes the requested filename to keep the example local to +that output directory. + +## Setup + +Install the OpenAI Responses extra from the repository root: + +```bash +uv sync --extra extensions +``` + +Configure Azure. `AZURE_OPENAI_ENDPOINT` is optional when +`AZURE_RESOURCE_NAME` is set; the sample derives the standard Azure endpoint +from the resource name. + +```bash +export AZURE_API_KEY="your-azure-api-key" +export AZURE_RESOURCE_NAME="your-azure-resource-name" +export AZURE_MODEL_DEPLOYMENT="your-model-deployment" +``` + +For a non-standard endpoint, set it explicitly: + +```bash +export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com" +``` + +Do not commit API keys or `.env` files. + +## Run With Dev UI + +The Dev UI discovers `agent.py` from the sample directory. Enable streaming +in the UI, then ask the agent to create a document. + +```bash +uv run --extra extensions adk web contributing/samples/models/azure_responses_streaming +``` + +Open the URL printed by `adk web`, select the sample agent, and send: + +`Create a detailed onboarding guide for backend engineers with four sections, references, and a rollout checklist.` + +The UI should show partial function-call content before the final tool call, +followed by the tool result and the generated Markdown path. + +## Run `run.py` + +`run.py` uses the same `agent.py`, forces `StreamingMode.SSE`, and prints each +text event and function-call delta. Run it from the repository root: + +```bash +uv run --extra extensions python contributing/samples/models/azure_responses_streaming/run.py +``` + +You can provide a custom prompt: + +```bash +uv run --extra extensions python contributing/samples/models/azure_responses_streaming/run.py \ + Create a security review document with threat model, controls, testing, and remediation sections. +``` + +Look for lines such as: + +```text +[function_call] partial=True ... delta='{"filename": ...' +[function_call] partial=True ... delta='...' +[function_call] partial=False ... args={...} +``` + +## Sample Inputs + +- `Create a technical design brief for a document streaming feature with architecture, API contract, rollout, and testing sections.` + +- `Create a detailed onboarding guide for backend engineers with four sections, references, and a rollout checklist.` + +- `Create a security review document with a threat model, controls, testing, and remediation sections.` + +## Graph + +```mermaid +graph TD + DocumentAgent[azure_responses_streaming_agent] -->|calls| CreateDocument[create_document] +``` + +## How To + +- `agent.py` builds the Azure Responses model lazily, so the optional OpenAI + dependency is only imported when the sample starts. +- `DocumentRequest` and `DocumentSection` provide a nested tool schema. Ask for + multiple detailed sections to make raw argument fragments easy to observe. +- `run.py` enables `StreamingMode.SSE` and prints `partial_args` separately + from the final parsed `FunctionCall.args`. +- The Dev UI uses the same `agent.py`; its streaming toggle controls the + request path, while `run.py` is a deterministic terminal harness. + +## Related Guides + +- [Function tools sample](../../tools/function_tools/README.md) - Register + typed Python functions as agent tools. +- [LLM agent single-turn mode](../../../../docs/guides/agents/llm_agent/single_turn.md) - + Configure a basic LLM agent. +- [Event guide](../../../../docs/guides/events/event/index.md) - Inspect the + events emitted by an agent run. diff --git a/contributing/samples/models/azure_responses_streaming/__init__.py b/contributing/samples/models/azure_responses_streaming/__init__.py new file mode 100644 index 00000000000..4015e47d6e4 --- /dev/null +++ b/contributing/samples/models/azure_responses_streaming/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import agent diff --git a/contributing/samples/models/azure_responses_streaming/agent.py b/contributing/samples/models/azure_responses_streaming/agent.py new file mode 100644 index 00000000000..e1af5306de1 --- /dev/null +++ b/contributing/samples/models/azure_responses_streaming/agent.py @@ -0,0 +1,172 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Agent used to exercise streamed function-call arguments.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from google.adk import Agent +from google.adk.models.base_llm import BaseLlm +from pydantic import BaseModel +from pydantic import Field + + +class DocumentSection(BaseModel): + """A section in the document generated by the tool.""" + + heading: str + purpose: str + key_points: list[str] + body: str + references: list[str] = Field(default_factory=list) + + +class DocumentRequest(BaseModel): + """Structured input deliberately large enough to make streaming visible.""" + + filename: str + title: str + executive_summary: str + audience: str + language: str + tone: str + keywords: list[str] + sections: list[DocumentSection] + include_table_of_contents: bool = True + footer: str = "Generated by the ADK streaming function-call sample." + + +def _output_directory() -> Path: + configured_directory = Path( + os.getenv("DOCUMENT_OUTPUT_DIR", "generated_docs") + ) + if not configured_directory.is_absolute(): + configured_directory = Path(__file__).parent / configured_directory + configured_directory.mkdir(parents=True, exist_ok=True) + return configured_directory + + +def create_document(document: DocumentRequest) -> dict[str, str | int]: + """Create a Markdown document from a structured request. + + Args: + document: A complete document specification. Include several sections and + detailed key points so the model has a sizable function-call payload to + stream. + + Returns: + The path and basic metadata for the generated Markdown file. + """ + filename = Path(document.filename).name + if not filename or filename in {".", ".."}: + filename = "generated_document.md" + if not filename.lower().endswith(".md"): + filename += ".md" + + lines = [ + f"# {document.title}", + "", + f"**Audience:** {document.audience}", + f"**Language:** {document.language}", + f"**Tone:** {document.tone}", + "", + "## Executive Summary", + "", + document.executive_summary, + "", + ] + + if document.keywords: + lines.extend(["**Keywords:** " + ", ".join(document.keywords), ""]) + + if document.include_table_of_contents: + lines.extend(["## Table of Contents", ""]) + lines.extend(f"- {section.heading}" for section in document.sections) + lines.append("") + + for section in document.sections: + lines.extend([ + f"## {section.heading}", + "", + f"**Purpose:** {section.purpose}", + "", + section.body, + "", + "### Key Points", + "", + ]) + lines.extend(f"- {point}" for point in section.key_points) + if section.references: + lines.extend(["", "### References", ""]) + lines.extend(f"- {reference}" for reference in section.references) + lines.append("") + + lines.extend(["---", "", document.footer, ""]) + output_path = _output_directory() / filename + output_path.write_text("\n".join(lines), encoding="utf-8") + return { + "status": "created", + "path": str(output_path), + "section_count": len(document.sections), + "byte_count": output_path.stat().st_size, + } + + +def _required_env(name: str) -> str: + value = os.getenv(name) + if not value: + raise RuntimeError( + f"Set {name} before starting the sample. See README.md for setup." + ) + return value + + +def _build_model() -> BaseLlm: + """Build the Azure Responses model used by this sample.""" + from google.adk.labs.openai import AzureOpenAIResponsesLlm + + endpoint = os.getenv("AZURE_OPENAI_ENDPOINT") + if not endpoint: + resource_name = _required_env("AZURE_RESOURCE_NAME") + endpoint = f"https://{resource_name}.openai.azure.com" + return AzureOpenAIResponsesLlm( + model=os.getenv("AZURE_MODEL_DEPLOYMENT", "gpt-4o"), + api_key=_required_env("AZURE_API_KEY"), + azure_endpoint=endpoint, + include_response_metadata=True, + ) + + +root_agent = Agent( + name="azure_responses_streaming_agent", + model=_build_model(), + description=( + "Creates Markdown documents while exposing streamed function-call " + "arguments." + ), + instruction=( + "You are a document planning assistant. When the user asks you to " + "create, draft, or write a document, you MUST call create_document. " + "Do not write the full document only in your answer. Build a rich " + "DocumentRequest with a safe Markdown filename, a clear title, an " + "executive summary, audience, language, tone, keywords, and three to " + "six detailed sections. Each section must contain a purpose, body, " + "multiple key points, and references when useful. After the tool " + "returns, tell the user where the file was written." + ), + tools=[create_document], +) diff --git a/contributing/samples/models/azure_responses_streaming/run.py b/contributing/samples/models/azure_responses_streaming/run.py new file mode 100644 index 00000000000..525b4b52ed3 --- /dev/null +++ b/contributing/samples/models/azure_responses_streaming/run.py @@ -0,0 +1,94 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Run the sample in a terminal and print streamed function-call deltas.""" + +from __future__ import annotations + +import argparse +import asyncio + +from google.adk.agents.run_config import RunConfig +from google.adk.agents.run_config import StreamingMode +from google.adk.runners import InMemoryRunner +from google.genai import types + +try: + from .agent import root_agent +except ImportError: + from agent import root_agent + + +APP_NAME = "azure_responses_streaming_sample" +USER_ID = "streaming tester" +DEFAULT_PROMPT = ( + "Create a technical design brief for a document streaming feature. " + "Target backend engineers, use a precise but approachable tone, and " + "include architecture, API contract, rollout, and testing sections." +) + + +def _print_event(event: object) -> None: + content = getattr(event, "content", None) + if not content: + return + for part in content.parts or []: + function_call = getattr(part, "function_call", None) + if function_call: + delta = "".join( + partial_arg.string_value or "" + for partial_arg in function_call.partial_args or [] + ) + print( + "[function_call] " + f"partial={getattr(event, 'partial', None)!r} " + f"id={function_call.id!r} name={function_call.name!r} " + f"delta={delta!r} args={function_call.args!r}" + ) + elif part.text: + print(f"[text] partial={getattr(event, 'partial', None)!r} {part.text}") + + +async def _run(prompt: str) -> None: + runner = InMemoryRunner(agent=root_agent, app_name=APP_NAME) + session = await runner.session_service.create_session( + app_name=APP_NAME, + user_id=USER_ID, + ) + content = types.Content( + role="user", + parts=[types.Part.from_text(text=prompt)], + ) + async for event in runner.run_async( + user_id=USER_ID, + session_id=session.id, + new_message=content, + run_config=RunConfig(streaming_mode=StreamingMode.SSE), + ): + _print_event(event) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "prompt", + nargs="*", + help="Prompt to send; the sample prompt is used when omitted.", + ) + args = parser.parse_args() + asyncio.run(_run(" ".join(args.prompt) or DEFAULT_PROMPT)) + + +if __name__ == "__main__": + main() diff --git a/src/google/adk/labs/openai/_openai_responses_llm.py b/src/google/adk/labs/openai/_openai_responses_llm.py index 371cca9b620..97b40589037 100644 --- a/src/google/adk/labs/openai/_openai_responses_llm.py +++ b/src/google/adk/labs/openai/_openai_responses_llm.py @@ -644,14 +644,30 @@ def _reasoning_parts( def _function_call_part( item: ResponseFunctionToolCall | Mapping[str, Any], -) -> types.Part: +) -> types.Part | None: name = _get_value(item, 'name') if not name: logger.warning('OpenAI Responses function call is missing a name.') + return None arguments = _get_value(item, 'arguments') + if arguments: + try: + parsed_arguments = json.loads(arguments) + except json.JSONDecodeError: + logger.warning( + 'Dropping OpenAI Responses function call with invalid arguments.' + ) + return None + if not isinstance(parsed_arguments, dict): + logger.warning( + 'Dropping OpenAI Responses function call with non-object arguments.' + ) + return None + else: + parsed_arguments = {} part = types.Part.from_function_call( name=name or '', - args=_loads_json_object(arguments), + args=parsed_arguments, ) part.function_call.id = _get_value(item, 'call_id') or _get_value(item, 'id') return part @@ -667,13 +683,18 @@ def _response_to_llm_response( output_metadata = [] reasoning_metadata = [] unmapped_output = [] + has_invalid_function_call = False for item in _get_value(response, 'output', []) or []: if isinstance(item, ResponseOutputMessage): parts.extend(_message_content_parts(item)) item_type = item.type elif isinstance(item, ResponseFunctionToolCall): - parts.append(_function_call_part(item)) + function_call_part = _function_call_part(item) + if function_call_part: + parts.append(function_call_part) + else: + has_invalid_function_call = True item_type = item.type elif isinstance(item, ResponseReasoningItem): reasoning, metadata = _reasoning_parts(item) @@ -686,7 +707,11 @@ def _response_to_llm_response( if item_type == 'message': parts.extend(_message_content_parts(cast(Mapping[str, Any], item))) elif item_type == 'function_call': - parts.append(_function_call_part(cast(Mapping[str, Any], item))) + function_call_part = _function_call_part(cast(Mapping[str, Any], item)) + if function_call_part: + parts.append(function_call_part) + else: + has_invalid_function_call = True elif item_type == 'reasoning': reasoning, metadata = _reasoning_parts(cast(Mapping[str, Any], item)) parts.extend(reasoning) @@ -715,7 +740,11 @@ def _response_to_llm_response( if unmapped_output: custom_metadata['openai_response']['unmapped_output'] = unmapped_output - finish_reason = _map_finish_reason(response) + finish_reason = ( + types.FinishReason.MAX_TOKENS + if has_invalid_function_call + else _map_finish_reason(response) + ) llm_response = LlmResponse( content=types.Content(role='model', parts=parts) if parts else None, usage_metadata=_usage_metadata(usage), @@ -724,7 +753,13 @@ def _response_to_llm_response( interaction_id=_get_value(response, 'id'), custom_metadata=custom_metadata, ) - if finish_reason and finish_reason != types.FinishReason.STOP: + if has_invalid_function_call: + llm_response.error_code = types.FinishReason.MAX_TOKENS + llm_response.error_message = ( + 'A Responses function call had invalid, incomplete, or missing ' + 'arguments or name.' + ) + elif finish_reason and finish_reason != types.FinishReason.STOP: error = _get_value(response, 'error') or _get_value( response, 'incomplete_details' ) @@ -804,6 +839,8 @@ def process_event( self._ensure_output_item(key, item_type) if item_type == 'function_call': self._track_function_call_item(key, item) + call = self.function_calls[key] + responses.append(self._partial_function_call_response(call)) elif event_type in ( 'response.content_part.done', 'response.output_text.done', @@ -837,9 +874,21 @@ def process_event( 'name': _get_value(event, 'name') or '', 'call_id': _get_value(event, 'call_id'), 'arguments': '', + 'argument_deltas_emitted': False, }, ) - call['arguments'] += _get_value(event, 'delta') or '' + call['name'] = _get_value(event, 'name') or call.get('name') or '' + if not call.get('call_id'): + call['call_id'] = _get_value(event, 'call_id') or str(key) + argument_delta = _get_value(event, 'delta') or '' + call['arguments'] += argument_delta + if argument_delta: + call['argument_deltas_emitted'] = True + responses.append( + self._partial_function_call_response( + call, argument_delta=argument_delta + ) + ) elif event_type == 'response.function_call_arguments.done': responses.extend(self._close_reasoning_stream(event)) key = self._stream_output_key(event, _get_value(event, 'call_id')) @@ -850,11 +899,22 @@ def process_event( 'name': _get_value(event, 'name') or '', 'call_id': _get_value(event, 'call_id'), 'arguments': '', + 'argument_deltas_emitted': False, }, ) + call['name'] = _get_value(event, 'name') or call.get('name') or '' + if not call.get('call_id'): + call['call_id'] = _get_value(event, 'call_id') or str(key) arguments = _get_value(event, 'arguments') if arguments is not None: call['arguments'] = arguments + if call['arguments'] and not call['argument_deltas_emitted']: + responses.append( + self._partial_function_call_response( + call, argument_delta=call['arguments'] + ) + ) + call['argument_deltas_emitted'] = True elif event_type == 'response.output_item.done': item = _get_value(event, 'item') item_type = _get_value(item, 'type') @@ -865,6 +925,14 @@ def process_event( output_item['done_item'] = item if item_type == 'function_call': self._track_function_call_item(key, item) + call = self.function_calls[key] + if call['arguments'] and not call['argument_deltas_emitted']: + responses.append( + self._partial_function_call_response( + call, argument_delta=call['arguments'] + ) + ) + call['argument_deltas_emitted'] = True elif event_type in ('response.completed', 'response.incomplete'): self.response = _get_value(event, 'response') response_usage = _get_value(self.response, 'usage') @@ -882,6 +950,33 @@ def process_event( ) return responses + def _partial_function_call_response( + self, call: dict[str, Any], argument_delta: str | None = None + ) -> LlmResponse: + partial_args = ( + [types.PartialArg(string_value=argument_delta)] + if argument_delta + else [] + ) + return LlmResponse( + content=types.Content( + role='model', + parts=[ + types.Part( + function_call=types.FunctionCall( + id=call.get('call_id'), + name=call.get('name') or '', + partial_args=partial_args, + will_continue=True, + ) + ) + ], + ), + partial=True, + model_version=self.model, + interaction_id=self.response_id, + ) + def _close_reasoning_stream( self, event: ResponseStreamEvent | Mapping[str, Any] ) -> list[LlmResponse]: @@ -974,14 +1069,19 @@ def _track_function_call_item( # A done item may omit fields already streamed via deltas; preserve them. existing = self.function_calls.get(key, {}) arguments = _get_value(item, 'arguments') + call_id = ( + _get_value(item, 'call_id') + or _get_value(item, 'id') + or existing.get('call_id') + or str(key) + ) self.function_calls[key] = { 'name': _get_value(item, 'name') or existing.get('name') or '', - 'call_id': ( - _get_value(item, 'call_id') - or _get_value(item, 'id') - or existing.get('call_id') - ), + 'call_id': call_id, 'arguments': arguments if arguments else existing.get('arguments', ''), + 'argument_deltas_emitted': existing.get( + 'argument_deltas_emitted', False + ), } def final_response(self) -> LlmResponse | None: @@ -994,6 +1094,7 @@ def final_response(self) -> LlmResponse | None: ) parts = [] + has_invalid_function_call = False for key in self.output_order: item = self.output_items[key] done_item = item.get('done_item') @@ -1019,26 +1120,70 @@ def final_response(self) -> LlmResponse | None: if text: parts.append(types.Part.from_text(text=text)) elif item_type == 'function_call' and key in self.function_calls: - parts.append(self._function_call_part_from_accumulator(key)) + function_call_part = self._function_call_part_from_accumulator(key) + if function_call_part: + parts.append(function_call_part) + else: + has_invalid_function_call = True for key in self.function_calls: if key not in self.output_items: - parts.append(self._function_call_part_from_accumulator(key)) - if not parts: + function_call_part = self._function_call_part_from_accumulator(key) + if function_call_part: + parts.append(function_call_part) + else: + has_invalid_function_call = True + if not parts and not has_invalid_function_call: return None + finish_reason = ( + types.FinishReason.MAX_TOKENS + if has_invalid_function_call + else types.FinishReason.STOP + ) return LlmResponse( - content=types.Content(role='model', parts=parts), + content=types.Content(role='model', parts=parts) if parts else None, partial=False, - finish_reason=types.FinishReason.STOP, + finish_reason=finish_reason, + error_code=finish_reason if has_invalid_function_call else None, + error_message=( + 'A streamed Responses function call had invalid, incomplete, or ' + 'missing arguments or name.' + if has_invalid_function_call + else None + ), interaction_id=self.response_id, model_version=self.model, usage_metadata=_usage_metadata(self.usage), ) - def _function_call_part_from_accumulator(self, key: int | str) -> types.Part: + def _function_call_part_from_accumulator( + self, key: int | str + ) -> types.Part | None: call = self.function_calls[key] + if not call.get('name'): + logger.warning( + 'Dropping streamed Responses function call without a name.' + ) + return None + arguments = call.get('arguments') + if arguments: + try: + parsed_arguments = json.loads(arguments) + except json.JSONDecodeError: + logger.warning( + 'Dropping streamed Responses function call with invalid arguments.' + ) + return None + if not isinstance(parsed_arguments, dict): + logger.warning( + 'Dropping streamed Responses function call with non-object' + ' arguments.' + ) + return None + else: + parsed_arguments = {} part = types.Part.from_function_call( name=call.get('name'), - args=_loads_json_object(call.get('arguments')), + args=parsed_arguments, ) part.function_call.id = call.get('call_id') return part diff --git a/tests/unittests/labs/openai/test_openai_responses_llm.py b/tests/unittests/labs/openai/test_openai_responses_llm.py index 0b4baae0032..4b2387f0be4 100644 --- a/tests/unittests/labs/openai/test_openai_responses_llm.py +++ b/tests/unittests/labs/openai/test_openai_responses_llm.py @@ -1046,9 +1046,30 @@ async def test_streaming_generation_aggregates_function_call_without_completed_e async for item in llm.generate_content_async(llm_request, stream=True) ] - assert len(responses) == 1 - assert responses[0].finish_reason == types.FinishReason.STOP - function_call = responses[0].content.parts[0].function_call + assert len(responses) == 4 + partial_responses = responses[:-1] + assert all(response.partial is True for response in partial_responses) + assert [ + response.content.parts[0].function_call.partial_args + for response in partial_responses + ] == [ + [], + [types.PartialArg(string_value='{"location"')], + [types.PartialArg(string_value=': "Paris"}')], + ] + assert [ + response.content.parts[0].function_call.id + for response in partial_responses + ] == ['call_123', 'call_123', 'call_123'] + assert all( + response.content.parts[0].function_call.will_continue + for response in partial_responses + ) + + final_response = responses[-1] + assert final_response.partial is False + assert final_response.finish_reason == types.FinishReason.STOP + function_call = final_response.content.parts[0].function_call assert function_call.id == 'call_123' assert function_call.name == 'get_weather' assert function_call.args == {'location': 'Paris'} @@ -1088,7 +1109,18 @@ async def test_streaming_generation_uses_function_arguments_done_event(): async for item in llm.generate_content_async(llm_request, stream=True) ] - function_call = responses[0].content.parts[0].function_call + assert len(responses) == 3 + assert responses[0].partial is True + assert responses[0].content.parts[0].function_call.partial_args == [] + assert responses[1].partial is True + assert ( + responses[1].content.parts[0].function_call.partial_args[0].string_value + == '{"location": "Paris"}' + ) + + final_response = responses[-1] + assert final_response.partial is False + function_call = final_response.content.parts[0].function_call assert function_call.id == 'call_123' assert function_call.args == {'location': 'Paris'} @@ -1139,6 +1171,57 @@ def test_azure_client_uses_openai_v1_base_url(): ) +@pytest.mark.asyncio +async def test_azure_responses_inherits_partial_function_call_streaming(): + """Azure Responses uses the shared partial function-call stream handling.""" + stream = _FakeAsyncStream([ + { + 'type': 'response.output_item.added', + 'output_index': 0, + 'item': { + 'type': 'function_call', + 'call_id': 'call_azure', + 'name': 'get_weather', + 'arguments': '', + }, + }, + { + 'type': 'response.function_call_arguments.delta', + 'output_index': 0, + 'delta': '{"city": "Seattle"}', + }, + ]) + client = _CaptureClient(stream) + llm = AzureOpenAIResponsesLlm( + model='deployment', azure_endpoint='https://example.openai.azure.com/' + ) + llm.__dict__['_openai_client'] = client + llm_request = LlmRequest( + contents=[ + types.Content( + role='user', parts=[types.Part.from_text(text='Weather?')] + ) + ] + ) + + responses = [ + item + async for item in llm.generate_content_async(llm_request, stream=True) + ] + + assert len(responses) == 3 + assert responses[0].partial is True + assert responses[1].partial is True + assert ( + responses[1].content.parts[0].function_call.partial_args[0].string_value + == '{"city": "Seattle"}' + ) + assert responses[-1].partial is False + assert responses[-1].content.parts[0].function_call.args == { + 'city': 'Seattle' + } + + def _user_request(**config_kwargs) -> LlmRequest: return LlmRequest( model='gpt-5', @@ -1330,6 +1413,25 @@ def test_loads_json_object_handles_malformed_arguments(): assert _loads_json_object('{"a": 1}') == {'a': 1} +def test_response_parsing_drops_malformed_function_call_arguments(): + """Malformed final arguments must not become an empty executable call.""" + response = { + 'id': 'resp_invalid', + 'model': 'gpt-5', + 'status': 'completed', + 'output': [{ + 'type': 'function_call', + 'call_id': 'call_invalid', + 'name': 'get_weather', + 'arguments': '{"city":', + }], + } + + llm_response = _response_to_llm_response(response) + + assert llm_response.get_function_calls() == [] + + def test_code_parts_handle_missing_inner_fields(): """Code parts with unset code/output do not crash the conversion.""" content = types.Content(