chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
@@ -0,0 +1,201 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import Any, cast
|
||||
from unittest.mock import patch
|
||||
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||
from pytest import fixture
|
||||
|
||||
|
||||
def _reset_env(monkeypatch, env_names: list[str]) -> None: # type: ignore
|
||||
for env_name in env_names:
|
||||
monkeypatch.delenv(env_name, raising=False) # type: ignore
|
||||
|
||||
|
||||
# region Connector Settings fixtures
|
||||
@fixture
|
||||
def exclude_list(request: Any) -> list[str]:
|
||||
"""Fixture that returns a list of environment variables to exclude."""
|
||||
return request.param if hasattr(request, "param") else []
|
||||
|
||||
|
||||
@fixture
|
||||
def override_env_param_dict(request: Any) -> dict[str, str]:
|
||||
"""Fixture that returns a dict of environment variables to override."""
|
||||
return request.param if hasattr(request, "param") else {}
|
||||
|
||||
|
||||
@fixture()
|
||||
def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore
|
||||
"""Fixture to set environment variables for OpenAISettings."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
_reset_env(
|
||||
monkeypatch,
|
||||
[
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_ORG_ID",
|
||||
"OPENAI_MODEL",
|
||||
"OPENAI_EMBEDDING_MODEL",
|
||||
"OPENAI_CHAT_COMPLETION_MODEL",
|
||||
"OPENAI_CHAT_MODEL",
|
||||
"OPENAI_API_VERSION",
|
||||
"OPENAI_BASE_URL",
|
||||
"AZURE_OPENAI_ENDPOINT",
|
||||
"AZURE_OPENAI_BASE_URL",
|
||||
"AZURE_OPENAI_API_KEY",
|
||||
"AZURE_OPENAI_CHAT_COMPLETION_MODEL",
|
||||
"AZURE_OPENAI_CHAT_MODEL",
|
||||
"AZURE_OPENAI_EMBEDDING_MODEL",
|
||||
"AZURE_OPENAI_MODEL",
|
||||
"AZURE_OPENAI_API_VERSION",
|
||||
],
|
||||
)
|
||||
|
||||
env_vars = {
|
||||
"OPENAI_API_KEY": "test-dummy-key",
|
||||
"OPENAI_ORG_ID": "test_org_id",
|
||||
"OPENAI_MODEL": "test_model",
|
||||
"OPENAI_EMBEDDING_MODEL": "test_embedding_model",
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict) # type: ignore
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key in exclude_list:
|
||||
monkeypatch.delenv(key, raising=False) # type: ignore
|
||||
continue
|
||||
monkeypatch.setenv(key, value) # type: ignore
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@fixture()
|
||||
def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore
|
||||
"""Fixture to set environment variables for Azure-backed OpenAI tests."""
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
_reset_env(
|
||||
monkeypatch,
|
||||
[
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_ORG_ID",
|
||||
"OPENAI_MODEL",
|
||||
"OPENAI_EMBEDDING_MODEL",
|
||||
"OPENAI_CHAT_COMPLETION_MODEL",
|
||||
"OPENAI_CHAT_MODEL",
|
||||
"OPENAI_API_VERSION",
|
||||
"OPENAI_BASE_URL",
|
||||
"AZURE_OPENAI_ENDPOINT",
|
||||
"AZURE_OPENAI_BASE_URL",
|
||||
"AZURE_OPENAI_API_KEY",
|
||||
"AZURE_OPENAI_CHAT_COMPLETION_MODEL",
|
||||
"AZURE_OPENAI_CHAT_MODEL",
|
||||
"AZURE_OPENAI_EMBEDDING_MODEL",
|
||||
"AZURE_OPENAI_MODEL",
|
||||
"AZURE_OPENAI_API_VERSION",
|
||||
],
|
||||
)
|
||||
|
||||
env_vars = {
|
||||
"AZURE_OPENAI_ENDPOINT": "https://test-endpoint.openai.azure.com",
|
||||
"AZURE_OPENAI_CHAT_COMPLETION_MODEL": "test_chat_deployment",
|
||||
"AZURE_OPENAI_CHAT_MODEL": "test_responses_deployment",
|
||||
"AZURE_OPENAI_EMBEDDING_MODEL": "test_embedding_deployment",
|
||||
"AZURE_OPENAI_MODEL": "test_deployment",
|
||||
"AZURE_OPENAI_API_KEY": "test_api_key",
|
||||
"AZURE_OPENAI_API_VERSION": "2024-12-01-preview",
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict) # type: ignore
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key in exclude_list:
|
||||
monkeypatch.delenv(key, raising=False) # type: ignore
|
||||
continue
|
||||
monkeypatch.setenv(key, value) # type: ignore
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
# region Observability fixtures
|
||||
@fixture
|
||||
def enable_instrumentation(request: Any) -> bool:
|
||||
"""Fixture that returns a boolean indicating if Otel is enabled."""
|
||||
return request.param if hasattr(request, "param") else True
|
||||
|
||||
|
||||
@fixture
|
||||
def enable_sensitive_data(request: Any) -> bool:
|
||||
"""Fixture that returns a boolean indicating if sensitive data is enabled."""
|
||||
return request.param if hasattr(request, "param") else True
|
||||
|
||||
|
||||
@fixture
|
||||
def span_exporter(monkeypatch, enable_instrumentation: bool, enable_sensitive_data: bool) -> Generator[SpanExporter]:
|
||||
"""Fixture to remove environment variables for ObservabilitySettings."""
|
||||
env_vars = [
|
||||
"ENABLE_INSTRUMENTATION",
|
||||
"ENABLE_SENSITIVE_DATA",
|
||||
"ENABLE_CONSOLE_EXPORTERS",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_HEADERS",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_HEADERS",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_HEADERS",
|
||||
"OTEL_SERVICE_NAME",
|
||||
"OTEL_SERVICE_VERSION",
|
||||
"OTEL_RESOURCE_ATTRIBUTES",
|
||||
]
|
||||
|
||||
for key in env_vars:
|
||||
monkeypatch.delenv(key, raising=False) # type: ignore
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", str(enable_instrumentation)) # type: ignore
|
||||
if not enable_instrumentation:
|
||||
enable_sensitive_data = False
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", str(enable_sensitive_data)) # type: ignore
|
||||
import importlib
|
||||
|
||||
import agent_framework.observability as observability
|
||||
from opentelemetry import trace
|
||||
|
||||
importlib.reload(observability)
|
||||
|
||||
observability_settings = observability.ObservabilitySettings()
|
||||
|
||||
if enable_instrumentation or enable_sensitive_data:
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
|
||||
tracer_provider = TracerProvider(resource=cast(Any, observability_settings)._resource)
|
||||
trace.set_tracer_provider(tracer_provider)
|
||||
|
||||
monkeypatch.setattr(observability, "OBSERVABILITY_SETTINGS", observability_settings, raising=False) # type: ignore
|
||||
|
||||
with (
|
||||
patch("agent_framework.observability.OBSERVABILITY_SETTINGS", observability_settings),
|
||||
patch("agent_framework.observability.configure_otel_providers"),
|
||||
):
|
||||
exporter = InMemorySpanExporter()
|
||||
if enable_instrumentation or enable_sensitive_data:
|
||||
current_tracer_provider = trace.get_tracer_provider()
|
||||
if not hasattr(current_tracer_provider, "add_span_processor"):
|
||||
raise RuntimeError("Tracer provider does not support adding span processors.")
|
||||
|
||||
current_tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) # type: ignore
|
||||
|
||||
yield exporter
|
||||
exporter.clear()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,540 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from types import TracebackType
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import Agent, AgentResponse, ChatResponse, Content, Message, SupportsChatGetResponse, tool
|
||||
from agent_framework.exceptions import SettingNotFoundError
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from openai import AsyncAzureOpenAI
|
||||
from pydantic import BaseModel
|
||||
from pytest import param
|
||||
|
||||
from agent_framework_openai import OpenAIChatClient
|
||||
|
||||
pytestmark = pytest.mark.azure
|
||||
|
||||
skip_if_azure_openai_integration_tests_disabled = pytest.mark.skip(
|
||||
reason="Azure OpenAI integration tests temporarily disabled: crashes the xdist runner in CI.",
|
||||
)
|
||||
|
||||
|
||||
def _with_azure_openai_debug() -> Any:
|
||||
def decorator(func: Any) -> Any:
|
||||
@wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
model = os.getenv("AZURE_OPENAI_CHAT_MODEL") or os.getenv("AZURE_OPENAI_MODEL", "<unset>")
|
||||
api_version = os.getenv("AZURE_OPENAI_API_VERSION") or "preview"
|
||||
endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "<unset>")
|
||||
debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}"
|
||||
if hasattr(exc, "add_note"):
|
||||
cast(Any, exc).add_note(debug_message)
|
||||
elif exc.args:
|
||||
exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:])
|
||||
else:
|
||||
exc.args = (debug_message,)
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class OutputStruct(BaseModel):
|
||||
"""A structured output for testing purposes."""
|
||||
|
||||
location: str
|
||||
weather: str | None = None
|
||||
|
||||
|
||||
async def create_vector_store(client: OpenAIChatClient) -> tuple[str, Content]:
|
||||
"""Create a vector store with sample documents for testing."""
|
||||
file = await client.client.files.create(
|
||||
file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."),
|
||||
purpose="assistants",
|
||||
)
|
||||
vector_store = await client.client.vector_stores.create(
|
||||
name="knowledge_base",
|
||||
expires_after={"anchor": "last_active_at", "days": 1},
|
||||
)
|
||||
result = await client.client.vector_stores.files.create_and_poll(
|
||||
vector_store_id=vector_store.id,
|
||||
file_id=file.id,
|
||||
poll_interval_ms=1000,
|
||||
)
|
||||
if result.last_error is not None:
|
||||
raise RuntimeError(f"Vector store file processing failed with status: {result.last_error.message}")
|
||||
|
||||
# Wait for the vector store index to be fully searchable.
|
||||
# create_and_poll confirms file processing, but the search index is eventually consistent.
|
||||
for _ in range(10):
|
||||
vs = await client.client.vector_stores.retrieve(vector_store.id)
|
||||
if vs.file_counts.completed >= 1 and vs.file_counts.in_progress == 0:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id)
|
||||
|
||||
|
||||
async def delete_vector_store(client: OpenAIChatClient, file_id: str, vector_store_id: str) -> None:
|
||||
"""Delete the vector store after tests."""
|
||||
|
||||
await client.client.vector_stores.delete(vector_store_id=vector_store_id)
|
||||
await client.client.files.delete(file_id=file_id)
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
async def get_weather(location: str) -> str:
|
||||
"""Get the current weather in a given location."""
|
||||
return f"The current weather in {location} is sunny."
|
||||
|
||||
|
||||
def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
client = OpenAIChatClient(credential=cast(Any, AzureCliCredential()))
|
||||
|
||||
assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_MODEL"]
|
||||
assert isinstance(client, SupportsChatGetResponse)
|
||||
assert isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert client.OTEL_PROVIDER_NAME == "azure.ai.openai"
|
||||
assert client.azure_endpoint is not None
|
||||
assert client.azure_endpoint.startswith(azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"])
|
||||
|
||||
|
||||
def test_init_auto_detects_azure_env(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
client = OpenAIChatClient()
|
||||
|
||||
assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_MODEL"]
|
||||
assert isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"]
|
||||
|
||||
|
||||
def test_openai_api_key_wins_over_azure_env(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key")
|
||||
monkeypatch.setenv("OPENAI_MODEL", "gpt-5")
|
||||
|
||||
client = OpenAIChatClient()
|
||||
|
||||
assert client.model == "gpt-5"
|
||||
assert not isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert client.azure_endpoint is None
|
||||
|
||||
|
||||
def test_api_version_alone_does_not_override_openai_api_key(
|
||||
monkeypatch, azure_openai_unit_test_env: dict[str, str]
|
||||
) -> None:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key")
|
||||
monkeypatch.setenv("OPENAI_MODEL", "gpt-5")
|
||||
|
||||
client = OpenAIChatClient(api_version="2024-10-21")
|
||||
|
||||
assert client.model == "gpt-5"
|
||||
assert not isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert client.azure_endpoint is None
|
||||
|
||||
|
||||
def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key")
|
||||
monkeypatch.setenv("OPENAI_MODEL", "gpt-5")
|
||||
|
||||
client = OpenAIChatClient(credential=lambda: "token")
|
||||
|
||||
assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_MODEL"]
|
||||
assert isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"]
|
||||
|
||||
|
||||
def test_init_falls_back_to_generic_azure_deployment_env(
|
||||
monkeypatch, azure_openai_unit_test_env: dict[str, str]
|
||||
) -> None:
|
||||
monkeypatch.delenv("AZURE_OPENAI_CHAT_MODEL", raising=False)
|
||||
|
||||
client = OpenAIChatClient()
|
||||
|
||||
assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_MODEL"]
|
||||
assert isinstance(client.client, AsyncAzureOpenAI)
|
||||
|
||||
|
||||
def test_init_does_not_fall_back_to_openai_responses_model_for_azure_env(
|
||||
monkeypatch, azure_openai_unit_test_env: dict[str, str]
|
||||
) -> None:
|
||||
monkeypatch.delenv("AZURE_OPENAI_CHAT_MODEL", raising=False)
|
||||
monkeypatch.delenv("AZURE_OPENAI_MODEL", raising=False)
|
||||
monkeypatch.setenv("OPENAI_CHAT_MODEL", "test_responses_model")
|
||||
|
||||
with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a model"):
|
||||
OpenAIChatClient()
|
||||
|
||||
|
||||
def test_init_does_not_fall_back_to_openai_model_for_azure_env(
|
||||
monkeypatch, azure_openai_unit_test_env: dict[str, str]
|
||||
) -> None:
|
||||
monkeypatch.delenv("AZURE_OPENAI_CHAT_MODEL", raising=False)
|
||||
monkeypatch.delenv("AZURE_OPENAI_MODEL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_CHAT_MODEL", raising=False)
|
||||
monkeypatch.setenv("OPENAI_MODEL", "gpt-5")
|
||||
|
||||
with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a model"):
|
||||
OpenAIChatClient()
|
||||
|
||||
|
||||
def test_init_with_credential_wraps_async_token_credential(
|
||||
monkeypatch, azure_openai_unit_test_env: dict[str, str]
|
||||
) -> None:
|
||||
class TestAsyncTokenCredential(AsyncTokenCredential):
|
||||
async def get_token(self, *scopes: str, **kwargs: object):
|
||||
raise NotImplementedError
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: TracebackType | None = None,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key")
|
||||
monkeypatch.setenv("OPENAI_MODEL", "gpt-5")
|
||||
credential = TestAsyncTokenCredential()
|
||||
token_provider = MagicMock()
|
||||
|
||||
with patch("azure.identity.aio.get_bearer_token_provider", return_value=token_provider) as mock_provider:
|
||||
client = OpenAIChatClient(credential=cast(Any, credential))
|
||||
|
||||
assert isinstance(client.client, AsyncAzureOpenAI)
|
||||
mock_provider.assert_called_once_with(credential, "https://cognitiveservices.azure.com/.default")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_VERSION"]], indirect=True)
|
||||
def test_init_uses_default_azure_api_version(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
client = OpenAIChatClient(credential=cast(Any, AzureCliCredential()))
|
||||
|
||||
assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_MODEL"]
|
||||
assert client.api_version is not None
|
||||
|
||||
|
||||
def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key")
|
||||
monkeypatch.setenv("OPENAI_MODEL", "gpt-5")
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "https://custom-openai-endpoint.com/v1")
|
||||
|
||||
client = OpenAIChatClient()
|
||||
|
||||
assert client.model == "gpt-5"
|
||||
assert not isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert client.azure_endpoint is None
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@pytest.mark.parametrize(
|
||||
"option_name,option_value,needs_validation",
|
||||
[
|
||||
param("max_tokens", 500, False, id="max_tokens"),
|
||||
param("seed", 123, False, id="seed"),
|
||||
param("user", "test-user-id", False, id="user"),
|
||||
param("metadata", {"test_key": "test_value"}, False, id="metadata"),
|
||||
param("frequency_penalty", 0.5, False, id="frequency_penalty"),
|
||||
param("presence_penalty", 0.3, False, id="presence_penalty"),
|
||||
param("stop", ["END"], False, id="stop"),
|
||||
param("allow_multiple_tool_calls", True, False, id="allow_multiple_tool_calls"),
|
||||
param("tool_choice", "none", True, id="tool_choice_none"),
|
||||
param("safety_identifier", "user-hash-abc123", False, id="safety_identifier"),
|
||||
param("truncation", "auto", False, id="truncation"),
|
||||
param("prompt_cache_key", "test-cache-key", False, id="prompt_cache_key"),
|
||||
param("max_tool_calls", 3, False, id="max_tool_calls"),
|
||||
param("tools", [get_weather], True, id="tools_function"),
|
||||
param("tool_choice", "auto", True, id="tool_choice_auto"),
|
||||
param(
|
||||
"tool_choice",
|
||||
{"mode": "required", "required_function_name": "get_weather"},
|
||||
True,
|
||||
id="tool_choice_required",
|
||||
),
|
||||
param("response_format", OutputStruct, True, id="response_format_pydantic"),
|
||||
param(
|
||||
"response_format",
|
||||
{
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "WeatherDigest",
|
||||
"strict": True,
|
||||
"schema": {
|
||||
"title": "WeatherDigest",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {"type": "string"},
|
||||
"conditions": {"type": "string"},
|
||||
"temperature_c": {"type": "number"},
|
||||
"advisory": {"type": "string"},
|
||||
},
|
||||
"required": ["location", "conditions", "temperature_c", "advisory"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
True,
|
||||
id="response_format_runtime_json_schema",
|
||||
),
|
||||
],
|
||||
)
|
||||
@_with_azure_openai_debug()
|
||||
async def test_integration_options(
|
||||
option_name: str,
|
||||
option_value: Any,
|
||||
needs_validation: bool,
|
||||
) -> None:
|
||||
async with AzureCliCredential() as credential:
|
||||
client = OpenAIChatClient(credential=cast(Any, credential))
|
||||
client.function_invocation_configuration["max_iterations"] = 2
|
||||
|
||||
for streaming in [False, True]:
|
||||
if option_name in {"tools", "tool_choice"}:
|
||||
messages = [Message(role="user", contents=["What is the weather in Seattle?"])]
|
||||
elif option_name == "response_format":
|
||||
messages = [
|
||||
Message(role="user", contents=["The weather in Seattle is sunny"]),
|
||||
Message(role="user", contents=["What is the weather in Seattle?"]),
|
||||
]
|
||||
else:
|
||||
messages = [Message(role="user", contents=["Say 'Hello World' briefly."])]
|
||||
|
||||
options: dict[str, Any] = {option_name: option_value}
|
||||
if option_name == "tool_choice":
|
||||
options["tools"] = [get_weather]
|
||||
|
||||
if streaming:
|
||||
response = (
|
||||
await cast(Any, client)
|
||||
.get_response(
|
||||
messages=messages,
|
||||
stream=True,
|
||||
options=options,
|
||||
)
|
||||
.get_final_response()
|
||||
)
|
||||
else:
|
||||
response = await cast(Any, client).get_response(messages=messages, options=options)
|
||||
|
||||
assert isinstance(response, ChatResponse)
|
||||
assert response.text is not None
|
||||
assert len(response.text) > 0
|
||||
|
||||
if needs_validation:
|
||||
if option_name in {"tools", "tool_choice"}:
|
||||
text = response.text.lower()
|
||||
assert "sunny" in text or "seattle" in text
|
||||
elif option_name == "response_format":
|
||||
if option_value == OutputStruct:
|
||||
assert response.value is not None
|
||||
assert isinstance(response.value, OutputStruct)
|
||||
assert "seattle" in response.value.location.lower()
|
||||
else:
|
||||
assert response.value is not None
|
||||
assert isinstance(response.value, dict)
|
||||
assert "location" in response.value
|
||||
assert "seattle" in response.value["location"].lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
async def test_integration_web_search() -> None:
|
||||
async with AzureCliCredential() as credential:
|
||||
client = OpenAIChatClient(credential=cast(Any, credential))
|
||||
|
||||
response = await client.get_response(
|
||||
messages=[
|
||||
Message(
|
||||
role="user",
|
||||
contents=["What is the current weather? Do not ask for my current location."],
|
||||
)
|
||||
],
|
||||
options={
|
||||
"tools": [OpenAIChatClient.get_web_search_tool(user_location={"country": "US", "city": "Seattle"})],
|
||||
},
|
||||
stream=True,
|
||||
).get_final_response()
|
||||
assert isinstance(response, ChatResponse)
|
||||
assert response.text is not None
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
async def test_integration_client_file_search() -> None:
|
||||
async with AzureCliCredential() as credential:
|
||||
client = OpenAIChatClient(credential=cast(Any, credential))
|
||||
file_id, vector_store = await create_vector_store(client)
|
||||
vector_store_id = vector_store.vector_store_id
|
||||
assert vector_store_id is not None
|
||||
try:
|
||||
response = await cast(Any, client).get_response(
|
||||
messages=[
|
||||
Message(role="user", contents=["What is the weather today? Do a file search to find the answer."])
|
||||
],
|
||||
options={
|
||||
"tools": [OpenAIChatClient.get_file_search_tool(vector_store_ids=[vector_store_id])],
|
||||
"tool_choice": "auto",
|
||||
},
|
||||
)
|
||||
|
||||
assert "sunny" in response.text.lower()
|
||||
assert "75" in response.text
|
||||
finally:
|
||||
await delete_vector_store(client, file_id, vector_store_id)
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
async def test_integration_client_file_search_streaming() -> None:
|
||||
async with AzureCliCredential() as credential:
|
||||
client = OpenAIChatClient(credential=cast(Any, credential))
|
||||
file_id, vector_store = await create_vector_store(client)
|
||||
vector_store_id = vector_store.vector_store_id
|
||||
assert vector_store_id is not None
|
||||
try:
|
||||
response_stream = cast(Any, client).get_response(
|
||||
messages=[
|
||||
Message(role="user", contents=["What is the weather today? Do a file search to find the answer."])
|
||||
],
|
||||
stream=True,
|
||||
options={
|
||||
"tools": [OpenAIChatClient.get_file_search_tool(vector_store_ids=[vector_store_id])],
|
||||
"tool_choice": "auto",
|
||||
},
|
||||
)
|
||||
|
||||
full_response = await response_stream.get_final_response()
|
||||
assert "sunny" in full_response.text.lower()
|
||||
assert "75" in full_response.text
|
||||
finally:
|
||||
await delete_vector_store(client, file_id, vector_store_id)
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
async def test_integration_client_agent_hosted_mcp_tool() -> None:
|
||||
async with AzureCliCredential() as credential:
|
||||
client = OpenAIChatClient(credential=cast(Any, credential))
|
||||
response = await client.get_response(
|
||||
messages=[Message(role="user", contents=["How to create an Azure storage account using az cli?"])],
|
||||
options={
|
||||
"max_tokens": 5000,
|
||||
"tools": OpenAIChatClient.get_mcp_tool(
|
||||
name="Microsoft Learn MCP",
|
||||
url="https://learn.microsoft.com/api/mcp",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
assert isinstance(response, ChatResponse)
|
||||
if not response.text:
|
||||
pytest.skip("MCP server returned empty response - service-side issue")
|
||||
assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
async def test_integration_client_agent_hosted_code_interpreter_tool() -> None:
|
||||
async with AzureCliCredential() as credential:
|
||||
client = OpenAIChatClient(credential=cast(Any, credential))
|
||||
|
||||
response = await client.get_response(
|
||||
messages=[Message(role="user", contents=["Calculate the sum of numbers from 1 to 10 using Python code."])],
|
||||
options={"tools": [OpenAIChatClient.get_code_interpreter_tool()]},
|
||||
)
|
||||
|
||||
contains_relevant_content = any(
|
||||
term in response.text.lower() for term in ["55", "sum", "code", "python", "calculate", "10"]
|
||||
)
|
||||
assert contains_relevant_content or len(response.text.strip()) > 10
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
async def test_integration_client_agent_existing_session() -> None:
|
||||
async with AzureCliCredential() as credential:
|
||||
preserved_session = None
|
||||
|
||||
async with Agent(
|
||||
client=OpenAIChatClient(credential=cast(Any, credential)),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as first_agent:
|
||||
session = first_agent.create_session()
|
||||
first_response = await first_agent.run(
|
||||
"My hobby is photography. Remember this.",
|
||||
session=session,
|
||||
options={"store": True},
|
||||
)
|
||||
|
||||
assert isinstance(first_response, AgentResponse)
|
||||
preserved_session = session
|
||||
|
||||
if preserved_session:
|
||||
async with Agent(
|
||||
client=OpenAIChatClient(credential=cast(Any, credential)),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as second_agent:
|
||||
second_response = await second_agent.run(
|
||||
"What is my hobby?", session=preserved_session, options={"store": True}
|
||||
)
|
||||
|
||||
assert isinstance(second_response, AgentResponse)
|
||||
assert second_response.text is not None
|
||||
assert "photography" in second_response.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
@pytest.mark.skip(reason="Azure OpenAI is flaky when handling image content as function result. Needs investigation.")
|
||||
async def test_azure_openai_chat_client_tool_rich_content_image() -> None:
|
||||
image_path = Path(__file__).parent.parent / "assets" / "sample_image.jpg"
|
||||
image_bytes = image_path.read_bytes()
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def get_test_image() -> Content:
|
||||
"""Return a test image for analysis."""
|
||||
return Content.from_data(data=image_bytes, media_type="image/jpeg")
|
||||
|
||||
async with AzureCliCredential() as credential:
|
||||
client = OpenAIChatClient(credential=cast(Any, credential))
|
||||
client.function_invocation_configuration["max_iterations"] = 2
|
||||
|
||||
response = await client.get_response(
|
||||
messages=[Message(role="user", contents=["Call the get_test_image tool and describe what you see."])],
|
||||
stream=True,
|
||||
options={"tools": [get_test_image], "tool_choice": "auto"},
|
||||
).get_final_response()
|
||||
|
||||
assert isinstance(response, ChatResponse)
|
||||
assert response.text is not None
|
||||
assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,418 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import wraps
|
||||
from types import TracebackType
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
Message,
|
||||
SupportsChatGetResponse,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.exceptions import SettingNotFoundError
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from openai import AsyncAzureOpenAI
|
||||
|
||||
from agent_framework_openai import OpenAIChatCompletionClient
|
||||
|
||||
pytestmark = pytest.mark.azure
|
||||
|
||||
skip_if_azure_openai_integration_tests_disabled = pytest.mark.skip(
|
||||
reason="Azure OpenAI integration tests temporarily disabled: crashes the xdist runner in CI.",
|
||||
)
|
||||
|
||||
|
||||
def _with_azure_openai_debug() -> Any:
|
||||
def decorator(func: Any) -> Any:
|
||||
@wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
model = os.getenv("AZURE_OPENAI_CHAT_COMPLETION_MODEL") or os.getenv("AZURE_OPENAI_MODEL", "<unset>")
|
||||
api_version = os.getenv("AZURE_OPENAI_API_VERSION", "<unset>")
|
||||
endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "<unset>")
|
||||
debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}"
|
||||
if hasattr(exc, "add_note"):
|
||||
cast(Any, exc).add_note(debug_message)
|
||||
elif exc.args:
|
||||
exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:])
|
||||
else:
|
||||
exc.args = (debug_message,)
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def get_story_text() -> str:
|
||||
"""Returns a story about Emily and David."""
|
||||
return (
|
||||
"Emily and David, two passionate scientists, met during a research expedition to Antarctica. "
|
||||
"Bonded by their love for the natural world and shared curiosity, they uncovered a "
|
||||
"groundbreaking phenomenon in glaciology that could potentially reshape our understanding "
|
||||
"of climate change."
|
||||
)
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
async def get_weather(location: str) -> str:
|
||||
"""Get the current weather in a given location."""
|
||||
return f"The current weather in {location} is sunny, 72F."
|
||||
|
||||
|
||||
def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
client = OpenAIChatCompletionClient(azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"))
|
||||
|
||||
assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_COMPLETION_MODEL"]
|
||||
assert isinstance(client, SupportsChatGetResponse)
|
||||
assert isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert client.OTEL_PROVIDER_NAME == "azure.ai.openai"
|
||||
assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"]
|
||||
assert client.api_version == azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"]
|
||||
|
||||
|
||||
def test_init_auto_detects_azure_env(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
client = OpenAIChatCompletionClient()
|
||||
|
||||
assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_COMPLETION_MODEL"]
|
||||
assert isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"]
|
||||
|
||||
|
||||
def test_openai_api_key_wins_over_azure_env(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key")
|
||||
monkeypatch.setenv("OPENAI_MODEL", "gpt-5")
|
||||
|
||||
client = OpenAIChatCompletionClient()
|
||||
|
||||
assert client.model == "gpt-5"
|
||||
assert not isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert client.azure_endpoint is None
|
||||
|
||||
|
||||
def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key")
|
||||
monkeypatch.setenv("OPENAI_MODEL", "gpt-5")
|
||||
|
||||
client = OpenAIChatCompletionClient(credential=lambda: "token")
|
||||
|
||||
assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_COMPLETION_MODEL"]
|
||||
assert isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"]
|
||||
|
||||
|
||||
def test_init_falls_back_to_generic_azure_deployment_env(
|
||||
monkeypatch, azure_openai_unit_test_env: dict[str, str]
|
||||
) -> None:
|
||||
monkeypatch.delenv("AZURE_OPENAI_CHAT_COMPLETION_MODEL", raising=False)
|
||||
|
||||
client = OpenAIChatCompletionClient()
|
||||
|
||||
assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_MODEL"]
|
||||
assert isinstance(client.client, AsyncAzureOpenAI)
|
||||
|
||||
|
||||
def test_init_does_not_fall_back_to_openai_chat_model_for_azure_env(
|
||||
monkeypatch, azure_openai_unit_test_env: dict[str, str]
|
||||
) -> None:
|
||||
monkeypatch.delenv("AZURE_OPENAI_CHAT_COMPLETION_MODEL", raising=False)
|
||||
monkeypatch.delenv("AZURE_OPENAI_MODEL", raising=False)
|
||||
monkeypatch.setenv("OPENAI_CHAT_COMPLETION_MODEL", "test_chat_model")
|
||||
|
||||
with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a model"):
|
||||
OpenAIChatCompletionClient()
|
||||
|
||||
|
||||
def test_init_does_not_fall_back_to_openai_model_for_azure_env(
|
||||
monkeypatch, azure_openai_unit_test_env: dict[str, str]
|
||||
) -> None:
|
||||
monkeypatch.delenv("AZURE_OPENAI_CHAT_COMPLETION_MODEL", raising=False)
|
||||
monkeypatch.delenv("AZURE_OPENAI_MODEL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_CHAT_COMPLETION_MODEL", raising=False)
|
||||
monkeypatch.setenv("OPENAI_MODEL", "gpt-5")
|
||||
|
||||
with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a model"):
|
||||
OpenAIChatCompletionClient()
|
||||
|
||||
|
||||
def test_init_with_credential_wraps_async_token_credential(
|
||||
monkeypatch, azure_openai_unit_test_env: dict[str, str]
|
||||
) -> None:
|
||||
monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False)
|
||||
|
||||
class TestAsyncTokenCredential(AsyncTokenCredential):
|
||||
async def get_token(self, *scopes: str, **kwargs: object):
|
||||
raise NotImplementedError
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: TracebackType | None = None,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key")
|
||||
monkeypatch.setenv("OPENAI_MODEL", "gpt-5")
|
||||
credential = TestAsyncTokenCredential()
|
||||
token_provider = MagicMock()
|
||||
|
||||
with patch("azure.identity.aio.get_bearer_token_provider", return_value=token_provider) as mock_provider:
|
||||
client = OpenAIChatCompletionClient(credential=cast(Any, credential))
|
||||
|
||||
assert isinstance(client.client, AsyncAzureOpenAI)
|
||||
mock_provider.assert_called_once_with(credential, "https://cognitiveservices.azure.com/.default")
|
||||
|
||||
|
||||
def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key")
|
||||
monkeypatch.setenv("OPENAI_MODEL", "gpt-5")
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "https://custom-openai-endpoint.com/v1")
|
||||
|
||||
client = OpenAIChatCompletionClient()
|
||||
|
||||
assert client.model == "gpt-5"
|
||||
assert not isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert client.azure_endpoint is None
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
async def test_azure_openai_chat_completion_client_response() -> None:
|
||||
async with AzureCliCredential() as credential:
|
||||
client = OpenAIChatCompletionClient(credential=cast(Any, credential))
|
||||
assert isinstance(client, SupportsChatGetResponse)
|
||||
|
||||
messages = [
|
||||
Message(
|
||||
role="user",
|
||||
contents=[
|
||||
(
|
||||
"Emily and David, two passionate scientists, met during a research expedition to Antarctica. "
|
||||
"Bonded by their love for the natural world and shared curiosity, they uncovered a "
|
||||
"groundbreaking phenomenon in glaciology that could potentially reshape our understanding "
|
||||
"of climate change."
|
||||
)
|
||||
],
|
||||
),
|
||||
Message(role="user", contents=["who are Emily and David?"]),
|
||||
]
|
||||
|
||||
response = await client.get_response(messages=messages)
|
||||
|
||||
assert response is not None
|
||||
assert isinstance(response, ChatResponse)
|
||||
assert any(
|
||||
word in response.text.lower() for word in ["scientists", "research", "antarctica", "glaciology", "climate"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
async def test_azure_openai_chat_completion_client_response_tools() -> None:
|
||||
async with AzureCliCredential() as credential:
|
||||
client = OpenAIChatCompletionClient(credential=cast(Any, credential))
|
||||
|
||||
response = await client.get_response(
|
||||
messages=[Message(role="user", contents=["who are Emily and David?"])],
|
||||
options={"tools": [get_story_text], "tool_choice": "auto"},
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert isinstance(response, ChatResponse)
|
||||
assert "Emily" in response.text or "David" in response.text
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
async def test_azure_openai_chat_completion_client_streaming() -> None:
|
||||
async with AzureCliCredential() as credential:
|
||||
client = OpenAIChatCompletionClient(credential=cast(Any, credential))
|
||||
|
||||
response = client.get_response(
|
||||
messages=[
|
||||
Message(
|
||||
role="user",
|
||||
contents=[
|
||||
(
|
||||
"Emily and David, two passionate scientists, met during a research expedition to "
|
||||
"Antarctica. Bonded by their love for the natural world and shared curiosity, they "
|
||||
"uncovered a groundbreaking phenomenon in glaciology that could potentially reshape our "
|
||||
"understanding of climate change."
|
||||
)
|
||||
],
|
||||
),
|
||||
Message(role="user", contents=["who are Emily and David?"]),
|
||||
],
|
||||
stream=True,
|
||||
)
|
||||
|
||||
full_message = ""
|
||||
async for chunk in response:
|
||||
assert isinstance(chunk, ChatResponseUpdate)
|
||||
assert chunk.message_id is not None
|
||||
assert chunk.response_id is not None
|
||||
for content in chunk.contents:
|
||||
if content.type == "text" and content.text:
|
||||
full_message += content.text
|
||||
|
||||
assert "Emily" in full_message or "David" in full_message
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
async def test_azure_openai_chat_completion_client_streaming_tools() -> None:
|
||||
async with AzureCliCredential() as credential:
|
||||
client = OpenAIChatCompletionClient(credential=cast(Any, credential))
|
||||
|
||||
response = client.get_response(
|
||||
messages=[Message(role="user", contents=["who are Emily and David?"])],
|
||||
stream=True,
|
||||
options={"tools": [get_story_text], "tool_choice": "auto"},
|
||||
)
|
||||
|
||||
full_message = ""
|
||||
async for chunk in response:
|
||||
assert isinstance(chunk, ChatResponseUpdate)
|
||||
for content in chunk.contents:
|
||||
if content.type == "text" and content.text:
|
||||
full_message += content.text
|
||||
|
||||
assert "Emily" in full_message or "David" in full_message
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
async def test_azure_openai_chat_completion_client_agent_basic_run() -> None:
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
Agent(
|
||||
client=OpenAIChatCompletionClient(credential=cast(Any, credential)),
|
||||
) as agent,
|
||||
):
|
||||
response = await agent.run("Please respond with exactly: 'This is a response test.'")
|
||||
|
||||
assert isinstance(response, AgentResponse)
|
||||
assert response.text is not None
|
||||
assert "response test" in response.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
async def test_azure_openai_chat_completion_client_agent_basic_run_streaming() -> None:
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
Agent(client=OpenAIChatCompletionClient(credential=cast(Any, credential))) as agent,
|
||||
):
|
||||
full_text = ""
|
||||
async for chunk in agent.run("Please respond with exactly: 'This is a streaming response test.'", stream=True):
|
||||
assert isinstance(chunk, AgentResponseUpdate)
|
||||
if chunk.text:
|
||||
full_text += chunk.text
|
||||
|
||||
assert "streaming response test" in full_text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
async def test_azure_openai_chat_completion_client_agent_session_persistence() -> None:
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
Agent(
|
||||
client=OpenAIChatCompletionClient(credential=cast(Any, credential)),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as agent,
|
||||
):
|
||||
session = agent.create_session()
|
||||
response1 = await agent.run("My name is Alice. Remember this.", session=session)
|
||||
response2 = await agent.run("What is my name?", session=session)
|
||||
|
||||
assert isinstance(response1, AgentResponse)
|
||||
assert isinstance(response2, AgentResponse)
|
||||
assert response2.text is not None
|
||||
assert "alice" in response2.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
async def test_azure_openai_chat_completion_client_agent_existing_session() -> None:
|
||||
async with AzureCliCredential() as credential:
|
||||
preserved_session = None
|
||||
|
||||
async with Agent(
|
||||
client=OpenAIChatCompletionClient(credential=cast(Any, credential)),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as first_agent:
|
||||
session = first_agent.create_session()
|
||||
first_response = await first_agent.run("My name is Alice. Remember this.", session=session)
|
||||
|
||||
assert isinstance(first_response, AgentResponse)
|
||||
preserved_session = session
|
||||
|
||||
if preserved_session:
|
||||
async with Agent(
|
||||
client=OpenAIChatCompletionClient(credential=cast(Any, credential)),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as second_agent:
|
||||
second_response = await second_agent.run("What is my name?", session=preserved_session)
|
||||
|
||||
assert isinstance(second_response, AgentResponse)
|
||||
assert second_response.text is not None
|
||||
assert "alice" in second_response.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
async def test_azure_chat_completion_client_agent_level_tool_persistence() -> None:
|
||||
async with (
|
||||
AzureCliCredential() as credential,
|
||||
Agent(
|
||||
client=OpenAIChatCompletionClient(credential=cast(Any, credential)),
|
||||
instructions="You are a helpful assistant that uses available tools.",
|
||||
tools=[get_weather],
|
||||
) as agent,
|
||||
):
|
||||
first_response = await agent.run("What's the weather like in Chicago?")
|
||||
second_response = await agent.run("What's the weather in Miami?")
|
||||
|
||||
assert isinstance(first_response, AgentResponse)
|
||||
assert isinstance(second_response, AgentResponse)
|
||||
assert first_response.text is not None
|
||||
assert second_response.text is not None
|
||||
assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"])
|
||||
assert any(term in second_response.text.lower() for term in ["miami", "sunny", "72"])
|
||||
@@ -0,0 +1,429 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import ChatResponseUpdate, Message
|
||||
from agent_framework.exceptions import ChatClientException
|
||||
from openai import AsyncStream
|
||||
from openai.resources.chat.completions import AsyncCompletions as AsyncChatCompletions
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionChunk
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
|
||||
from openai.types.chat.chat_completion_chunk import ChoiceDelta as ChunkChoiceDelta
|
||||
from openai.types.chat.chat_completion_message import ChatCompletionMessage
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_openai import OpenAIChatCompletionClient
|
||||
|
||||
|
||||
async def mock_async_process_chat_stream_response(_):
|
||||
mock_content = MagicMock(spec=ChatResponseUpdate)
|
||||
yield mock_content, None
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def chat_history() -> list[Message]:
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_chat_completion_response() -> ChatCompletion:
|
||||
return ChatCompletion(
|
||||
id="test_id",
|
||||
choices=[
|
||||
Choice(index=0, message=ChatCompletionMessage(content="test", role="assistant"), finish_reason="stop")
|
||||
],
|
||||
created=0,
|
||||
model="test",
|
||||
object="chat.completion",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_streaming_chat_completion_response() -> AsyncStream[ChatCompletionChunk]:
|
||||
content = ChatCompletionChunk(
|
||||
id="test_id",
|
||||
choices=[ChunkChoice(index=0, delta=ChunkChoiceDelta(content="test", role="assistant"), finish_reason="stop")],
|
||||
created=0,
|
||||
model="test",
|
||||
object="chat.completion.chunk",
|
||||
)
|
||||
stream = MagicMock(spec=AsyncStream)
|
||||
stream.__aiter__.return_value = [content]
|
||||
return stream
|
||||
|
||||
|
||||
# region Chat Message Content
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_cmc(
|
||||
mock_create: AsyncMock,
|
||||
chat_history: list[Message],
|
||||
mock_chat_completion_response: ChatCompletion,
|
||||
openai_unit_test_env: dict[str, str],
|
||||
):
|
||||
mock_create.return_value = mock_chat_completion_response
|
||||
chat_history.append(Message(role="user", contents=["hello world"]))
|
||||
|
||||
openai_chat_completion = OpenAIChatCompletionClient()
|
||||
await openai_chat_completion.get_response(messages=chat_history)
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=openai_unit_test_env["OPENAI_MODEL"],
|
||||
stream=False,
|
||||
messages=openai_chat_completion._prepare_messages_for_openai(chat_history), # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_cmc_chat_options(
|
||||
mock_create: AsyncMock,
|
||||
chat_history: list[Message],
|
||||
mock_chat_completion_response: ChatCompletion,
|
||||
openai_unit_test_env: dict[str, str],
|
||||
):
|
||||
mock_create.return_value = mock_chat_completion_response
|
||||
chat_history.append(Message(role="user", contents=["hello world"]))
|
||||
|
||||
openai_chat_completion = OpenAIChatCompletionClient()
|
||||
await openai_chat_completion.get_response(
|
||||
messages=chat_history,
|
||||
)
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=openai_unit_test_env["OPENAI_MODEL"],
|
||||
stream=False,
|
||||
messages=openai_chat_completion._prepare_messages_for_openai(chat_history), # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_cmc_no_fcc_in_response(
|
||||
mock_create: AsyncMock,
|
||||
chat_history: list[Message],
|
||||
mock_chat_completion_response: ChatCompletion,
|
||||
openai_unit_test_env: dict[str, str],
|
||||
):
|
||||
mock_create.return_value = mock_chat_completion_response
|
||||
chat_history.append(Message(role="user", contents=["hello world"]))
|
||||
orig_chat_history = deepcopy(chat_history)
|
||||
|
||||
openai_chat_completion = OpenAIChatCompletionClient()
|
||||
await openai_chat_completion.get_response(
|
||||
messages=chat_history,
|
||||
)
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=openai_unit_test_env["OPENAI_MODEL"],
|
||||
stream=False,
|
||||
messages=openai_chat_completion._prepare_messages_for_openai(orig_chat_history), # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_cmc_structured_output_no_fcc(
|
||||
mock_create: AsyncMock,
|
||||
chat_history: list[Message],
|
||||
mock_chat_completion_response: ChatCompletion,
|
||||
openai_unit_test_env: dict[str, str],
|
||||
):
|
||||
mock_create.return_value = mock_chat_completion_response
|
||||
chat_history.append(Message(role="user", contents=["hello world"]))
|
||||
|
||||
# Define a mock response format
|
||||
class Test(BaseModel):
|
||||
name: str
|
||||
|
||||
openai_chat_completion = OpenAIChatCompletionClient()
|
||||
await openai_chat_completion.get_response(
|
||||
messages=chat_history,
|
||||
options={"response_format": Test},
|
||||
)
|
||||
mock_create.assert_awaited_once()
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_scmc_chat_options(
|
||||
mock_create: AsyncMock,
|
||||
chat_history: list[Message],
|
||||
mock_streaming_chat_completion_response: AsyncStream[ChatCompletionChunk],
|
||||
openai_unit_test_env: dict[str, str],
|
||||
):
|
||||
mock_create.return_value = mock_streaming_chat_completion_response
|
||||
chat_history.append(Message(role="user", contents=["hello world"]))
|
||||
|
||||
openai_chat_completion = OpenAIChatCompletionClient()
|
||||
async for msg in openai_chat_completion.get_response(
|
||||
stream=True,
|
||||
messages=chat_history,
|
||||
):
|
||||
assert isinstance(msg, ChatResponseUpdate)
|
||||
assert msg.message_id is not None
|
||||
assert msg.response_id is not None
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=openai_unit_test_env["OPENAI_MODEL"],
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
messages=openai_chat_completion._prepare_messages_for_openai(chat_history), # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock, side_effect=Exception)
|
||||
async def test_cmc_general_exception(
|
||||
mock_create: AsyncMock,
|
||||
chat_history: list[Message],
|
||||
mock_chat_completion_response: ChatCompletion,
|
||||
openai_unit_test_env: dict[str, str],
|
||||
):
|
||||
mock_create.return_value = mock_chat_completion_response
|
||||
chat_history.append(Message(role="user", contents=["hello world"]))
|
||||
|
||||
openai_chat_completion = OpenAIChatCompletionClient()
|
||||
with pytest.raises(ChatClientException):
|
||||
await openai_chat_completion.get_response(
|
||||
messages=chat_history,
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_cmc_additional_properties(
|
||||
mock_create: AsyncMock,
|
||||
chat_history: list[Message],
|
||||
mock_chat_completion_response: ChatCompletion,
|
||||
openai_unit_test_env: dict[str, str],
|
||||
):
|
||||
mock_create.return_value = mock_chat_completion_response
|
||||
chat_history.append(Message(role="user", contents=["hello world"]))
|
||||
|
||||
openai_chat_completion = OpenAIChatCompletionClient()
|
||||
await cast(Any, openai_chat_completion).get_response(
|
||||
messages=chat_history,
|
||||
options={"reasoning_effort": "low"},
|
||||
)
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=openai_unit_test_env["OPENAI_MODEL"],
|
||||
stream=False,
|
||||
messages=openai_chat_completion._prepare_messages_for_openai(chat_history), # type: ignore
|
||||
reasoning_effort="low",
|
||||
)
|
||||
|
||||
|
||||
# region Streaming
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_get_streaming(
|
||||
mock_create: AsyncMock,
|
||||
chat_history: list[Message],
|
||||
openai_unit_test_env: dict[str, str],
|
||||
):
|
||||
content1 = ChatCompletionChunk(
|
||||
id="test_id",
|
||||
choices=[],
|
||||
created=0,
|
||||
model="test",
|
||||
object="chat.completion.chunk",
|
||||
)
|
||||
content2 = ChatCompletionChunk(
|
||||
id="test_id",
|
||||
choices=[ChunkChoice(index=0, delta=ChunkChoiceDelta(content="test", role="assistant"), finish_reason="stop")],
|
||||
created=0,
|
||||
model="test",
|
||||
object="chat.completion.chunk",
|
||||
)
|
||||
stream = MagicMock(spec=AsyncStream)
|
||||
stream.__aiter__.return_value = [content1, content2]
|
||||
mock_create.return_value = stream
|
||||
chat_history.append(Message(role="user", contents=["hello world"]))
|
||||
orig_chat_history = deepcopy(chat_history)
|
||||
|
||||
openai_chat_completion = OpenAIChatCompletionClient()
|
||||
async for msg in openai_chat_completion.get_response(
|
||||
stream=True,
|
||||
messages=chat_history,
|
||||
):
|
||||
assert isinstance(msg, ChatResponseUpdate)
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=openai_unit_test_env["OPENAI_MODEL"],
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
messages=openai_chat_completion._prepare_messages_for_openai(orig_chat_history), # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_get_streaming_singular(
|
||||
mock_create: AsyncMock,
|
||||
chat_history: list[Message],
|
||||
openai_unit_test_env: dict[str, str],
|
||||
):
|
||||
content1 = ChatCompletionChunk(
|
||||
id="test_id",
|
||||
choices=[],
|
||||
created=0,
|
||||
model="test",
|
||||
object="chat.completion.chunk",
|
||||
)
|
||||
content2 = ChatCompletionChunk(
|
||||
id="test_id",
|
||||
choices=[ChunkChoice(index=0, delta=ChunkChoiceDelta(content="test", role="assistant"), finish_reason="stop")],
|
||||
created=0,
|
||||
model="test",
|
||||
object="chat.completion.chunk",
|
||||
)
|
||||
stream = MagicMock(spec=AsyncStream)
|
||||
stream.__aiter__.return_value = [content1, content2]
|
||||
mock_create.return_value = stream
|
||||
chat_history.append(Message(role="user", contents=["hello world"]))
|
||||
orig_chat_history = deepcopy(chat_history)
|
||||
|
||||
openai_chat_completion = OpenAIChatCompletionClient()
|
||||
async for msg in openai_chat_completion.get_response(
|
||||
stream=True,
|
||||
messages=chat_history,
|
||||
):
|
||||
assert isinstance(msg, ChatResponseUpdate)
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=openai_unit_test_env["OPENAI_MODEL"],
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
messages=openai_chat_completion._prepare_messages_for_openai(orig_chat_history), # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_get_streaming_structured_output_no_fcc(
|
||||
mock_create: AsyncMock,
|
||||
chat_history: list[Message],
|
||||
openai_unit_test_env: dict[str, str],
|
||||
):
|
||||
content1 = ChatCompletionChunk(
|
||||
id="test_id",
|
||||
choices=[],
|
||||
created=0,
|
||||
model="test",
|
||||
object="chat.completion.chunk",
|
||||
)
|
||||
content2 = ChatCompletionChunk(
|
||||
id="test_id",
|
||||
choices=[ChunkChoice(index=0, delta=ChunkChoiceDelta(content="test", role="assistant"), finish_reason="stop")],
|
||||
created=0,
|
||||
model="test",
|
||||
object="chat.completion.chunk",
|
||||
)
|
||||
stream = MagicMock(spec=AsyncStream)
|
||||
stream.__aiter__.return_value = [content1, content2]
|
||||
mock_create.return_value = stream
|
||||
chat_history.append(Message(role="user", contents=["hello world"]))
|
||||
|
||||
# Define a mock response format
|
||||
class Test(BaseModel):
|
||||
name: str
|
||||
|
||||
openai_chat_completion = OpenAIChatCompletionClient()
|
||||
async for msg in openai_chat_completion.get_response(
|
||||
stream=True,
|
||||
messages=chat_history,
|
||||
options={"response_format": Test},
|
||||
):
|
||||
assert isinstance(msg, ChatResponseUpdate)
|
||||
mock_create.assert_awaited_once()
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_get_streaming_no_fcc_in_response(
|
||||
mock_create: AsyncMock,
|
||||
chat_history: list[Message],
|
||||
mock_streaming_chat_completion_response: ChatCompletion,
|
||||
openai_unit_test_env: dict[str, str],
|
||||
):
|
||||
mock_create.return_value = mock_streaming_chat_completion_response
|
||||
chat_history.append(Message(role="user", contents=["hello world"]))
|
||||
orig_chat_history = deepcopy(chat_history)
|
||||
|
||||
openai_chat_completion = OpenAIChatCompletionClient()
|
||||
[
|
||||
msg
|
||||
async for msg in openai_chat_completion.get_response(
|
||||
stream=True,
|
||||
messages=chat_history,
|
||||
)
|
||||
]
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=openai_unit_test_env["OPENAI_MODEL"],
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
messages=openai_chat_completion._prepare_messages_for_openai(orig_chat_history), # type: ignore
|
||||
)
|
||||
|
||||
|
||||
# region UTC Timestamp Tests
|
||||
|
||||
|
||||
def test_chat_response_created_at_uses_utc(openai_unit_test_env: dict[str, str]):
|
||||
"""Test that ChatResponse.created_at uses UTC timestamp, not local time.
|
||||
|
||||
This is a regression test for the issue where created_at was using local time
|
||||
but labeling it as UTC (with 'Z' suffix).
|
||||
"""
|
||||
# Use a specific Unix timestamp: 1733011890 = 2024-12-01T00:31:30Z (UTC)
|
||||
# This ensures we test that the timestamp is actually converted to UTC
|
||||
utc_timestamp = 1733011890
|
||||
|
||||
mock_response = ChatCompletion(
|
||||
id="test_id",
|
||||
choices=[
|
||||
Choice(index=0, message=ChatCompletionMessage(content="test", role="assistant"), finish_reason="stop")
|
||||
],
|
||||
created=utc_timestamp,
|
||||
model="test",
|
||||
object="chat.completion",
|
||||
)
|
||||
|
||||
client = OpenAIChatCompletionClient()
|
||||
response = client._parse_response_from_openai(mock_response, {})
|
||||
|
||||
# Verify that created_at is correctly formatted as UTC
|
||||
assert response.created_at is not None
|
||||
assert response.created_at.endswith("Z"), "Timestamp should end with 'Z' for UTC"
|
||||
|
||||
# Parse the timestamp and verify it matches UTC time
|
||||
expected_utc_time = datetime.fromtimestamp(utc_timestamp, tz=timezone.utc)
|
||||
expected_formatted = expected_utc_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
assert response.created_at == expected_formatted, (
|
||||
f"Expected UTC timestamp {expected_formatted}, got {response.created_at}"
|
||||
)
|
||||
|
||||
|
||||
def test_chat_response_update_created_at_uses_utc(openai_unit_test_env: dict[str, str]):
|
||||
"""Test that ChatResponseUpdate.created_at uses UTC timestamp, not local time.
|
||||
|
||||
This is a regression test for the issue where created_at was using local time
|
||||
but labeling it as UTC (with 'Z' suffix).
|
||||
"""
|
||||
# Use a specific Unix timestamp: 1733011890 = 2024-12-01T00:31:30Z (UTC)
|
||||
utc_timestamp = 1733011890
|
||||
|
||||
mock_chunk = ChatCompletionChunk(
|
||||
id="test_id",
|
||||
choices=[ChunkChoice(index=0, delta=ChunkChoiceDelta(content="test", role="assistant"), finish_reason="stop")],
|
||||
created=utc_timestamp,
|
||||
model="test",
|
||||
object="chat.completion.chunk",
|
||||
)
|
||||
|
||||
client = OpenAIChatCompletionClient()
|
||||
response_update = client._parse_response_update_from_openai(mock_chunk)
|
||||
|
||||
# Verify that created_at is correctly formatted as UTC
|
||||
assert response_update.created_at is not None
|
||||
assert response_update.created_at.endswith("Z"), "Timestamp should end with 'Z' for UTC"
|
||||
|
||||
# Parse the timestamp and verify it matches UTC time
|
||||
expected_utc_time = datetime.fromtimestamp(utc_timestamp, tz=timezone.utc)
|
||||
expected_formatted = expected_utc_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
assert response_update.created_at == expected_formatted, (
|
||||
f"Expected UTC timestamp {expected_formatted}, got {response_update.created_at}"
|
||||
)
|
||||
@@ -0,0 +1,266 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import os
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from agent_framework import SupportsGetEmbeddings
|
||||
from agent_framework.exceptions import SettingNotFoundError
|
||||
from openai.types import CreateEmbeddingResponse
|
||||
from openai.types import Embedding as OpenAIEmbedding
|
||||
from openai.types.create_embedding_response import Usage
|
||||
|
||||
from agent_framework_openai import (
|
||||
OpenAIEmbeddingClient,
|
||||
OpenAIEmbeddingOptions,
|
||||
)
|
||||
from agent_framework_openai._embedding_client import RawOpenAIEmbeddingClient
|
||||
|
||||
|
||||
def _make_openai_response(
|
||||
embeddings: list[list[float]],
|
||||
model: str = "text-embedding-3-small",
|
||||
prompt_tokens: int = 5,
|
||||
total_tokens: int = 5,
|
||||
) -> CreateEmbeddingResponse:
|
||||
"""Helper to create a mock OpenAI embeddings response."""
|
||||
data = [OpenAIEmbedding(embedding=emb, index=i, object="embedding") for i, emb in enumerate(embeddings)]
|
||||
return CreateEmbeddingResponse(
|
||||
data=data,
|
||||
model=model,
|
||||
object="list",
|
||||
usage=Usage(prompt_tokens=prompt_tokens, total_tokens=total_tokens),
|
||||
)
|
||||
|
||||
|
||||
# --- OpenAI unit tests ---
|
||||
|
||||
|
||||
def test_openai_construction_with_explicit_params() -> None:
|
||||
client = OpenAIEmbeddingClient(
|
||||
model="text-embedding-3-small",
|
||||
api_key="test-key",
|
||||
)
|
||||
assert client.model == "text-embedding-3-small"
|
||||
assert isinstance(client, SupportsGetEmbeddings)
|
||||
|
||||
|
||||
def test_raw_openai_embedding_client_init_uses_explicit_parameters() -> None:
|
||||
signature = inspect.signature(RawOpenAIEmbeddingClient.__init__)
|
||||
|
||||
assert "additional_properties" in signature.parameters
|
||||
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
|
||||
|
||||
|
||||
def test_openai_construction_from_env(openai_unit_test_env: dict[str, str]) -> None:
|
||||
client = OpenAIEmbeddingClient()
|
||||
assert client.model == openai_unit_test_env["OPENAI_EMBEDDING_MODEL"]
|
||||
|
||||
|
||||
def test_with_callable_api_key() -> None:
|
||||
"""Test OpenAIEmbeddingClient initialization with callable API key."""
|
||||
|
||||
async def get_api_key() -> str:
|
||||
return "test-api-key-123"
|
||||
|
||||
client = OpenAIEmbeddingClient(model="text-embedding-3-small", api_key=get_api_key)
|
||||
|
||||
assert client.model == "text-embedding-3-small"
|
||||
assert client.client is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True)
|
||||
def test_openai_construction_without_openai_or_azure_config_raises_clear_error(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
with pytest.raises(SettingNotFoundError):
|
||||
OpenAIEmbeddingClient(model="text-embedding-3-small")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_EMBEDDING_MODEL"]], indirect=True)
|
||||
def test_openai_construction_falls_back_to_openai_model(openai_unit_test_env: dict[str, str]) -> None:
|
||||
client = OpenAIEmbeddingClient()
|
||||
|
||||
assert client.model == openai_unit_test_env["OPENAI_MODEL"]
|
||||
|
||||
|
||||
async def test_openai_get_embeddings(openai_unit_test_env: dict[str, str]) -> None:
|
||||
mock_response = _make_openai_response(
|
||||
embeddings=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]],
|
||||
)
|
||||
client = OpenAIEmbeddingClient()
|
||||
client.client = MagicMock()
|
||||
client.client.embeddings = MagicMock()
|
||||
client.client.embeddings.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
result = await client.get_embeddings(["hello", "world"])
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0].vector == [0.1, 0.2, 0.3]
|
||||
assert result[1].vector == [0.4, 0.5, 0.6]
|
||||
assert result[0].model == "text-embedding-3-small"
|
||||
assert result[0].dimensions == 3
|
||||
|
||||
|
||||
async def test_openai_get_embeddings_usage(openai_unit_test_env: dict[str, str]) -> None:
|
||||
mock_response = _make_openai_response(
|
||||
embeddings=[[0.1]],
|
||||
prompt_tokens=10,
|
||||
total_tokens=10,
|
||||
)
|
||||
client = OpenAIEmbeddingClient()
|
||||
client.client = MagicMock()
|
||||
client.client.embeddings = MagicMock()
|
||||
client.client.embeddings.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
result = await client.get_embeddings(["test"])
|
||||
|
||||
assert result.usage is not None
|
||||
assert result.usage["input_token_count"] == 10
|
||||
assert result.usage["total_token_count"] == 10
|
||||
|
||||
|
||||
async def test_openai_options_passthrough_dimensions(openai_unit_test_env: dict[str, str]) -> None:
|
||||
mock_response = _make_openai_response(embeddings=[[0.1]])
|
||||
client = OpenAIEmbeddingClient()
|
||||
client.client = MagicMock()
|
||||
client.client.embeddings = MagicMock()
|
||||
client.client.embeddings.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
options: OpenAIEmbeddingOptions = {"dimensions": 256}
|
||||
result = await client.get_embeddings(["test"], options=options)
|
||||
|
||||
call_kwargs = client.client.embeddings.create.call_args[1]
|
||||
assert call_kwargs["dimensions"] == 256
|
||||
assert result.options is options
|
||||
|
||||
|
||||
async def test_openai_options_passthrough_encoding_format(openai_unit_test_env: dict[str, str]) -> None:
|
||||
mock_response = _make_openai_response(embeddings=[[0.1]])
|
||||
client = OpenAIEmbeddingClient()
|
||||
client.client = MagicMock()
|
||||
client.client.embeddings = MagicMock()
|
||||
client.client.embeddings.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
options: OpenAIEmbeddingOptions = {"encoding_format": "base64"}
|
||||
await client.get_embeddings(["test"], options=options)
|
||||
|
||||
call_kwargs = client.client.embeddings.create.call_args[1]
|
||||
assert call_kwargs["encoding_format"] == "base64"
|
||||
|
||||
|
||||
async def test_openai_base64_decoding(openai_unit_test_env: dict[str, str]) -> None:
|
||||
import base64
|
||||
import struct
|
||||
|
||||
# Encode [0.1, 0.2, 0.3] as base64 little-endian floats
|
||||
raw_floats = [0.1, 0.2, 0.3]
|
||||
b64_str = base64.b64encode(struct.pack(f"<{len(raw_floats)}f", *raw_floats)).decode()
|
||||
|
||||
# Mock the embedding item to return a base64 string (as the API does with encoding_format=base64)
|
||||
mock_item = MagicMock()
|
||||
mock_item.embedding = b64_str
|
||||
mock_item.index = 0
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.data = [mock_item]
|
||||
mock_response.model = "text-embedding-3-small"
|
||||
mock_response.usage = MagicMock(prompt_tokens=3, total_tokens=3)
|
||||
|
||||
client = OpenAIEmbeddingClient()
|
||||
client.client = MagicMock()
|
||||
client.client.embeddings = MagicMock()
|
||||
client.client.embeddings.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
options: OpenAIEmbeddingOptions = {"encoding_format": "base64"}
|
||||
result = await client.get_embeddings(["test"], options=options)
|
||||
|
||||
assert len(result) == 1
|
||||
assert len(result[0].vector) == 3
|
||||
assert result[0].dimensions == 3
|
||||
for expected, actual in zip(raw_floats, result[0].vector):
|
||||
assert abs(expected - actual) < 1e-6
|
||||
|
||||
|
||||
async def test_openai_error_when_no_model() -> None:
|
||||
client = cast(Any, object.__new__(OpenAIEmbeddingClient))
|
||||
client.model = None
|
||||
client.client = MagicMock()
|
||||
client.additional_properties = {}
|
||||
client.otel_provider_name = "openai"
|
||||
|
||||
with pytest.raises(ValueError, match="model is required"):
|
||||
await client.get_embeddings(["test"])
|
||||
|
||||
|
||||
async def test_openai_empty_values_returns_empty(openai_unit_test_env: dict[str, str]) -> None:
|
||||
client = OpenAIEmbeddingClient()
|
||||
client.client = MagicMock()
|
||||
client.client.embeddings = MagicMock()
|
||||
client.client.embeddings.create = AsyncMock()
|
||||
|
||||
result = await client.get_embeddings([])
|
||||
|
||||
assert len(result) == 0
|
||||
assert result.usage is None
|
||||
client.client.embeddings.create.assert_not_called()
|
||||
|
||||
|
||||
# --- Integration tests ---
|
||||
|
||||
skip_if_openai_integration_tests_disabled = pytest.mark.skipif(
|
||||
os.getenv("OPENAI_API_KEY", "") in ("", "test-dummy-key"),
|
||||
reason="No real OPENAI_API_KEY provided; skipping integration tests.",
|
||||
)
|
||||
|
||||
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
async def test_integration_openai_get_embeddings() -> None:
|
||||
"""End-to-end test of OpenAI embedding generation."""
|
||||
client = OpenAIEmbeddingClient(model="text-embedding-3-small")
|
||||
|
||||
result = await client.get_embeddings(["hello world"])
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0].vector, list)
|
||||
assert len(result[0].vector) > 0
|
||||
assert all(isinstance(v, float) for v in result[0].vector)
|
||||
assert result[0].model is not None
|
||||
assert result.usage is not None
|
||||
input_token_count = result.usage["input_token_count"]
|
||||
assert input_token_count is not None
|
||||
assert input_token_count > 0
|
||||
|
||||
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
async def test_integration_openai_get_embeddings_multiple() -> None:
|
||||
"""Test embedding generation for multiple inputs."""
|
||||
client = OpenAIEmbeddingClient(model="text-embedding-3-small")
|
||||
|
||||
result = await client.get_embeddings(["hello", "world", "test"])
|
||||
|
||||
assert len(result) == 3
|
||||
dims = [len(e.vector) for e in result]
|
||||
assert all(d == dims[0] for d in dims)
|
||||
|
||||
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
async def test_integration_openai_get_embeddings_with_dimensions() -> None:
|
||||
"""Test embedding generation with custom dimensions."""
|
||||
client = OpenAIEmbeddingClient(model="text-embedding-3-small")
|
||||
|
||||
options: OpenAIEmbeddingOptions = {"dimensions": 256}
|
||||
result = await client.get_embeddings(["hello world"], options=options)
|
||||
|
||||
assert len(result) == 1
|
||||
assert len(result[0].vector) == 256
|
||||
@@ -0,0 +1,329 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import wraps
|
||||
from types import TracebackType
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework.exceptions import SettingNotFoundError
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
from openai import AsyncAzureOpenAI, AsyncOpenAI
|
||||
|
||||
from agent_framework_openai import OpenAIEmbeddingClient, OpenAIEmbeddingOptions
|
||||
|
||||
pytestmark = pytest.mark.azure
|
||||
|
||||
skip_if_azure_openai_integration_tests_disabled = pytest.mark.skip(
|
||||
reason="Azure OpenAI integration tests temporarily disabled: crashes the xdist runner in CI.",
|
||||
)
|
||||
|
||||
|
||||
def _with_azure_openai_debug() -> Any:
|
||||
def decorator(func: Any) -> Any:
|
||||
@wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
model = os.getenv("AZURE_OPENAI_EMBEDDING_MODEL") or os.getenv("AZURE_OPENAI_MODEL", "<unset>")
|
||||
api_version = os.getenv("AZURE_OPENAI_API_VERSION", "<unset>")
|
||||
endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "<unset>")
|
||||
debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}"
|
||||
if hasattr(exc, "add_note"):
|
||||
cast(Any, exc).add_note(debug_message)
|
||||
elif exc.args:
|
||||
exc.args = (f"{exc.args[0]}\n{debug_message}", *exc.args[1:])
|
||||
else:
|
||||
exc.args = (debug_message,)
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _get_azure_embedding_deployment_name() -> str:
|
||||
return os.getenv("AZURE_OPENAI_EMBEDDING_MODEL") or os.environ["AZURE_OPENAI_MODEL"]
|
||||
|
||||
|
||||
def _create_azure_embedding_client(
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
credential: AsyncTokenCredential | None = None,
|
||||
) -> OpenAIEmbeddingClient:
|
||||
resolved_api_key = (
|
||||
api_key if api_key is not None else None if credential is not None else os.environ["AZURE_OPENAI_API_KEY"]
|
||||
)
|
||||
return OpenAIEmbeddingClient(
|
||||
model=_get_azure_embedding_deployment_name(),
|
||||
api_key=resolved_api_key,
|
||||
azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
|
||||
api_version=os.getenv("AZURE_OPENAI_API_VERSION"),
|
||||
credential=cast(Any, credential),
|
||||
)
|
||||
|
||||
|
||||
def test_init_with_azure_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
client = _create_azure_embedding_client()
|
||||
|
||||
assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_MODEL"]
|
||||
assert isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert client.OTEL_PROVIDER_NAME == "azure.ai.openai"
|
||||
assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"]
|
||||
assert client.api_version == azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"]
|
||||
|
||||
|
||||
def test_init_auto_detects_azure_embedding_env(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
client = OpenAIEmbeddingClient()
|
||||
|
||||
assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_MODEL"]
|
||||
assert isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"]
|
||||
|
||||
|
||||
def test_init_falls_back_to_generic_azure_deployment_env(
|
||||
monkeypatch, azure_openai_unit_test_env: dict[str, str]
|
||||
) -> None:
|
||||
monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_MODEL", raising=False)
|
||||
|
||||
client = OpenAIEmbeddingClient()
|
||||
|
||||
assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_MODEL"]
|
||||
assert isinstance(client.client, AsyncAzureOpenAI)
|
||||
|
||||
|
||||
def test_init_does_not_fall_back_to_openai_embedding_model_for_azure_env(
|
||||
monkeypatch, azure_openai_unit_test_env: dict[str, str]
|
||||
) -> None:
|
||||
monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_MODEL", raising=False)
|
||||
monkeypatch.delenv("AZURE_OPENAI_MODEL", raising=False)
|
||||
monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
|
||||
|
||||
with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a model"):
|
||||
OpenAIEmbeddingClient()
|
||||
|
||||
|
||||
def test_init_does_not_fall_back_to_openai_model_for_azure_env(
|
||||
monkeypatch, azure_openai_unit_test_env: dict[str, str]
|
||||
) -> None:
|
||||
monkeypatch.delenv("AZURE_OPENAI_EMBEDDING_MODEL", raising=False)
|
||||
monkeypatch.delenv("AZURE_OPENAI_MODEL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_EMBEDDING_MODEL", raising=False)
|
||||
monkeypatch.setenv("OPENAI_MODEL", "gpt-5")
|
||||
|
||||
with pytest.raises(SettingNotFoundError, match="Azure OpenAI client requires a model"):
|
||||
OpenAIEmbeddingClient()
|
||||
|
||||
|
||||
def test_openai_api_key_wins_over_azure_env(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key")
|
||||
monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
|
||||
|
||||
client = OpenAIEmbeddingClient()
|
||||
|
||||
assert client.model == "text-embedding-3-small"
|
||||
assert not isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert client.azure_endpoint is None
|
||||
|
||||
|
||||
def test_api_version_alone_does_not_override_openai_api_key(
|
||||
monkeypatch, azure_openai_unit_test_env: dict[str, str]
|
||||
) -> None:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key")
|
||||
monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
|
||||
|
||||
client = OpenAIEmbeddingClient(api_version="2024-10-21")
|
||||
|
||||
assert client.model == "text-embedding-3-small"
|
||||
assert not isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert client.azure_endpoint is None
|
||||
|
||||
|
||||
def test_explicit_credential_wins_over_openai_api_key(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key")
|
||||
monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
|
||||
|
||||
client = OpenAIEmbeddingClient(credential=lambda: "token")
|
||||
|
||||
assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_MODEL"]
|
||||
assert isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert client.azure_endpoint == azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"]
|
||||
|
||||
|
||||
def test_init_with_credential_wraps_async_token_credential(
|
||||
monkeypatch, azure_openai_unit_test_env: dict[str, str]
|
||||
) -> None:
|
||||
class TestAsyncTokenCredential(AsyncTokenCredential):
|
||||
async def get_token(self, *scopes: str, **kwargs: object):
|
||||
raise NotImplementedError
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: TracebackType | None = None,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key")
|
||||
monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
|
||||
credential = TestAsyncTokenCredential()
|
||||
token_provider = MagicMock()
|
||||
|
||||
with patch("azure.identity.aio.get_bearer_token_provider", return_value=token_provider) as mock_provider:
|
||||
client = OpenAIEmbeddingClient(credential=cast(Any, credential))
|
||||
|
||||
assert isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_MODEL"]
|
||||
mock_provider.assert_called_once_with(credential, "https://cognitiveservices.azure.com/.default")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_API_VERSION"]], indirect=True)
|
||||
def test_init_uses_default_azure_api_version(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
client = _create_azure_embedding_client()
|
||||
|
||||
assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_EMBEDDING_MODEL"]
|
||||
assert client.api_version == "2024-10-21"
|
||||
|
||||
|
||||
def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-dummy-key")
|
||||
monkeypatch.setenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "https://custom-openai-endpoint.com/v1")
|
||||
|
||||
client = OpenAIEmbeddingClient()
|
||||
|
||||
assert client.model == "text-embedding-3-small"
|
||||
assert not isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert client.azure_endpoint is None
|
||||
|
||||
|
||||
def test_init_with_openai_v1_base_url_and_credential_uses_openai_client(monkeypatch) -> None:
|
||||
for env in [
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_ORG_ID",
|
||||
"OPENAI_MODEL",
|
||||
"OPENAI_EMBEDDING_MODEL",
|
||||
"OPENAI_BASE_URL",
|
||||
"AZURE_OPENAI_ENDPOINT",
|
||||
"AZURE_OPENAI_BASE_URL",
|
||||
"AZURE_OPENAI_API_KEY",
|
||||
"AZURE_OPENAI_EMBEDDING_MODEL",
|
||||
"AZURE_OPENAI_MODEL",
|
||||
"AZURE_OPENAI_API_VERSION",
|
||||
"AZURE_OPENAI_CHAT_MODEL",
|
||||
"AZURE_OPENAI_CHAT_COMPLETION_MODEL",
|
||||
]:
|
||||
monkeypatch.delenv(env, raising=False)
|
||||
|
||||
client = OpenAIEmbeddingClient(
|
||||
base_url="https://myproject.openai.azure.com/openai/v1/",
|
||||
model="text-embedding-3-large",
|
||||
credential=lambda: "fake-token",
|
||||
)
|
||||
|
||||
assert client.model == "text-embedding-3-large"
|
||||
assert not isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert isinstance(client.client, AsyncOpenAI)
|
||||
assert client.OTEL_PROVIDER_NAME == "azure.ai.openai"
|
||||
assert str(client.client.base_url).rstrip("/").endswith("/openai/v1")
|
||||
|
||||
|
||||
def test_init_with_openai_v1_base_url_and_api_key_uses_openai_client(monkeypatch) -> None:
|
||||
for env in [
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_ORG_ID",
|
||||
"OPENAI_MODEL",
|
||||
"OPENAI_EMBEDDING_MODEL",
|
||||
"OPENAI_BASE_URL",
|
||||
"AZURE_OPENAI_ENDPOINT",
|
||||
"AZURE_OPENAI_BASE_URL",
|
||||
"AZURE_OPENAI_API_KEY",
|
||||
"AZURE_OPENAI_EMBEDDING_MODEL",
|
||||
"AZURE_OPENAI_MODEL",
|
||||
"AZURE_OPENAI_API_VERSION",
|
||||
"AZURE_OPENAI_CHAT_MODEL",
|
||||
"AZURE_OPENAI_CHAT_COMPLETION_MODEL",
|
||||
]:
|
||||
monkeypatch.delenv(env, raising=False)
|
||||
|
||||
# AZURE_OPENAI_BASE_URL + AZURE_OPENAI_API_KEY enter the Azure settings
|
||||
# path without an explicit endpoint parameter; the /openai/v1 suffix
|
||||
# should still produce AsyncOpenAI (not AsyncAzureOpenAI).
|
||||
monkeypatch.setenv("AZURE_OPENAI_BASE_URL", "https://myproject.openai.azure.com/openai/v1/")
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key")
|
||||
|
||||
client = OpenAIEmbeddingClient(model="text-embedding-3-large")
|
||||
|
||||
assert client.model == "text-embedding-3-large"
|
||||
assert not isinstance(client.client, AsyncAzureOpenAI)
|
||||
assert isinstance(client.client, AsyncOpenAI)
|
||||
assert str(client.client.base_url).rstrip("/").endswith("/openai/v1")
|
||||
|
||||
|
||||
def test_init_with_azure_endpoint_still_uses_azure_client(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
client = OpenAIEmbeddingClient(
|
||||
azure_endpoint=azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"],
|
||||
api_key=azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"],
|
||||
)
|
||||
|
||||
assert isinstance(client.client, AsyncAzureOpenAI)
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
async def test_azure_openai_get_embeddings() -> None:
|
||||
async with AzureCliCredential() as credential:
|
||||
client = _create_azure_embedding_client(credential=cast(Any, credential))
|
||||
|
||||
result = await client.get_embeddings(["hello world"])
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0].vector, list)
|
||||
assert len(result[0].vector) > 0
|
||||
assert all(isinstance(v, float) for v in result[0].vector)
|
||||
assert result[0].model is not None
|
||||
assert result.usage is not None
|
||||
input_token_count = result.usage["input_token_count"]
|
||||
assert input_token_count is not None
|
||||
assert input_token_count > 0
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
async def test_azure_openai_get_embeddings_multiple() -> None:
|
||||
async with AzureCliCredential() as credential:
|
||||
client = _create_azure_embedding_client(credential=cast(Any, credential))
|
||||
|
||||
result = await client.get_embeddings(["hello", "world", "test"])
|
||||
|
||||
assert len(result) == 3
|
||||
dims = [len(embedding.vector) for embedding in result]
|
||||
assert all(dimension == dims[0] for dimension in dims)
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_azure_openai_integration_tests_disabled
|
||||
@_with_azure_openai_debug()
|
||||
async def test_azure_openai_get_embeddings_with_dimensions() -> None:
|
||||
async with AzureCliCredential() as credential:
|
||||
client = _create_azure_embedding_client(credential=cast(Any, credential))
|
||||
|
||||
options: OpenAIEmbeddingOptions = {"dimensions": 256}
|
||||
result = await client.get_embeddings(["hello world"], options=options)
|
||||
|
||||
assert len(result) == 1
|
||||
assert len(result[0].vector) == 256
|
||||
@@ -0,0 +1,91 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import TracebackType
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from azure.core.credentials import TokenCredential
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
from agent_framework_openai._shared import (
|
||||
AZURE_OPENAI_TOKEN_SCOPE,
|
||||
_ensure_async_token_provider,
|
||||
_resolve_azure_credential_to_token_provider,
|
||||
)
|
||||
|
||||
|
||||
class _AsyncTokenCredentialStub(AsyncTokenCredential):
|
||||
async def get_token(self, *scopes: str, **kwargs: object):
|
||||
raise NotImplementedError
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None = None,
|
||||
exc_value: BaseException | None = None,
|
||||
traceback: TracebackType | None = None,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _TokenCredentialStub(TokenCredential):
|
||||
def get_token(self, *scopes: str, **kwargs: object):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def test_resolve_azure_async_credential_wraps_provider() -> None:
|
||||
credential = _AsyncTokenCredentialStub()
|
||||
token_provider = MagicMock()
|
||||
|
||||
with patch("azure.identity.aio.get_bearer_token_provider", return_value=token_provider) as mock_provider:
|
||||
resolved = _resolve_azure_credential_to_token_provider(credential)
|
||||
|
||||
assert resolved is token_provider
|
||||
mock_provider.assert_called_once_with(credential, AZURE_OPENAI_TOKEN_SCOPE)
|
||||
|
||||
|
||||
def test_resolve_azure_sync_credential_wraps_provider() -> None:
|
||||
credential = _TokenCredentialStub()
|
||||
token_provider = MagicMock()
|
||||
|
||||
with patch("azure.identity.get_bearer_token_provider", return_value=token_provider) as mock_provider:
|
||||
resolved = _resolve_azure_credential_to_token_provider(credential)
|
||||
|
||||
assert resolved is token_provider
|
||||
mock_provider.assert_called_once_with(credential, AZURE_OPENAI_TOKEN_SCOPE)
|
||||
|
||||
|
||||
def test_resolve_azure_callable_token_provider_passthrough() -> None:
|
||||
token_provider = MagicMock()
|
||||
|
||||
assert _resolve_azure_credential_to_token_provider(token_provider) is token_provider
|
||||
|
||||
|
||||
def test_resolve_azure_invalid_credential_raises() -> None:
|
||||
with pytest.raises(ValueError, match="credential"):
|
||||
_resolve_azure_credential_to_token_provider(cast(Any, object()))
|
||||
|
||||
|
||||
async def test_ensure_async_token_provider_wraps_sync_provider() -> None:
|
||||
def sync_provider() -> str:
|
||||
return "sync-token"
|
||||
|
||||
wrapper = _ensure_async_token_provider(sync_provider)
|
||||
result = await wrapper()
|
||||
|
||||
assert result == "sync-token"
|
||||
|
||||
|
||||
async def test_ensure_async_token_provider_wraps_async_provider() -> None:
|
||||
async def async_provider() -> str:
|
||||
return "async-token"
|
||||
|
||||
wrapper = _ensure_async_token_provider(async_provider)
|
||||
result = await wrapper()
|
||||
|
||||
assert result == "async-token"
|
||||
Reference in New Issue
Block a user