# 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 __future__ import annotations

from unittest import mock

from google.genai import types
from pydantic import BaseModel

if not hasattr(types, 'AvatarConfig'):
  # The repository may be tested locally with a google-genai version older than
  # the source tree expects. Keep this test focused on the labs model behavior.
  types.AvatarConfig = type('AvatarConfig', (BaseModel,), {})

from google.adk.labs.openai._openai_responses_llm import _content_to_response_input_items
from google.adk.labs.openai._openai_responses_llm import _function_declaration_to_response_tool
from google.adk.labs.openai._openai_responses_llm import _loads_json_object
from google.adk.labs.openai._openai_responses_llm import _response_to_llm_response
from google.adk.labs.openai._openai_responses_llm import _tool_choice
from google.adk.labs.openai._openai_responses_llm import AzureOpenAIResponsesLlm
from google.adk.labs.openai._openai_responses_llm import OpenAIResponsesLlm
from google.adk.labs.openai._openai_schema import enforce_strict_openai_schema
from google.adk.models.llm_request import LlmRequest
from openai import AsyncOpenAI
from openai.types.responses import EasyInputMessageParam
from openai.types.responses import FunctionToolParam
from openai.types.responses import Response
from openai.types.responses import ResponseFunctionToolCall
from openai.types.responses import ResponseFunctionToolCallParam
from openai.types.responses import ResponseInputFileParam
from openai.types.responses import ResponseInputImageParam
from openai.types.responses import ResponseInputItemParam
from openai.types.responses import ResponseInputTextParam
from openai.types.responses import ResponseOutputMessage
from openai.types.responses import ResponseOutputText
from openai.types.responses import ResponseReasoningItem
from openai.types.responses import ResponseReasoningItemParam
from openai.types.responses import ResponseStreamEvent
from openai.types.responses import ResponseUsage
from openai.types.responses import ToolParam
from openai.types.responses.response_reasoning_item import Summary
from openai.types.responses.response_usage import InputTokensDetails
from openai.types.responses.response_usage import OutputTokensDetails
import pytest


class _FakeAsyncStream:

  def __init__(self, events: list[dict]):
    self._events = events

  def __aiter__(self):
    return self._iter()

  async def _iter(self):
    for event in self._events:
      yield event


class _CaptureResponses:

  def __init__(self, response):
    self.response = response
    self.kwargs = None

  async def create(self, **kwargs):
    self.kwargs = kwargs
    return self.response


class _CaptureClient:

  def __init__(self, response):
    self.responses = _CaptureResponses(response)


def test_openai_responses_package_exports_required_types():
  """The supported OpenAI SDK range exposes the Responses API types we use."""
  assert EasyInputMessageParam
  assert FunctionToolParam
  assert Response
  assert ResponseFunctionToolCall
  assert ResponseFunctionToolCallParam
  assert ResponseInputFileParam
  assert ResponseInputImageParam
  assert ResponseInputItemParam
  assert ResponseInputTextParam
  assert ResponseOutputMessage
  assert ResponseOutputText
  assert ResponseReasoningItem
  assert ResponseReasoningItemParam
  assert ResponseStreamEvent
  assert ResponseUsage
  assert ToolParam
  assert Summary
  assert InputTokensDetails
  assert OutputTokensDetails


def test_request_kwargs_use_responses_api_shape():
  """ADK requests are converted to Responses input, tools, and config."""
  llm = OpenAIResponsesLlm(
      model='gpt-5',
      store=False,
      include=['reasoning.encrypted_content'],
      reasoning={'effort': 'medium'},
  )
  llm_request = LlmRequest(
      model='gpt-5-mini',
      previous_interaction_id='resp_previous',
      contents=[
          types.Content(
              role='user',
              parts=[types.Part.from_text(text='What is the weather?')],
          ),
          types.Content(
              role='tool',
              parts=[
                  types.Part(
                      function_response=types.FunctionResponse(
                          id='call_weather',
                          name='get_weather',
                          response={'temperature': '70 F'},
                      )
                  )
              ],
          ),
      ],
      config=types.GenerateContentConfig(
          system_instruction='You are concise.',
          temperature=0.2,
          top_p=0.9,
          max_output_tokens=128,
          tools=[
              types.Tool(
                  function_declarations=[
                      types.FunctionDeclaration(
                          name='get_weather',
                          description='Get weather',
                          parameters=types.Schema(
                              type=types.Type.OBJECT,
                              properties={
                                  'location': types.Schema(
                                      type=types.Type.STRING
                                  )
                              },
                              required=['location'],
                          ),
                      )
                  ]
              )
          ],
      ),
  )

  kwargs = llm._get_response_create_kwargs(llm_request, stream=False)

  assert kwargs['model'] == 'gpt-5-mini'
  assert kwargs['instructions'] == 'You are concise.'
  assert kwargs['previous_response_id'] == 'resp_previous'
  assert kwargs['stream'] is False
  assert kwargs['temperature'] == 0.2
  assert kwargs['top_p'] == 0.9
  assert kwargs['max_output_tokens'] == 128
  assert kwargs['store'] is False
  assert kwargs['include'] == ['reasoning.encrypted_content']
  assert kwargs['reasoning'] == {'effort': 'medium'}
  assert kwargs['input'] == [
      {
          'type': 'message',
          'role': 'user',
          'content': [{'type': 'input_text', 'text': 'What is the weather?'}],
      },
      {
          'type': 'function_call_output',
          'call_id': 'call_weather',
          'output': '{"temperature": "70 F"}',
      },
  ]
  assert kwargs['tools'] == [{
      'type': 'function',
      'name': 'get_weather',
      'description': 'Get weather',
      'parameters': {
          'type': 'object',
          'properties': {'location': {'type': 'string'}},
          'required': ['location'],
      },
      'strict': False,
  }]


def test_content_mapping_preserves_model_tool_calls_and_reasoning():
  """Model tool calls/text replay while synthetic reasoning is skipped."""
  function_call_part = types.Part.from_function_call(
      name='get_weather', args={'location': 'Paris'}
  )
  function_call_part.function_call.id = 'call_123'
  thought_part = types.Part(text='Need weather first.', thought=True)
  content = types.Content(
      role='model',
      parts=[thought_part, function_call_part, types.Part.from_text(text='Hi')],
  )

  items = _content_to_response_input_items(content)

  assert items == [
      {
          'type': 'function_call',
          'call_id': 'call_123',
          'name': 'get_weather',
          'arguments': '{"location": "Paris"}',
      },
      {
          'type': 'message',
          'role': 'assistant',
          'content': 'Hi',
      },
  ]


def test_content_mapping_preserves_reasoning_signature():
  """Replayed thoughts are skipped because synthetic IDs are invalid."""
  thought_part = types.Part(text='Need weather first.', thought=True)
  thought_part.thought_signature = b'encrypted_reasoning'
  redacted_part = types.Part(
      thought=True, thought_signature=b'redacted_reasoning'
  )
  content = types.Content(role='model', parts=[thought_part, redacted_part])

  items = _content_to_response_input_items(content)

  assert items == []


def test_content_mapping_sanitizes_function_call_ids_per_request():
  """Invalid IDs get stable fallbacks and missing IDs do not collide."""
  invalid_call = types.Part.from_function_call(name='tool', args={})
  invalid_call.function_call.id = 'invalid id!'
  invalid_response = types.Part(
      function_response=types.FunctionResponse(
          id='invalid id!', name='tool', response={'result': 'ok'}
      )
  )
  missing_call_1 = types.Part.from_function_call(name='tool', args={})
  missing_call_2 = types.Part.from_function_call(name='tool', args={})
  content = types.Content(
      role='model',
      parts=[invalid_call, invalid_response, missing_call_1, missing_call_2],
  )

  items = OpenAIResponsesLlm()._get_response_input(
      LlmRequest(contents=[content])
  )

  assert items[0]['call_id'] == 'call_adk_fallback_0'
  assert items[1]['call_id'] == 'call_adk_fallback_0'
  assert items[2]['call_id'] == 'call_adk_fallback_1'
  assert items[3]['call_id'] == 'call_adk_fallback_2'


def test_function_response_serializes_mcp_content_as_text():
  """MCP-style text content is flattened for function_call_output."""
  content = types.Content(
      role='tool',
      parts=[
          types.Part(
              function_response=types.FunctionResponse(
                  id='call_123',
                  name='tool',
                  response={
                      'content': [
                          {'type': 'text', 'text': 'first'},
                          {'type': 'text', 'text': 'second'},
                      ]
                  },
              )
          )
      ],
  )

  items = _content_to_response_input_items(content)

  assert items == [{
      'type': 'function_call_output',
      'call_id': 'call_123',
      'output': 'first\nsecond',
  }]


def test_image_and_file_parts_use_responses_content_types():
  """Image and file parts become Responses input_image/input_file content."""
  content = types.Content(
      role='user',
      parts=[
          types.Part(
              inline_data=types.Blob(data=b'image', mime_type='image/png')
          ),
          types.Part(
              inline_data=types.Blob(
                  data=b'hello', mime_type='text/plain', display_name='a.txt'
              )
          ),
          types.Part(
              file_data=types.FileData(
                  file_uri='file-abc', mime_type='application/pdf'
              )
          ),
          types.Part(
              file_data=types.FileData(
                  file_uri='https://example.com/doc.pdf',
                  mime_type='application/pdf',
              )
          ),
          types.Part(
              file_data=types.FileData(
                  file_uri='https://example.com/image.png',
                  mime_type='image/png',
              )
          ),
      ],
  )

  items = _content_to_response_input_items(content)

  assert items[0]['content'][0]['type'] == 'input_image'
  assert items[0]['content'][0]['image_url'].startswith(
      'data:image/png;base64,'
  )
  assert items[0]['content'][1] == {
      'type': 'input_file',
      'filename': 'a.txt',
      'file_data': 'data:text/plain;base64,aGVsbG8=',
  }
  assert items[0]['content'][2] == {
      'type': 'input_file',
      'file_id': 'file-abc',
  }
  assert items[0]['content'][3] == {
      'type': 'input_file',
      'file_url': 'https://example.com/doc.pdf',
  }
  assert items[0]['content'][4] == {
      'type': 'input_image',
      'detail': 'auto',
      'image_url': 'https://example.com/image.png',
  }


def test_assistant_media_is_filtered(caplog):
  """Assistant media is skipped instead of creating invalid input blocks."""
  content = types.Content(
      role='model',
      parts=[
          types.Part.from_text(text='before'),
          types.Part(
              inline_data=types.Blob(data=b'image', mime_type='image/png')
          ),
          types.Part.from_text(text='after'),
      ],
  )

  items = _content_to_response_input_items(content)

  assert items == [
      {'type': 'message', 'role': 'assistant', 'content': 'before'},
      {'type': 'message', 'role': 'assistant', 'content': 'after'},
  ]
  assert (
      'Media data is not supported in Responses assistant turns.' in caplog.text
  )


def test_code_parts_are_preserved_as_text():
  """Code parts use the same lossy text fallback as other adapters."""
  content = types.Content(
      role='user',
      parts=[
          types.Part(
              executable_code=types.ExecutableCode(
                  language='PYTHON', code='print(1)'
              )
          ),
          types.Part(
              code_execution_result=types.CodeExecutionResult(
                  output='1', outcome=types.Outcome.OUTCOME_OK
              )
          ),
      ],
  )

  items = _content_to_response_input_items(content)

  assert items[0]['content'] == [
      {'type': 'input_text', 'text': 'Code:```python\nprint(1)\n```'},
      {
          'type': 'input_text',
          'text': 'Execution Result:```code_output\n1\n```',
      },
  ]


def test_function_declaration_uses_responses_tool_shape():
  """Function declarations use top-level Responses function tool fields."""
  declaration = types.FunctionDeclaration(
      name='search',
      description='Search docs',
      parameters_json_schema={
          'type': 'OBJECT',
          'properties': {'query': {'type': 'STRING'}},
      },
  )

  tool = _function_declaration_to_response_tool(declaration)

  assert tool == {
      'type': 'function',
      'name': 'search',
      'description': 'Search docs',
      'parameters': {
          'type': 'object',
          'properties': {'query': {'type': 'string'}},
      },
      'strict': False,
  }


def test_structured_output_uses_responses_text_format():
  """ADK response schemas become Responses text.format json_schema."""

  class Answer(BaseModel):
    answer: str

  llm = OpenAIResponsesLlm(model='gpt-5')
  llm_request = LlmRequest(
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='Hi')])
      ],
      config=types.GenerateContentConfig(response_schema=Answer),
  )

  kwargs = llm._get_response_create_kwargs(llm_request, stream=False)

  assert kwargs['text']['format']['type'] == 'json_schema'
  assert kwargs['text']['format']['name'] == 'Answer'
  assert kwargs['text']['format']['strict'] is True
  assert kwargs['text']['format']['schema']['additionalProperties'] is False
  assert kwargs['text']['format']['schema']['required'] == ['answer']


def test_thinking_config_zero_budget_maps_to_minimal_reasoning():
  """thinking_budget=0 maps to minimal effort and overrides the static field."""
  llm = OpenAIResponsesLlm(model='gpt-5', reasoning={'effort': 'medium'})
  llm_request = LlmRequest(
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='Hi')])
      ],
      config=types.GenerateContentConfig(
          thinking_config=types.ThinkingConfig(thinking_budget=0)
      ),
  )

  kwargs = llm._get_response_create_kwargs(llm_request, stream=False)

  assert kwargs['reasoning'] == {'effort': 'minimal', 'summary': 'concise'}


@pytest.mark.parametrize(
    ('thinking_level', 'effort'),
    [
        (types.ThinkingLevel.MINIMAL, 'minimal'),
        (types.ThinkingLevel.LOW, 'low'),
        (types.ThinkingLevel.MEDIUM, 'medium'),
        (types.ThinkingLevel.HIGH, 'high'),
        (types.ThinkingLevel.THINKING_LEVEL_UNSPECIFIED, 'medium'),
    ],
)
def test_thinking_config_level_maps_to_openai_reasoning_effort(
    thinking_level, effort
):
  """thinking_level maps directly to Responses reasoning effort."""
  llm = OpenAIResponsesLlm(model='gpt-5')
  llm_request = LlmRequest(
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='Hi')])
      ],
      config=types.GenerateContentConfig(
          thinking_config=types.ThinkingConfig(thinking_level=thinking_level)
      ),
  )

  kwargs = llm._get_response_create_kwargs(llm_request, stream=False)

  assert kwargs['reasoning'] == {'effort': effort, 'summary': 'concise'}


def test_thinking_config_level_takes_precedence_over_budget():
  """thinking_level is a better OpenAI mapping than token budget."""
  llm = OpenAIResponsesLlm(model='gpt-5')
  llm_request = LlmRequest(
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='Hi')])
      ],
      config=types.GenerateContentConfig(
          thinking_config=types.ThinkingConfig(
              thinking_budget=0, thinking_level=types.ThinkingLevel.HIGH
          )
      ),
  )

  kwargs = llm._get_response_create_kwargs(llm_request, stream=False)

  assert kwargs['reasoning'] == {'effort': 'high', 'summary': 'concise'}


def test_thinking_config_automatic_uses_medium_concise_reasoning():
  """Negative budgets map to medium reasoning with concise summaries."""
  llm = OpenAIResponsesLlm(model='gpt-5', reasoning={'effort': 'high'})
  llm_request = LlmRequest(
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='Hi')])
      ],
      config=types.GenerateContentConfig(
          thinking_config=types.ThinkingConfig(thinking_budget=-1)
      ),
  )

  kwargs = llm._get_response_create_kwargs(llm_request, stream=False)

  assert kwargs['reasoning'] == {'effort': 'medium', 'summary': 'concise'}


def test_thinking_config_none_budget_raises():
  """ThinkingConfig requires level or explicit budget semantics."""
  llm = OpenAIResponsesLlm(model='gpt-5')
  llm_request = LlmRequest(
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='Hi')])
      ],
      config=types.GenerateContentConfig(
          thinking_config=types.ThinkingConfig()
      ),
  )

  with pytest.raises(
      ValueError, match='thinking_budget must be set explicitly'
  ):
    llm._get_response_create_kwargs(llm_request, stream=False)


def test_thinking_config_positive_budget_uses_medium_concise_reasoning():
  """Positive budgets map to medium reasoning with concise summaries."""
  llm = OpenAIResponsesLlm(model='gpt-5')
  llm_request = LlmRequest(
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='Hi')])
      ],
      config=types.GenerateContentConfig(
          thinking_config=types.ThinkingConfig(thinking_budget=1024)
      ),
  )

  kwargs = llm._get_response_create_kwargs(llm_request, stream=False)

  assert kwargs['reasoning'] == {'effort': 'medium', 'summary': 'concise'}


def test_response_parsing_maps_text_reasoning_tool_calls_and_usage():
  """Responses output items become ADK text, thought, and function parts."""
  response = {
      'id': 'resp_123',
      'model': 'gpt-5',
      'status': 'completed',
      'usage': {
          'input_tokens': 11,
          'output_tokens': 7,
          'total_tokens': 18,
          'input_tokens_details': {'cached_tokens': 3},
          'output_tokens_details': {'reasoning_tokens': 4},
      },
      'output': [
          {
              'type': 'reasoning',
              'id': 'rs_1',
              'summary': [{'type': 'summary_text', 'text': 'Think.'}],
              'encrypted_content': 'encrypted',
          },
          {
              'type': 'message',
              'role': 'assistant',
              'content': [{'type': 'output_text', 'text': 'Calling a tool.'}],
          },
          {
              'type': 'function_call',
              'call_id': 'call_123',
              'name': 'get_weather',
              'arguments': '{"location": "Paris"}',
          },
      ],
  }

  llm_response = _response_to_llm_response(response)

  assert llm_response.interaction_id == 'resp_123'
  assert llm_response.model_version == 'gpt-5'
  assert llm_response.finish_reason == types.FinishReason.STOP
  assert llm_response.usage_metadata.prompt_token_count == 11
  assert llm_response.usage_metadata.candidates_token_count == 7
  assert llm_response.usage_metadata.total_token_count == 18
  assert llm_response.usage_metadata.cached_content_token_count == 3
  assert llm_response.usage_metadata.thoughts_token_count == 4
  assert llm_response.content.parts[0].thought is True
  assert llm_response.content.parts[0].text == 'Think.'
  assert llm_response.content.parts[0].thought_signature == b'encrypted'
  assert llm_response.content.parts[1].text == 'Calling a tool.'
  function_call = llm_response.content.parts[2].function_call
  assert function_call.id == 'call_123'
  assert function_call.name == 'get_weather'
  assert function_call.args == {'location': 'Paris'}
  assert llm_response.custom_metadata['openai_response']['reasoning'] == [
      {'encrypted_content': 'encrypted', 'id': 'rs_1'}
  ]


def test_response_parsing_accepts_openai_sdk_response_types():
  """OpenAI SDK Response objects are parsed through typed paths."""
  response = Response(
      id='resp_typed',
      created_at=1.0,
      model='gpt-5',
      object='response',
      output=[
          ResponseReasoningItem(
              id='rs_typed',
              type='reasoning',
              summary=[Summary(type='summary_text', text='Typed thought.')],
              encrypted_content='encrypted_typed',
          ),
          ResponseOutputMessage(
              id='msg_typed',
              type='message',
              role='assistant',
              status='completed',
              content=[
                  ResponseOutputText(
                      type='output_text', text='Typed hello.', annotations=[]
                  )
              ],
          ),
          ResponseFunctionToolCall(
              type='function_call',
              call_id='call_typed',
              name='get_weather',
              arguments='{"city": "Tokyo"}',
          ),
      ],
      parallel_tool_calls=True,
      tool_choice='auto',
      tools=[],
      status='completed',
      usage=ResponseUsage(
          input_tokens=3,
          input_tokens_details=InputTokensDetails(
              cached_tokens=1, cache_write_tokens=0
          ),
          output_tokens=5,
          output_tokens_details=OutputTokensDetails(reasoning_tokens=2),
          total_tokens=8,
      ),
  )

  llm_response = _response_to_llm_response(response)

  assert llm_response.interaction_id == 'resp_typed'
  assert llm_response.content.parts[0].thought is True
  assert llm_response.content.parts[0].text == 'Typed thought.'
  assert llm_response.content.parts[0].thought_signature == b'encrypted_typed'
  assert llm_response.content.parts[1].text == 'Typed hello.'
  assert llm_response.content.parts[2].function_call.id == 'call_typed'
  assert llm_response.content.parts[2].function_call.args == {'city': 'Tokyo'}
  assert llm_response.usage_metadata.total_token_count == 8
  assert llm_response.custom_metadata['openai_response']['reasoning'] == [
      {'encrypted_content': 'encrypted_typed', 'id': 'rs_typed'}
  ]


def test_response_parsing_preserves_redacted_reasoning():
  """Encrypted-only reasoning becomes a signature-only thought part."""
  response = {
      'id': 'resp_123',
      'model': 'gpt-5',
      'status': 'completed',
      'output': [
          {
              'type': 'reasoning',
              'id': 'rs_1',
              'encrypted_content': 'encrypted_only',
          },
      ],
  }

  llm_response = _response_to_llm_response(response)

  part = llm_response.content.parts[0]
  assert part.thought is True
  assert part.text is None
  assert part.thought_signature == b'encrypted_only'


@pytest.mark.asyncio
async def test_generate_content_async_calls_responses_create():
  """Non-streaming generation calls responses.create and parses the result."""
  response = {
      'id': 'resp_123',
      'model': 'gpt-5',
      'status': 'completed',
      'output': [{
          'type': 'message',
          'role': 'assistant',
          'content': [{'type': 'output_text', 'text': 'Hello'}],
      }],
  }
  client = _CaptureClient(response)
  llm = OpenAIResponsesLlm(model='gpt-5')
  llm.__dict__['_openai_client'] = client
  llm_request = LlmRequest(
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='Hi')])
      ]
  )

  responses = [item async for item in llm.generate_content_async(llm_request)]

  assert client.responses.kwargs['model'] == 'gpt-5'
  assert client.responses.kwargs['stream'] is False
  assert responses[0].content.parts[0].text == 'Hello'
  assert responses[0].interaction_id == 'resp_123'


@pytest.mark.asyncio
async def test_generate_content_async_can_skip_response_metadata():
  """Response metadata can be omitted from LlmResponse.custom_metadata."""
  response = {
      'id': 'resp_123',
      'model': 'gpt-5',
      'status': 'completed',
      'usage': {
          'input_tokens': 1,
          'output_tokens': 2,
          'total_tokens': 3,
      },
      'output': [{
          'type': 'message',
          'role': 'assistant',
          'content': [{'type': 'output_text', 'text': 'Hello'}],
      }],
  }
  client = _CaptureClient(response)
  llm = OpenAIResponsesLlm(model='gpt-5', include_response_metadata=False)
  llm.__dict__['_openai_client'] = client
  llm_request = LlmRequest(
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='Hi')])
      ]
  )

  responses = [item async for item in llm.generate_content_async(llm_request)]

  assert responses[0].custom_metadata is None
  assert responses[0].usage_metadata.total_token_count == 3


@pytest.mark.asyncio
async def test_streaming_generation_yields_partials_and_final_response():
  """Streaming generation yields text/thought deltas and a final response."""
  stream = _FakeAsyncStream([
      {
          'type': 'response.created',
          'response': {'id': 'resp_stream', 'model': 'gpt-5'},
      },
      {'type': 'response.reasoning_summary_text.delta', 'delta': 'Think'},
      {'type': 'response.output_text.delta', 'delta': 'Hel'},
      {'type': 'response.output_text.delta', 'delta': 'lo'},
      {
          'type': 'response.completed',
          'response': {
              'id': 'resp_stream',
              'model': 'gpt-5',
              'status': 'completed',
              'output': [
                  {
                      'type': 'reasoning',
                      'summary': [{'type': 'summary_text', 'text': 'Think'}],
                  },
                  {
                      'type': 'message',
                      'role': 'assistant',
                      'content': [{'type': 'output_text', 'text': 'Hello'}],
                  },
              ],
          },
      },
  ])
  client = _CaptureClient(stream)
  llm = OpenAIResponsesLlm(model='gpt-5')
  llm.__dict__['_openai_client'] = client
  llm_request = LlmRequest(
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='Hi')])
      ]
  )

  responses = [
      item
      async for item in llm.generate_content_async(llm_request, stream=True)
  ]

  assert client.responses.kwargs['stream'] is True
  assert responses[0].partial is True
  assert responses[0].content.parts[0].thought is True
  assert responses[0].content.parts[0].text == 'Think'
  assert responses[1].partial is True
  assert responses[1].content is None
  assert responses[1].custom_metadata == {
      'openai_response': {
          'stream_event': {
              'type': 'response.output_text.delta',
              'reasoning_done': True,
          }
      }
  }
  assert responses[2].content.parts[0].text == 'Hel'
  assert responses[3].content.parts[0].text == 'lo'
  assert responses[4].partial is None
  assert responses[4].content.parts[0].thought is True
  assert responses[4].content.parts[1].text == 'Hello'


@pytest.mark.asyncio
async def test_streaming_generation_can_skip_response_metadata():
  """Metadata-only stream boundary events are omitted when metadata is off."""
  stream = _FakeAsyncStream([
      {
          'type': 'response.created',
          'response': {'id': 'resp_stream', 'model': 'gpt-5'},
      },
      {'type': 'response.reasoning_summary_text.delta', 'delta': 'Think'},
      {'type': 'response.output_text.delta', 'delta': 'Hello'},
      {
          'type': 'response.completed',
          'response': {
              'id': 'resp_stream',
              'model': 'gpt-5',
              'status': 'completed',
              'output': [
                  {
                      'type': 'reasoning',
                      'summary': [{'type': 'summary_text', 'text': 'Think'}],
                  },
                  {
                      'type': 'message',
                      'role': 'assistant',
                      'content': [{'type': 'output_text', 'text': 'Hello'}],
                  },
              ],
          },
      },
  ])
  client = _CaptureClient(stream)
  llm = OpenAIResponsesLlm(model='gpt-5', include_response_metadata=False)
  llm.__dict__['_openai_client'] = client
  llm_request = LlmRequest(
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='Hi')])
      ]
  )

  responses = [
      item
      async for item in llm.generate_content_async(llm_request, stream=True)
  ]

  assert [response.custom_metadata for response in responses] == [
      None,
      None,
      None,
  ]
  assert responses[0].content.parts[0].thought is True
  assert responses[1].content.parts[0].text == 'Hello'
  assert responses[2].partial is None


@pytest.mark.asyncio
async def test_streaming_generation_fallback_preserves_output_item_order():
  """Fallback final response preserves separate reasoning/text items."""
  stream = _FakeAsyncStream([
      {
          'type': 'response.created',
          'response': {'id': 'resp_stream', 'model': 'gpt-5'},
      },
      {
          'type': 'response.output_item.added',
          'output_index': 0,
          'item': {'id': 'rs_1', 'type': 'reasoning', 'summary': []},
      },
      {
          'type': 'response.reasoning_summary_text.delta',
          'output_index': 0,
          'summary_index': 0,
          'delta': 'Think',
      },
      {
          'type': 'response.reasoning_summary_text.done',
          'output_index': 0,
          'summary_index': 0,
          'text': 'Think',
      },
      {
          'type': 'response.output_item.added',
          'output_index': 1,
          'item': {'id': 'msg_1', 'type': 'message', 'content': []},
      },
      {
          'type': 'response.output_text.delta',
          'output_index': 1,
          'content_index': 0,
          'delta': 'Hel',
      },
      {
          'type': 'response.output_text.delta',
          'output_index': 1,
          'content_index': 0,
          'delta': 'lo',
      },
      {
          'type': 'response.output_item.added',
          'output_index': 2,
          'item': {'id': 'rs_2', 'type': 'reasoning', 'summary': []},
      },
      {
          'type': 'response.reasoning_summary_text.delta',
          'output_index': 2,
          'summary_index': 0,
          'delta': 'Again',
      },
      {
          'type': 'response.output_item.added',
          'output_index': 3,
          'item': {'id': 'msg_2', 'type': 'message', 'content': []},
      },
      {
          'type': 'response.output_text.delta',
          'output_index': 3,
          'content_index': 0,
          'delta': 'Bye',
      },
  ])
  client = _CaptureClient(stream)
  llm = OpenAIResponsesLlm(model='gpt-5')
  llm.__dict__['_openai_client'] = client
  llm_request = LlmRequest(
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='Hi')])
      ]
  )

  responses = [
      item
      async for item in llm.generate_content_async(llm_request, stream=True)
  ]

  final_response = responses[-1]
  assert final_response.partial is False
  parts = final_response.content.parts
  assert [(part.text, part.thought) for part in parts] == [
      ('Think', True),
      ('Hello', None),
      ('Again', True),
      ('Bye', None),
  ]
  boundaries = [
      response
      for response in responses
      if response.custom_metadata
      and response.custom_metadata['openai_response']['stream_event'][
          'reasoning_done'
      ]
  ]
  assert [
      boundary.custom_metadata['openai_response']['stream_event']['type']
      for boundary in boundaries
  ] == [
      'response.reasoning_summary_text.done',
      'response.output_item.added',
  ]


@pytest.mark.asyncio
async def test_streaming_generation_aggregates_function_call_without_completed_event():
  """Streaming function-call events become a final ADK function call."""
  stream = _FakeAsyncStream([
      {
          'type': 'response.output_item.added',
          'output_index': 0,
          'item': {
              'type': 'function_call',
              'call_id': 'call_123',
              'name': 'get_weather',
              'arguments': '',
          },
      },
      {
          'type': 'response.function_call_arguments.delta',
          'output_index': 0,
          'delta': '{"location"',
      },
      {
          'type': 'response.function_call_arguments.delta',
          'output_index': 0,
          'delta': ': "Paris"}',
      },
  ])
  client = _CaptureClient(stream)
  llm = OpenAIResponsesLlm(model='gpt-5')
  llm.__dict__['_openai_client'] = client
  llm_request = LlmRequest(
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='Hi')])
      ]
  )

  responses = [
      item
      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 function_call.id == 'call_123'
  assert function_call.name == 'get_weather'
  assert function_call.args == {'location': 'Paris'}


@pytest.mark.asyncio
async def test_streaming_generation_uses_function_arguments_done_event():
  """Final function-call arguments can arrive in a done event."""
  stream = _FakeAsyncStream([
      {
          'type': 'response.output_item.added',
          'output_index': 0,
          'item': {
              'type': 'function_call',
              'call_id': 'call_123',
              'name': 'get_weather',
              'arguments': '',
          },
      },
      {
          'type': 'response.function_call_arguments.done',
          'output_index': 0,
          'arguments': '{"location": "Paris"}',
      },
  ])
  client = _CaptureClient(stream)
  llm = OpenAIResponsesLlm(model='gpt-5')
  llm.__dict__['_openai_client'] = client
  llm_request = LlmRequest(
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='Hi')])
      ]
  )

  responses = [
      item
      async for item in llm.generate_content_async(llm_request, stream=True)
  ]

  function_call = responses[0].content.parts[0].function_call
  assert function_call.id == 'call_123'
  assert function_call.args == {'location': 'Paris'}


@pytest.mark.asyncio
async def test_streaming_generation_failed_event_is_terminal():
  """A failed stream does not also emit a successful fallback final."""
  stream = _FakeAsyncStream([
      {'type': 'response.output_text.delta', 'delta': 'partial'},
      {'type': 'response.failed', 'response': {'id': 'resp_123'}},
  ])
  client = _CaptureClient(stream)
  llm = OpenAIResponsesLlm(model='gpt-5')
  llm.__dict__['_openai_client'] = client
  llm_request = LlmRequest(
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='Hi')])
      ]
  )

  responses = [
      item
      async for item in llm.generate_content_async(llm_request, stream=True)
  ]

  assert len(responses) == 2
  assert responses[0].partial is True
  assert responses[1].finish_reason == types.FinishReason.OTHER
  assert responses[1].error_code == types.FinishReason.OTHER


def test_azure_client_uses_openai_v1_base_url():
  """Azure model uses the Azure OpenAI /openai/v1 base URL."""
  with mock.patch(
      'google.adk.labs.openai._openai_responses_llm.AsyncOpenAI'
  ) as client_cls:
    llm = AzureOpenAIResponsesLlm(
        model='deployment',
        azure_endpoint='https://example.openai.azure.com/',
        api_key='key',
    )

    _ = llm._openai_client

  client_cls.assert_called_once_with(
      api_key='key',
      base_url='https://example.openai.azure.com/openai/v1/',
  )


def _user_request(**config_kwargs) -> LlmRequest:
  return LlmRequest(
      model='gpt-5',
      contents=[
          types.Content(role='user', parts=[types.Part.from_text(text='Hi')])
      ],
      config=types.GenerateContentConfig(**config_kwargs),
  )


def test_provided_client_is_used():
  """A pre-configured client is used verbatim instead of constructing one."""
  client = AsyncOpenAI(api_key='x')
  llm = OpenAIResponsesLlm(model='gpt-5', client=client)
  assert llm._openai_client is client


def test_default_client_built_with_resolved_api_key():
  """Without a client, AsyncOpenAI is constructed with the resolved key."""
  with mock.patch(
      'google.adk.labs.openai._openai_responses_llm.AsyncOpenAI'
  ) as client_cls:
    llm = OpenAIResponsesLlm(model='gpt-5', api_key='secret')
    _ = llm._openai_client

  client_cls.assert_called_once_with(api_key='secret')


def test_api_key_callable_is_resolved():
  """A sync api_key callable is invoked to produce the key."""
  with mock.patch(
      'google.adk.labs.openai._openai_responses_llm.AsyncOpenAI'
  ) as client_cls:
    llm = OpenAIResponsesLlm(model='gpt-5', api_key=lambda: 'dynamic')
    _ = llm._openai_client

  client_cls.assert_called_once_with(api_key='dynamic')


@pytest.mark.filterwarnings('ignore:coroutine .* was never awaited')
def test_async_api_key_callable_raises():
  """An async api_key provider fails fast instead of leaking a coroutine."""

  async def _key() -> str:
    return 'k'

  llm = OpenAIResponsesLlm(model='gpt-5', api_key=_key)
  with pytest.raises(TypeError, match='Async api_key'):
    llm._resolve_api_key()


def test_azure_api_key_env_fallback(monkeypatch):
  """Azure falls back to AZURE_OPENAI_API_KEY when no key is provided."""
  monkeypatch.setenv('AZURE_OPENAI_API_KEY', 'env-key')
  llm = AzureOpenAIResponsesLlm(
      model='deployment',
      azure_endpoint='https://example.openai.azure.com/',
  )
  assert llm._resolve_api_key() == 'env-key'


def test_extra_request_args_override_and_merge_extra_body():
  """extra_request_args overrides kwargs but merges (not clobbers) extra_body."""
  llm = OpenAIResponsesLlm(
      model='gpt-5',
      extra_request_args={'temperature': 0.9, 'extra_body': {'foo': 'bar'}},
  )
  kwargs = llm._get_response_create_kwargs(
      _user_request(temperature=0.1, stop_sequences=['STOP']), stream=False
  )

  assert kwargs['temperature'] == 0.9
  assert kwargs['extra_body'] == {'stop': ['STOP'], 'foo': 'bar'}


def test_structured_output_schema_name_is_sanitized():
  """Schema names are sanitized to OpenAI's ^[a-zA-Z0-9_-]+$ requirement."""
  llm = OpenAIResponsesLlm(model='gpt-5')
  kwargs = llm._get_response_create_kwargs(
      _user_request(
          response_json_schema={
              'title': 'My Schema!',
              'type': 'object',
              'properties': {'x': {'type': 'integer'}},
          }
      ),
      stream=False,
  )

  assert kwargs['text']['format']['name'] == 'My_Schema_'


def test_enforce_strict_openai_schema_handles_nested_refs():
  """Strict transform recurses into $defs, properties, anyOf, and items."""
  schema = {
      'type': 'object',
      'properties': {
          'items': {'type': 'array', 'items': {'$ref': '#/$defs/Item'}},
          'choice': {'anyOf': [{'type': 'string'}, {'type': 'integer'}]},
      },
      '$defs': {
          'Item': {'type': 'object', 'properties': {'n': {'type': 'integer'}}}
      },
  }

  enforce_strict_openai_schema(schema)

  assert schema['additionalProperties'] is False
  assert schema['required'] == ['choice', 'items']
  assert schema['$defs']['Item']['additionalProperties'] is False
  assert schema['$defs']['Item']['required'] == ['n']


@pytest.mark.parametrize(
    ('mode', 'expected'),
    [
        (types.FunctionCallingConfigMode.ANY, 'required'),
        (types.FunctionCallingConfigMode.NONE, 'none'),
        (types.FunctionCallingConfigMode.AUTO, 'auto'),
    ],
)
def test_tool_choice_maps_function_calling_mode(mode, expected):
  """function_calling_config.mode maps to the Responses tool_choice value."""
  config = types.GenerateContentConfig(
      tool_config=types.ToolConfig(
          function_calling_config=types.FunctionCallingConfig(mode=mode)
      )
  )
  assert _tool_choice(config) == expected


def test_response_parsing_incomplete_max_tokens_sets_error():
  """An incomplete max-tokens response maps to MAX_TOKENS with an error."""
  response = {
      'id': 'resp_1',
      'model': 'gpt-5',
      'status': 'incomplete',
      'incomplete_details': {'reason': 'max_output_tokens'},
      'output': [],
  }

  llm_response = _response_to_llm_response(response)

  assert llm_response.finish_reason == types.FinishReason.MAX_TOKENS
  assert llm_response.error_code == types.FinishReason.MAX_TOKENS
  assert 'max_output_tokens' in llm_response.error_message


def test_response_parsing_failed_status_sets_error():
  """A failed response maps to OTHER and surfaces the error payload."""
  response = {
      'id': 'resp_1',
      'model': 'gpt-5',
      'status': 'failed',
      'error': {'message': 'boom'},
      'output': [],
  }

  llm_response = _response_to_llm_response(response)

  assert llm_response.finish_reason == types.FinishReason.OTHER
  assert llm_response.error_code == types.FinishReason.OTHER
  assert 'boom' in llm_response.error_message


def test_response_parsing_maps_refusal_to_prefixed_text():
  """Refusal content becomes prefixed text rather than being dropped."""
  response = {
      'id': 'resp_1',
      'model': 'gpt-5',
      'status': 'completed',
      'output': [{
          'type': 'message',
          'role': 'assistant',
          'content': [{'type': 'refusal', 'refusal': 'I cannot help.'}],
      }],
  }

  llm_response = _response_to_llm_response(response)

  assert llm_response.content.parts[0].text == 'OpenAI refusal: I cannot help.'


def test_loads_json_object_handles_malformed_arguments():
  """Malformed or non-object function arguments degrade to an empty dict."""
  assert _loads_json_object('not json') == {}
  assert _loads_json_object('[1, 2]') == {}
  assert _loads_json_object('') == {}
  assert _loads_json_object('{"a": 1}') == {'a': 1}


def test_code_parts_handle_missing_inner_fields():
  """Code parts with unset code/output do not crash the conversion."""
  content = types.Content(
      role='user',
      parts=[
          types.Part(executable_code=types.ExecutableCode(language='PYTHON')),
      ],
  )

  items = _content_to_response_input_items(content)

  assert items[0]['content'][0]['text'] == 'Code:```python\n\n```'


@pytest.mark.asyncio
async def test_streaming_incomplete_event_sets_max_tokens():
  """A streamed incomplete response yields a MAX_TOKENS final response."""
  stream = _FakeAsyncStream([
      {'type': 'response.output_text.delta', 'delta': 'Hi'},
      {
          'type': 'response.incomplete',
          'response': {
              'id': 'resp_stream',
              'model': 'gpt-5',
              'status': 'incomplete',
              'incomplete_details': {'reason': 'max_output_tokens'},
              'output': [{
                  'type': 'message',
                  'role': 'assistant',
                  'content': [{'type': 'output_text', 'text': 'Hi'}],
              }],
          },
      },
  ])
  llm = OpenAIResponsesLlm(model='gpt-5')
  llm.__dict__['_openai_client'] = _CaptureClient(stream)

  responses = [
      item
      async for item in llm.generate_content_async(_user_request(), stream=True)
  ]

  assert responses[-1].finish_reason == types.FinishReason.MAX_TOKENS


@pytest.mark.asyncio
async def test_streaming_output_item_done_uses_done_item_text():
  """A done output item without a completed response feeds the fallback final."""
  stream = _FakeAsyncStream([
      {
          'type': 'response.output_item.added',
          'output_index': 0,
          'item': {'type': 'message', 'content': []},
      },
      {
          'type': 'response.output_item.done',
          'output_index': 0,
          'item': {
              'type': 'message',
              'role': 'assistant',
              'content': [{'type': 'output_text', 'text': 'Done text'}],
          },
      },
  ])
  llm = OpenAIResponsesLlm(model='gpt-5')
  llm.__dict__['_openai_client'] = _CaptureClient(stream)

  responses = [
      item
      async for item in llm.generate_content_async(_user_request(), stream=True)
  ]

  assert responses[-1].content.parts[0].text == 'Done text'
