From 3a49b1d6dd2e6cd336195a05141edf10db22e140 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 31 Mar 2026 22:36:21 +0200 Subject: [PATCH] Python: [BREAKING] Remove deprecated Python OpenAI/Azure AI surfaces (#4990) * [BREAKING] Remove deprecated Python OpenAI/Azure AI surfaces Also clean up follow-on docs, environment guidance, package metadata, and lab test stability. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix deleted semantic-kernel sample links Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * improve foundry language * Fix A2A Foundry sample regression Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/python-integration-tests.yml | 3 - .github/workflows/python-merge-tests.yml | 7 +- .../workflows/python-sample-validation.yml | 112 +- .../skills/python-development/SKILL.md | 2 +- .../skills/python-package-management/SKILL.md | 10 +- python/CODING_STANDARD.md | 6 +- python/README.md | 2 +- python/packages/ag-ui/README.md | 8 +- .../agent_framework_ag_ui_examples/README.md | 32 +- .../server/api/backend_tool_rendering.py | 4 +- .../server/main.py | 4 +- .../packages/ag-ui/getting_started/README.md | 14 +- .../packages/ag-ui/getting_started/server.py | 12 +- python/packages/azure-ai/AGENTS.md | 28 +- .../agent_framework_azure_ai/__init__.py | 33 - .../_agent_provider.py | 558 ----- .../agent_framework_azure_ai/_chat_client.py | 1557 ------------- .../agent_framework_azure_ai/_client.py | 1329 ----------- .../_deprecated_azure_openai.py | 918 -------- .../_project_provider.py | 488 ---- .../agent_framework_azure_ai/_shared.py | 547 ----- .../azure-ai/tests/azure_openai/conftest.py | 61 - .../test_azure_assistants_client.py | 409 ---- .../azure_openai/test_azure_chat_client.py | 1102 --------- .../test_azure_embedding_client.py | 219 -- .../test_azure_responses_client.py | 542 ----- .../test_azure_responses_client_foundry.py | 131 -- .../azure-ai/tests/test_agent_provider.py | 773 ------- .../tests/test_azure_ai_agent_client.py | 1941 ---------------- .../azure-ai/tests/test_azure_ai_client.py | 1965 ----------------- .../packages/azure-ai/tests/test_provider.py | 682 ------ python/packages/azure-ai/tests/test_shared.py | 494 ----- .../packages/azure-cosmos/samples/README.md | 2 +- .../samples/cosmos_history_provider.py | 20 +- .../agent_framework_azurefunctions/_app.py | 7 +- .../tests/integration_tests/.env.example | 2 +- python/packages/chatkit/README.md | 4 +- python/packages/core/AGENTS.md | 9 +- python/packages/core/README.md | 72 +- .../packages/core/agent_framework/_clients.py | 3 +- .../core/agent_framework/_serialization.py | 4 +- .../_workflows/_agent_executor.py | 15 - .../core/agent_framework/azure/__init__.py | 15 - .../core/agent_framework/azure/__init__.pyi | 30 - .../core/agent_framework/openai/__init__.py | 8 - .../core/agent_framework/openai/__init__.pyi | 14 - .../agent_framework_declarative/_loader.py | 189 +- .../_workflows/_factory.py | 12 +- python/packages/devui/README.md | 4 +- .../agent_framework_devui/ui/assets/index.js | 4 +- python/packages/devui/dev.md | 2 +- .../components/layout/deployment-modal.tsx | 2 +- .../src/data/gallery/sample-entities.ts | 6 +- python/packages/durabletask/AGENTS.md | 4 +- python/packages/durabletask/README.md | 4 +- .../agent_framework_durabletask/_worker.py | 4 +- .../tests/integration_tests/.env.example | 2 +- python/packages/foundry/pyproject.toml | 2 +- .../lab/gaia/agent_framework_lab_gaia/gaia.py | 2 +- .../lab/gaia/samples/azure_ai_agent.py | 21 +- .../packages/lab/gaia/samples/openai_agent.py | 8 +- .../lab/tau2/tests/test_message_utils.py | 7 + .../lab/tau2/tests/test_sliding_window.py | 7 + .../lab/tau2/tests/test_tau2_utils.py | 7 + python/packages/openai/AGENTS.md | 6 +- python/packages/openai/README.md | 2 +- .../openai/agent_framework_openai/__init__.py | 42 - .../_assistant_provider.py | 564 ----- .../_assistants_client.py | 968 -------- .../agent_framework_openai/_chat_client.py | 2 +- .../openai/agent_framework_openai/_shared.py | 288 +-- python/packages/openai/pyproject.toml | 3 +- .../tests/openai/test_assistant_provider.py | 751 ------- .../openai/test_openai_assistants_client.py | 1481 ------------- .../tests/openai/test_openai_chat_client.py | 24 +- python/packages/purview/README.md | 16 +- python/samples/01-get-started/README.md | 7 + .../samples/02-agents/chat_client/README.md | 30 +- .../chat_client/built_in_chat_clients.py | 59 +- .../azure_ai_search/README.md | 20 +- .../azure_ai_search/search_context_agentic.py | 4 +- .../search_context_semantic.py | 4 +- .../context_providers/mem0/README.md | 4 +- .../context_providers/redis/README.md | 12 +- .../02-agents/declarative/mcp_tool_yaml.py | 6 +- python/samples/02-agents/devui/README.md | 6 +- .../devui/azure_responses_agent/.env.example | 2 +- .../devui/foundry_agent/.env.example | 4 +- .../02-agents/devui/foundry_agent/agent.py | 2 +- .../devui/weather_agent_azure/.env.example | 2 +- .../devui/workflow_agents/.env.example | 2 +- .../evaluation/evaluate_multimodal.py | 1 - python/samples/02-agents/middleware/README.md | 2 +- .../override_result_with_middleware.py | 4 +- .../middleware/usage_tracking_middleware.py | 4 +- .../02-agents/multimodal_input/README.md | 12 +- .../02-agents/observability/.env.example | 4 +- .../02-agents/providers/anthropic/README.md | 3 +- .../providers/anthropic/anthropic_foundry.py | 7 +- ...ompletion_client_with_explicit_settings.py | 2 +- .../02-agents/providers/custom/README.md | 8 +- .../skills/code_defined_skill/README.md | 4 +- .../skills/file_based_skill/README.md | 4 +- .../02-agents/skills/mixed_skills/README.md | 4 +- .../skills/script_approval/README.md | 4 +- .../function_invocation_configuration.py | 4 +- .../tools/function_tool_declaration_only.py | 4 +- ...ool_from_dict_with_dependency_injection.py | 4 +- .../function_tool_with_explicit_schema.py | 4 +- .../tools/function_tool_with_kwargs.py | 4 +- .../function_tool_with_max_exceptions.py | 4 +- .../function_tool_with_max_invocations.py | 4 +- .../function_tool_with_session_injection.py | 4 +- .../samples/02-agents/tools/tool_in_class.py | 4 +- python/samples/03-workflows/README.md | 12 +- .../declarative/function_tools/README.md | 12 +- .../03-workflows/orchestrations/README.md | 12 +- python/samples/04-hosting/a2a/README.md | 10 +- .../a2a/a2a_agent_as_function_tools.py | 18 +- .../azure_functions/08_mcp_server/README.md | 24 +- .../ag_ui_workflow_handoff/README.md | 4 +- .../ag_ui_workflow_handoff/backend/server.py | 2 +- .../chatkit-integration/README.md | 2 +- .../evaluation/red_teaming/.env.example | 2 +- .../evaluation/red_teaming/README.md | 8 +- .../05-end-to-end/hosted_agents/README.md | 8 +- .../agent_with_hosted_mcp/agent.yaml | 2 +- .../agent_with_text_search_rag/agent.yaml | 2 +- .../agents_in_workflow/agent.yaml | 2 +- .../workflow_evaluation/.env.example | 6 +- .../workflow_evaluation/run_evaluation.py | 6 +- python/samples/AGENTS.md | 14 +- python/samples/README.md | 88 +- .../semantic-kernel-migration/README.md | 8 +- .../01_basic_openai_assistant.py | 64 - ..._openai_assistant_with_code_interpreter.py | 74 - .../03_openai_assistant_function_tool.py | 103 - .../01_basic_responses_agent.py | 6 +- .../02_responses_agent_with_tool.py | 4 +- .../03_responses_agent_structured_output.py | 4 +- .../orchestrations/concurrent_basic.py | 4 +- .../orchestrations/magentic.py | 6 +- .../orchestrations/sequential.py | 4 +- python/uv.lock | 2 - 144 files changed, 669 insertions(+), 18739 deletions(-) delete mode 100644 python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py delete mode 100644 python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py delete mode 100644 python/packages/azure-ai/agent_framework_azure_ai/_client.py delete mode 100644 python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py delete mode 100644 python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py delete mode 100644 python/packages/azure-ai/tests/azure_openai/conftest.py delete mode 100644 python/packages/azure-ai/tests/azure_openai/test_azure_assistants_client.py delete mode 100644 python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py delete mode 100644 python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py delete mode 100644 python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py delete mode 100644 python/packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py delete mode 100644 python/packages/azure-ai/tests/test_agent_provider.py delete mode 100644 python/packages/azure-ai/tests/test_azure_ai_agent_client.py delete mode 100644 python/packages/azure-ai/tests/test_azure_ai_client.py delete mode 100644 python/packages/azure-ai/tests/test_provider.py delete mode 100644 python/packages/azure-ai/tests/test_shared.py delete mode 100644 python/packages/openai/agent_framework_openai/_assistant_provider.py delete mode 100644 python/packages/openai/agent_framework_openai/_assistants_client.py delete mode 100644 python/packages/openai/tests/openai/test_assistant_provider.py delete mode 100644 python/packages/openai/tests/openai/test_openai_assistants_client.py delete mode 100644 python/samples/semantic-kernel-migration/openai_assistant/01_basic_openai_assistant.py delete mode 100644 python/samples/semantic-kernel-migration/openai_assistant/02_openai_assistant_with_code_interpreter.py delete mode 100644 python/samples/semantic-kernel-migration/openai_assistant/03_openai_assistant_function_tool.py diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index df2fda5cb2..71f4267e41 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -126,8 +126,6 @@ jobs: packages/openai/tests/openai/test_openai_chat_completion_client_azure.py packages/openai/tests/openai/test_openai_chat_client_azure.py packages/openai/tests/openai/test_openai_embedding_client_azure.py - packages/azure-ai/tests/azure_openai - --ignore=packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py -m integration -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread @@ -288,7 +286,6 @@ jobs: timeout-minutes: 15 run: > uv run pytest --import-mode=importlib - packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py packages/foundry/tests -m integration -n logical --dist worksteal diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 453e4335a6..8c8abec84a 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -62,9 +62,7 @@ jobs: azure: - 'python/packages/openai/**' - 'python/packages/core/agent_framework/azure/**' - - 'python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py' - - 'python/packages/azure-ai/tests/azure_openai/**' - - 'python/samples/**/providers/azure/openai_chat_completion_client_azure*.py' + - 'python/samples/**/providers/azure/**' misc: - 'python/packages/anthropic/**' - 'python/packages/ollama/**' @@ -223,8 +221,6 @@ jobs: packages/openai/tests/openai/test_openai_chat_completion_client_azure.py packages/openai/tests/openai/test_openai_chat_client_azure.py packages/openai/tests/openai/test_openai_embedding_client_azure.py - packages/azure-ai/tests/azure_openai - --ignore=packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py -m integration -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread @@ -430,7 +426,6 @@ jobs: timeout-minutes: 15 run: > uv run pytest --import-mode=importlib - packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py packages/foundry/tests -m integration -n logical --dist worksteal diff --git a/.github/workflows/python-sample-validation.yml b/.github/workflows/python-sample-validation.yml index 7ce2219573..bbb3195d1a 100644 --- a/.github/workflows/python-sample-validation.yml +++ b/.github/workflows/python-sample-validation.yml @@ -23,10 +23,8 @@ jobs: environment: integration env: # Required configuration for get-started samples - AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} defaults: run: working-directory: python @@ -43,10 +41,8 @@ jobs: - name: Create .env for samples run: | - echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env - echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env - echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env - echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env + echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env + echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env - name: Run sample validation run: | @@ -64,16 +60,13 @@ jobs: runs-on: ubuntu-latest environment: integration env: - # Azure AI configuration - AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} - FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} + # Foundry configuration + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure OpenAI configuration AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }} + AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME || vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }} # OpenAI configuration OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} @@ -101,11 +94,8 @@ jobs: run: | echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env - echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env - echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env - echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env - echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_DEPLOYMENT_NAME=$AZURE_OPENAI_DEPLOYMENT_NAME" >> .env echo "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME=$AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME" >> .env echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env @@ -169,10 +159,9 @@ jobs: runs-on: ubuntu-latest environment: integration env: - AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_API_VERSION: ${{ vars.AZURE_OPENAI_API_VERSION || '' }} defaults: run: working-directory: python @@ -189,10 +178,9 @@ jobs: - name: Create .env for samples run: | - echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env - echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env - echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_DEPLOYMENT_NAME=$AZURE_OPENAI_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_API_VERSION=$AZURE_OPENAI_API_VERSION" >> .env - name: Run sample validation run: | @@ -337,11 +325,14 @@ jobs: validate-02-agents-foundry: name: Validate 02-agents/providers/foundry + if: false # Temporarily disabled - provider folder also contains the local Foundry sample runs-on: ubuntu-latest environment: integration env: - FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} - FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME || '' }} + FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION || '' }} defaults: run: working-directory: python @@ -360,6 +351,8 @@ jobs: run: | echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env + echo "FOUNDRY_AGENT_NAME=$FOUNDRY_AGENT_NAME" >> .env + echo "FOUNDRY_AGENT_VERSION=$FOUNDRY_AGENT_VERSION" >> .env - name: Run sample validation run: | @@ -448,15 +441,8 @@ jobs: runs-on: ubuntu-latest environment: integration env: - # Azure AI configuration - FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} - FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} - AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - # Azure OpenAI configuration - AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} defaults: run: working-directory: python @@ -475,11 +461,6 @@ jobs: run: | echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env - echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env - echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env - echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env - echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env - echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env - name: Run sample validation run: | @@ -498,12 +479,8 @@ jobs: runs-on: ubuntu-latest environment: integration env: - # Azure AI configuration - AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} - # Azure OpenAI configuration - AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # A2A configuration A2A_AGENT_HOST: http://localhost:5001/ defaults: @@ -537,19 +514,18 @@ jobs: runs-on: ubuntu-latest environment: integration env: - # Azure AI configuration - AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure OpenAI configuration AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure AI Search (for evaluation samples) AZURE_SEARCH_ENDPOINT: ${{ secrets.AZURE_SEARCH_ENDPOINT }} AZURE_SEARCH_API_KEY: ${{ secrets.AZURE_SEARCH_API_KEY }} AZURE_SEARCH_INDEX_NAME: ${{ secrets.AZURE_SEARCH_INDEX_NAME }} # Evaluation sample - AZURE_AI_MODEL_DEPLOYMENT_NAME_WORKFLOW: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + FOUNDRY_MODEL_WORKFLOW: ${{ vars.FOUNDRY_MODEL_WORKFLOW || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + FOUNDRY_MODEL_EVAL: ${{ vars.FOUNDRY_MODEL_EVAL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} defaults: run: working-directory: python @@ -580,12 +556,11 @@ jobs: runs-on: ubuntu-latest environment: integration env: - # Azure AI configuration - AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure OpenAI configuration AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # OpenAI configuration OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} @@ -607,10 +582,10 @@ jobs: - name: Create .env for samples run: | - echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env - echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env + echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env + echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env - echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_DEPLOYMENT_NAME=$AZURE_OPENAI_DEPLOYMENT_NAME" >> .env echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env @@ -631,13 +606,11 @@ jobs: runs-on: ubuntu-latest environment: integration env: - # Azure AI configuration - AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }} - AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # Azure OpenAI configuration AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} # OpenAI configuration OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }} @@ -664,11 +637,10 @@ jobs: - name: Create .env for samples run: | - echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env - echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env + echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env + echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_ENDPOINT" >> .env - echo "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=$AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" >> .env - echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env + echo "AZURE_OPENAI_DEPLOYMENT_NAME=$AZURE_OPENAI_DEPLOYMENT_NAME" >> .env echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env diff --git a/python/.github/skills/python-development/SKILL.md b/python/.github/skills/python-development/SKILL.md index ca73bd8ada..d3bb38ca4b 100644 --- a/python/.github/skills/python-development/SKILL.md +++ b/python/.github/skills/python-development/SKILL.md @@ -76,7 +76,7 @@ from agent_framework.observability import enable_instrumentation # Connectors (lazy-loaded) from agent_framework.openai import OpenAIChatClient -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.foundry import FoundryChatClient ``` ## Public API and Exports diff --git a/python/.github/skills/python-package-management/SKILL.md b/python/.github/skills/python-package-management/SKILL.md index 814410e73d..baee776828 100644 --- a/python/.github/skills/python-package-management/SKILL.md +++ b/python/.github/skills/python-package-management/SKILL.md @@ -134,7 +134,7 @@ Recommended dependency workflow during connector implementation: pip install agent-framework-core # Core only pip install agent-framework-core[all] # Core + all connectors pip install agent-framework # Same as core[all] -pip install agent-framework-azure-ai # Specific connector (pulls in core) +pip install agent-framework-foundry # Specific connector (pulls in core) ``` ## Maintaining Documentation @@ -143,3 +143,11 @@ When changing a package, check if its `AGENTS.md` needs updates: - Adding/removing/renaming public classes or functions - Changing the package's purpose or architecture - Modifying import paths or usage patterns + +When a package adds, removes, or renames environment variables, update the related documentation in the same +change: +- The package's `README.md` for package-level configuration/env var guidance +- `samples/README.md` if the package is included in `packages/core/pyproject.toml` `[all]` and the env var is + part of the consolidated package env-var inventory +- Any affected sample/package-local `.env.example`, `.env.template`, or sample README files when sample setup + changes alongside the package diff --git a/python/CODING_STANDARD.md b/python/CODING_STANDARD.md index d02b22e088..22173cfb9a 100644 --- a/python/CODING_STANDARD.md +++ b/python/CODING_STANDARD.md @@ -192,7 +192,7 @@ The package follows a flat import structure: - **Connectors**: Import from `agent_framework.` ```python from agent_framework.openai import OpenAIChatClient - from agent_framework.azure import AzureOpenAIChatClient + from agent_framework.foundry import FoundryChatClient ``` ## Exception Hierarchy @@ -429,6 +429,10 @@ Each file should have a single first line containing: # Copyright (c) Microsoft. We follow the [Google Docstring](https://github.com/google/styleguide/blob/gh-pages/pyguide.md#383-functions-and-methods) style guide for functions and methods. They are currently not checked for private functions (functions starting with '_'). +When a change adds, removes, or renames a sample-facing environment variable in repo-level samples or +package-local sample docs for a package included by `agent-framework-core[all]`, update the consolidated +inventory in `samples/README.md` in the same change. + They should contain: - Single line explaining what the function does, ending with a period. diff --git a/python/README.md b/python/README.md index 0a3042992f..1f1977358c 100644 --- a/python/README.md +++ b/python/README.md @@ -51,7 +51,7 @@ OPENAI_MODEL=... ... AZURE_OPENAI_API_KEY=... AZURE_OPENAI_ENDPOINT=... -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=... +AZURE_OPENAI_DEPLOYMENT_NAME=... ... FOUNDRY_PROJECT_ENDPOINT=... FOUNDRY_MODEL=... diff --git a/python/packages/ag-ui/README.md b/python/packages/ag-ui/README.md index d37d37bdfa..22841900d6 100644 --- a/python/packages/ag-ui/README.md +++ b/python/packages/ag-ui/README.md @@ -15,16 +15,16 @@ pip install agent-framework-ag-ui ```python from fastapi import FastAPI from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint # Create your agent agent = Agent( name="my_agent", instructions="You are a helpful assistant.", - client=AzureOpenAIChatClient( - endpoint="https://your-resource.openai.azure.com/", - deployment_name="gpt-4o-mini", + client=OpenAIChatCompletionClient( + azure_endpoint="https://your-resource.openai.azure.com/", + model="gpt-4o-mini", api_key="your-api-key", ), ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md b/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md index 04332dab9c..d4caa856ca 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/README.md @@ -16,7 +16,7 @@ All example agents are factory functions that accept any `SupportsChatGetRespons ```python from fastapi import FastAPI -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.openai import OpenAIChatClient from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint from agent_framework_ag_ui_examples.agents import simple_agent, weather_agent @@ -24,11 +24,11 @@ from agent_framework_ag_ui_examples.agents import simple_agent, weather_agent app = FastAPI() # Option 1: Use Azure OpenAI -azure_client = AzureOpenAIChatClient(model_id="gpt-4") +azure_client = OpenAIChatCompletionClient(model="gpt-4") add_agent_framework_fastapi_endpoint(app, simple_agent(azure_client), "/chat") # Option 2: Use OpenAI -openai_client = OpenAIChatClient(model_id="gpt-4o") +openai_client = OpenAIChatClient(model="gpt-4o") add_agent_framework_fastapi_endpoint(app, weather_agent(openai_client), "/weather") # Run with: uvicorn main:app --reload @@ -39,14 +39,14 @@ add_agent_framework_fastapi_endpoint(app, weather_agent(openai_client), "/weathe ```python from fastapi import FastAPI from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint # Create your agent agent = Agent( name="my_agent", instructions="You are a helpful assistant.", - client=AzureOpenAIChatClient(model_id="gpt-4o"), + client=OpenAIChatCompletionClient(model="gpt-4o"), ) # Create FastAPI app and add AG-UI endpoint @@ -90,7 +90,7 @@ Complete examples for all AG-UI features are available: ### Using Example Agents ```python -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.openai import OpenAIChatClient from agent_framework_ag_ui_examples.agents import ( simple_agent, @@ -99,8 +99,8 @@ from agent_framework_ag_ui_examples.agents import ( ) # Create a chat client (use any SupportsChatGetResponse implementation) -azure_client = AzureOpenAIChatClient(model_id="gpt-4") -openai_client = OpenAIChatClient(model_id="gpt-4o") +azure_client = OpenAIChatCompletionClient(model="gpt-4") +openai_client = OpenAIChatClient(model="gpt-4o") # Create agent instances by calling the factory functions agent1 = simple_agent(azure_client) @@ -137,7 +137,7 @@ The server exposes endpoints at: ```python from fastapi import FastAPI -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint from agent_framework_ag_ui_examples.agents import ( simple_agent, @@ -153,7 +153,7 @@ from agent_framework_ag_ui_examples.agents import ( app = FastAPI(title="AG-UI Examples") # Create a chat client (shared across all agents, or create individual ones) -client = AzureOpenAIChatClient(model_id="gpt-4") +client = OpenAIChatCompletionClient(model="gpt-4") # Add all example endpoints add_agent_framework_fastapi_endpoint(app, simple_agent(client), "/agentic_chat") @@ -223,8 +223,8 @@ def my_custom_agent(client: SupportsChatGetResponse) -> AgentFrameworkAgent: ) # Use it -from agent_framework.azure import AzureOpenAIChatClient -client = AzureOpenAIChatClient() +from agent_framework.openai import OpenAIChatCompletionClient +client = OpenAIChatCompletionClient() agent = my_custom_agent(client) ``` @@ -234,13 +234,13 @@ State is injected as system messages and updated via predictive state updates: ```python from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.ag_ui import AgentFrameworkAgent # Create your agent agent = Agent( name="recipe_agent", - client=AzureOpenAIChatClient(model_id="gpt-4o"), + client=OpenAIChatCompletionClient(model="gpt-4o"), ) state_schema = { @@ -271,13 +271,13 @@ Predictive state updates automatically stream tool arguments as optimistic state ```python from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.ag_ui import AgentFrameworkAgent # Create your agent agent = Agent( name="document_writer", - client=AzureOpenAIChatClient(model_id="gpt-4o"), + client=OpenAIChatCompletionClient(model="gpt-4o"), ) predict_state_config = { diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py index b18fc103e8..7db6ff5278 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/api/backend_tool_rendering.py @@ -6,7 +6,7 @@ from typing import Any, cast from agent_framework._clients import SupportsChatGetResponse from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from fastapi import FastAPI from ...agents.weather_agent import weather_agent @@ -19,7 +19,7 @@ def register_backend_tool_rendering(app: FastAPI) -> None: app: The FastAPI application. """ # Create a chat client and call the factory function - client = cast(SupportsChatGetResponse[Any], AzureOpenAIChatClient()) + client = cast(SupportsChatGetResponse[Any], OpenAIChatCompletionClient()) add_agent_framework_fastapi_endpoint( app, diff --git a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py index b422d70c8e..31a7c47963 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py +++ b/python/packages/ag-ui/agent_framework_ag_ui_examples/server/main.py @@ -12,7 +12,7 @@ import uvicorn from agent_framework import ChatOptions from agent_framework._clients import SupportsChatGetResponse from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -80,7 +80,7 @@ client: SupportsChatGetResponse[ChatOptions] = cast( SupportsChatGetResponse[ChatOptions], AnthropicClient() if AnthropicClient is not None and os.getenv("CHAT_CLIENT", "").lower() == "anthropic" - else AzureOpenAIChatClient(), + else OpenAIChatCompletionClient(), ) # Agentic Chat - basic chat agent diff --git a/python/packages/ag-ui/getting_started/README.md b/python/packages/ag-ui/getting_started/README.md index d3d14694a5..6c414e9c14 100644 --- a/python/packages/ag-ui/getting_started/README.md +++ b/python/packages/ag-ui/getting_started/README.md @@ -185,7 +185,7 @@ Create a file named `server.py`: import os from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint from fastapi import FastAPI @@ -205,9 +205,9 @@ if not api_key: agent = Agent( name="AGUIAssistant", instructions="You are a helpful assistant.", - client=AzureOpenAIChatClient( - endpoint=endpoint, - deployment_name=deployment_name, + client=OpenAIChatCompletionClient( + azure_endpoint=endpoint, + model=deployment_name, api_key=api_key, ), ) @@ -230,7 +230,7 @@ if __name__ == "__main__": - **`Agent`**: The agent that will handle incoming requests - **FastAPI Integration**: Uses FastAPI's native async support for streaming responses - **Instructions**: The agent is created with default instructions, which can be overridden by client messages -- **Configuration**: `AzureOpenAIChatClient` can read from environment variables (`AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_KEY`) or accept parameters directly +- **Configuration**: `OpenAIChatCompletionClient` can read from environment variables (`AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_KEY`) or accept parameters directly **Alternative (simpler)**: Use environment variables only: @@ -239,7 +239,7 @@ if __name__ == "__main__": agent = Agent( name="AGUIAssistant", instructions="You are a helpful assistant.", - client=AzureOpenAIChatClient(), # Reads from environment automatically + client=OpenAIChatCompletionClient(), # Reads from environment automatically ) ``` @@ -249,7 +249,7 @@ Set the required environment variables: ```bash export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" -export AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="gpt-4o-mini" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional: Set API key if not using DefaultAzureCredential # export AZURE_OPENAI_API_KEY="your-api-key" ``` diff --git a/python/packages/ag-ui/getting_started/server.py b/python/packages/ag-ui/getting_started/server.py index 8d32009fb1..dd1f4b7326 100644 --- a/python/packages/ag-ui/getting_started/server.py +++ b/python/packages/ag-ui/getting_started/server.py @@ -9,7 +9,7 @@ import os from agent_framework import Agent, tool from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from dotenv import load_dotenv from fastapi import Depends, FastAPI, HTTPException, Security from fastapi.security import APIKeyHeader @@ -26,12 +26,12 @@ logger = logging.getLogger(__name__) # Read required configuration endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") -deployment_name = os.environ.get("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME") +deployment_name = os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME") if not endpoint: raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required") if not deployment_name: - raise ValueError("AZURE_OPENAI_CHAT_DEPLOYMENT_NAME environment variable is required") + raise ValueError("AZURE_OPENAI_DEPLOYMENT_NAME environment variable is required") # ============================================================================ @@ -119,9 +119,9 @@ def get_time_zone(location: str) -> str: agent = Agent( name="AGUIAssistant", instructions="You are a helpful assistant. Use get_weather for weather and get_time_zone for time zones.", - client=AzureOpenAIChatClient( - endpoint=endpoint, - deployment_name=deployment_name, + client=OpenAIChatCompletionClient( + azure_endpoint=endpoint, + model=deployment_name, ), tools=[get_time_zone], # ONLY server-side tools ) diff --git a/python/packages/azure-ai/AGENTS.md b/python/packages/azure-ai/AGENTS.md index 1907ae1854..6a89bec1da 100644 --- a/python/packages/azure-ai/AGENTS.md +++ b/python/packages/azure-ai/AGENTS.md @@ -1,32 +1,30 @@ # Azure AI Package (agent-framework-azure-ai) -Integration with Azure AI Foundry for persistent agents and project-based agent management. +Integration with Azure AI inference embeddings plus shared Azure authentication helpers. ## Main Classes -- **`AzureAIAgentClient`** - Chat client for Azure AI Agents (persistent agents with threads) -- **`AzureAIClient`** - Client for Azure AI Foundry project-based agents -- **`AzureAIAgentsProvider`** - Provider for listing/managing Azure AI agents -- **`AzureAIProjectAgentProvider`** - Provider for project-scoped agent management -- **`AzureAISettings`** - Pydantic settings for Azure AI configuration -- **`AzureAIAgentOptions`** / **`AzureAIProjectAgentOptions`** - Options TypedDicts +- **`AzureAIInferenceEmbeddingClient`** - Full-featured Azure AI inference embeddings client +- **`RawAzureAIInferenceEmbeddingClient`** - Raw embeddings client without middleware layers +- **`AzureAIInferenceEmbeddingOptions`** / **`AzureAIInferenceEmbeddingSettings`** - Embedding options and settings +- **`AzureAISettings`** - Shared Azure AI project settings TypedDict +- **`AzureCredentialTypes`** / **`AzureTokenProvider`** - Shared Azure authentication helpers ## Usage ```python -from agent_framework.azure import AzureAIAgentClient +from agent_framework_azure_ai import AzureAIInferenceEmbeddingClient -client = AzureAIAgentClient( - endpoint="https://your-project.services.ai.azure.com", - agent_id="your-agent-id", +client = AzureAIInferenceEmbeddingClient( + endpoint="https://.inference.ai.azure.com", + api_key="...", + model_id="text-embedding-3-large", ) -response = await client.get_response("Hello") +result = await client.get_embeddings(["Hello"]) ``` ## Import Path ```python -from agent_framework.azure import AzureAIAgentClient, AzureAIClient -# or directly: -from agent_framework_azure_ai import AzureAIAgentClient +from agent_framework_azure_ai import AzureAIInferenceEmbeddingClient ``` diff --git a/python/packages/azure-ai/agent_framework_azure_ai/__init__.py b/python/packages/azure-ai/agent_framework_azure_ai/__init__.py index 401af22c51..5e72be8852 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/__init__.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/__init__.py @@ -2,21 +2,6 @@ import importlib.metadata -from ._agent_provider import AzureAIAgentsProvider # pyright: ignore[reportDeprecated] -from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions # pyright: ignore[reportDeprecated] -from ._client import AzureAIClient, AzureAIProjectAgentOptions, RawAzureAIClient # pyright: ignore[reportDeprecated] -from ._deprecated_azure_openai import ( - AzureOpenAIAssistantsClient, # pyright: ignore[reportDeprecated] - AzureOpenAIAssistantsOptions, - AzureOpenAIChatClient, # pyright: ignore[reportDeprecated] - AzureOpenAIChatOptions, - AzureOpenAIConfigMixin, - AzureOpenAIEmbeddingClient, # pyright: ignore[reportDeprecated] - AzureOpenAIResponsesClient, # pyright: ignore[reportDeprecated] - AzureOpenAIResponsesOptions, - AzureOpenAISettings, - AzureUserSecurityContext, -) from ._embedding_client import ( AzureAIInferenceEmbeddingClient, AzureAIInferenceEmbeddingOptions, @@ -24,7 +9,6 @@ from ._embedding_client import ( RawAzureAIInferenceEmbeddingClient, ) from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider -from ._project_provider import AzureAIProjectAgentProvider # pyright: ignore[reportDeprecated] from ._shared import AzureAISettings try: @@ -33,29 +17,12 @@ except importlib.metadata.PackageNotFoundError: __version__ = "0.0.0" __all__ = [ - "AzureAIAgentClient", - "AzureAIAgentOptions", - "AzureAIAgentsProvider", - "AzureAIClient", "AzureAIInferenceEmbeddingClient", "AzureAIInferenceEmbeddingOptions", "AzureAIInferenceEmbeddingSettings", - "AzureAIProjectAgentOptions", - "AzureAIProjectAgentProvider", "AzureAISettings", "AzureCredentialTypes", - "AzureOpenAIAssistantsClient", - "AzureOpenAIAssistantsOptions", - "AzureOpenAIChatClient", - "AzureOpenAIChatOptions", - "AzureOpenAIConfigMixin", - "AzureOpenAIEmbeddingClient", - "AzureOpenAIResponsesClient", - "AzureOpenAIResponsesOptions", - "AzureOpenAISettings", "AzureTokenProvider", - "AzureUserSecurityContext", - "RawAzureAIClient", "RawAzureAIInferenceEmbeddingClient", "__version__", ] diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py b/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py deleted file mode 100644 index a43702d8b3..0000000000 --- a/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py +++ /dev/null @@ -1,558 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import sys -import warnings -from collections.abc import Callable, Sequence -from typing import Any, Generic, cast - -from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, - Agent, - BaseContextProvider, - FunctionTool, - MiddlewareTypes, - normalize_tools, -) -from agent_framework._mcp import MCPTool -from agent_framework._settings import load_settings -from agent_framework._tools import ToolTypes -from azure.ai.agents.aio import AgentsClient -from azure.ai.agents.models import Agent as AzureAgent -from azure.ai.agents.models import ResponseFormatJsonSchema, ResponseFormatJsonSchemaType -from pydantic import BaseModel - -from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions # pyright: ignore[reportDeprecated] -from ._entra_id_authentication import AzureCredentialTypes -from ._shared import AzureAISettings, to_azure_ai_agent_tools - -if sys.version_info >= (3, 13): - from typing import Self, TypeVar # type: ignore # pragma: no cover -else: - from typing_extensions import Self, TypeVar # type: ignore # pragma: no cover -if sys.version_info >= (3, 13): - from warnings import deprecated # type: ignore # pragma: no cover -else: - from typing_extensions import deprecated # type: ignore # pragma: no cover -if sys.version_info >= (3, 11): - from typing import TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover - - -# Type variable for options - allows typed Agent[TOptions] returns -# Default matches AzureAIAgentClient's default options type -OptionsCoT = TypeVar( - "OptionsCoT", - bound=TypedDict, # type: ignore[valid-type] - default="AzureAIAgentOptions", - covariant=True, -) - - -@deprecated( - "AzureAIAgentClient and the AzureAIAgentsProvider are deprecated. " - "They target the V1 Agents Service API and have no direct replacement; " - "for new Foundry projects, use FoundryAgent." -) -class AzureAIAgentsProvider(Generic[OptionsCoT]): - """Provider for Azure AI Agent Service V1 (Persistent Agents API). - - .. deprecated:: - AzureAIAgentsProvider is deprecated and will be removed in a future release. - Use :class:`AzureAIProjectAgentProvider` instead for the V2 (Projects/Responses) API. - - This provider enables creating, retrieving, and wrapping Azure AI agents as Agent - instances. It manages the underlying AgentsClient lifecycle and provides a high-level - interface for agent operations. - - The provider can be initialized with either: - - An existing AgentsClient instance - - Azure credentials and endpoint for automatic client creation - - Examples: - Using credentials (auto-creates client): - - .. code-block:: python - - from agent_framework.azure import AzureAIAgentsProvider - from azure.identity.aio import AzureCliCredential - - async with ( - AzureCliCredential() as credential, - AzureAIAgentsProvider(credential=credential) as provider, - ): - agent = await provider.create_agent( - name="MyAgent", - instructions="You are a helpful assistant.", - ) - result = await agent.run("Hello!") - - Using existing AgentsClient: - - .. code-block:: python - - from agent_framework.azure import AzureAIAgentsProvider - from azure.ai.agents.aio import AgentsClient - - async with AgentsClient(endpoint=endpoint, credential=credential) as client: - provider = AzureAIAgentsProvider(agents_client=client) - agent = await provider.create_agent(name="MyAgent", instructions="...") - """ - - def __init__( - self, - agents_client: AgentsClient | None = None, - *, - project_endpoint: str | None = None, - credential: AzureCredentialTypes | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize the Azure AI Agents Provider. - - Args: - agents_client: An existing AgentsClient to use. If provided, the provider - will not manage its lifecycle. - - Keyword Args: - project_endpoint: The Azure AI Project endpoint URL. - Can also be set via AZURE_AI_PROJECT_ENDPOINT environment variable. - credential: Azure credential for authentication. Accepts a TokenCredential, - AsyncTokenCredential, or a callable token provider. - Required if agents_client is not provided. - env_file_path: Path to .env file for loading settings. - env_file_encoding: Encoding of the .env file. - - Raises: - ValueError: If required parameters are missing or invalid. - """ - warnings.warn( - "AzureAIAgentsProvider is deprecated and will be removed in a future release; " - "use AzureAIProjectAgentProvider instead for the V2 (Projects/Responses) API.", - DeprecationWarning, - stacklevel=2, - ) - self._settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - project_endpoint=project_endpoint, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - - self._should_close_client = False - - if agents_client is not None: - self._agents_client = agents_client - else: - resolved_endpoint = self._settings.get("project_endpoint") - if not resolved_endpoint: - raise ValueError( - "Azure AI project endpoint is required. Provide 'project_endpoint' parameter " - "or set 'AZURE_AI_PROJECT_ENDPOINT' environment variable." - ) - if not credential: - raise ValueError("Azure credential is required when agents_client is not provided.") - self._agents_client = AgentsClient( - endpoint=resolved_endpoint, - credential=credential, # type: ignore[arg-type] - user_agent=AGENT_FRAMEWORK_USER_AGENT, - ) - self._should_close_client = True - - async def __aenter__(self) -> Self: - """Async context manager entry.""" - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: Any, - ) -> None: - """Async context manager exit.""" - await self.close() - - async def close(self) -> None: - """Close the provider and release resources. - - Only closes the AgentsClient if it was created by this provider. - """ - if self._should_close_client: - await self._agents_client.close() - - async def create_agent( - self, - name: str, - *, - model: str | None = None, - instructions: str | None = None, - description: str | None = None, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Create a new agent on the Azure AI service and return a Agent. - - .. deprecated:: - This method is deprecated and will be removed in a future release. - Use :meth:`AzureAIProjectAgentProvider.create_agent` instead. - - This method creates a persistent agent on the Azure AI service with the specified - configuration and returns a local Agent instance for interaction. - - Args: - name: The name for the agent. - - Keyword Args: - model: The model deployment name to use. Falls back to - AZURE_AI_MODEL_DEPLOYMENT_NAME environment variable if not provided. - instructions: Instructions for the agent's behavior. - description: A description of the agent's purpose. - tools: Tools to make available to the agent. - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: List of middleware to intercept agent and function invocations. - context_providers: Context providers to include during agent invocation. - - Returns: - Agent: A Agent instance configured with the created agent. - - Raises: - ValueError: If model deployment name is not available. - - Examples: - .. code-block:: python - - agent = await provider.create_agent( - name="WeatherAgent", - instructions="You are a helpful weather assistant.", - tools=get_weather, - ) - """ - warnings.warn( - "AzureAIAgentsProvider.create_agent() is deprecated and will be removed in a future release; " - "use AzureAIProjectAgentProvider.create_agent() instead.", - DeprecationWarning, - stacklevel=2, - ) - resolved_model = model or self._settings.get("model_deployment_name") - if not resolved_model: - raise ValueError( - "Model deployment name is required. Provide 'model' parameter " - "or set 'AZURE_AI_MODEL_DEPLOYMENT_NAME' environment variable." - ) - - # Extract response_format from default_options if present - opts = dict(default_options) if default_options else {} - response_format = opts.get("response_format") - - args: dict[str, Any] = { - "model": resolved_model, - "name": name, - } - - if description: - args["description"] = description - if instructions: - args["instructions"] = instructions - - # Handle response format - if response_format and isinstance(response_format, type) and issubclass(response_format, BaseModel): - args["response_format"] = self._create_response_format_config(response_format) - - # Normalize and convert tools - # Local MCP tools (MCPTool) are handled by Agent at runtime, not stored on the Azure agent - normalized_tools = normalize_tools(tools) - if normalized_tools: - # Collect all non-MCP tools for Azure AI agent creation. - # to_azure_ai_agent_tools handles FunctionTool, SDK Tool types (FileSearchTool, etc.), and dicts. - non_mcp_tools: list[Any] = [t for t in normalized_tools if not isinstance(t, MCPTool)] - if non_mcp_tools: - # Pass run_options to capture tool_resources (e.g., for file search vector stores) - run_options: dict[str, Any] = {} - args["tools"] = to_azure_ai_agent_tools(non_mcp_tools, run_options) - if "tool_resources" in run_options: - args["tool_resources"] = run_options["tool_resources"] - - # Create the agent on the service - created_agent = await self._agents_client.create_agent(**args) - - # Create Agent wrapper - return self._to_chat_agent_from_agent( - created_agent, - normalized_tools, - default_options=default_options, - middleware=middleware, - context_providers=context_providers, - ) - - async def get_agent( - self, - id: str, - *, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Retrieve an existing agent from the service and return a Agent. - - .. deprecated:: - This method is deprecated and will be removed in a future release. - Use :meth:`AzureAIProjectAgentProvider.get_agent` instead. - - This method fetches an agent by ID from the Azure AI service - and returns a local Agent instance for interaction. - - Args: - id: The ID of the agent to retrieve from the service. - - Keyword Args: - tools: Tools to make available to the agent. Required if the agent - has function tools that need implementations. - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: List of middleware to intercept agent and function invocations. - context_providers: Context providers to include during agent invocation. - - Returns: - Agent: A Agent instance configured with the retrieved agent. - - Raises: - ValueError: If required function tools are not provided. - - Examples: - .. code-block:: python - - agent = await provider.get_agent("agent-123") - - # With function tools - agent = await provider.get_agent("agent-123", tools=my_function) - """ - warnings.warn( - "AzureAIAgentsProvider.get_agent() is deprecated and will be removed in a future release; " - "use AzureAIProjectAgentProvider.get_agent() instead.", - DeprecationWarning, - stacklevel=2, - ) - agent = await self._agents_client.get_agent(id) - - # Validate function tools - normalized_tools = normalize_tools(tools) - self._validate_function_tools(agent.tools, normalized_tools) - - return self._to_chat_agent_from_agent( - agent, - normalized_tools, - default_options=default_options, - middleware=middleware, - context_providers=context_providers, - ) - - def as_agent( - self, - agent: AzureAgent, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Wrap an existing Agent SDK object as a Agent without making HTTP calls. - - .. deprecated:: - This method is deprecated and will be removed in a future release. - Use :meth:`AzureAIProjectAgentProvider.as_agent` instead. - - Use this method when you already have an Agent object from a previous - SDK operation and want to use it with the Agent Framework. - - Args: - agent: The Agent object to wrap. - tools: Tools to make available to the agent. Required if the agent - has function tools that need implementations. - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: List of middleware to intercept agent and function invocations. - context_providers: Context providers to include during agent invocation. - - Returns: - Agent: A Agent instance configured with the agent. - - Raises: - ValueError: If required function tools are not provided. - - Examples: - .. code-block:: python - - # Create agent directly with SDK - sdk_agent = await agents_client.create_agent( - model="gpt-4", - name="MyAgent", - instructions="...", - ) - - # Wrap as Agent - chat_agent = provider.as_agent(sdk_agent) - """ - warnings.warn( - "AzureAIAgentsProvider.as_agent() is deprecated and will be removed in a future release; " - "use AzureAIProjectAgentProvider.as_agent() instead.", - DeprecationWarning, - stacklevel=2, - ) - # Validate function tools - normalized_tools = normalize_tools(tools) - self._validate_function_tools(agent.tools, normalized_tools) - - return self._to_chat_agent_from_agent( - agent, - normalized_tools, - default_options=default_options, - middleware=middleware, - context_providers=context_providers, - ) - - def _to_chat_agent_from_agent( - self, - agent: AzureAgent, - provided_tools: Sequence[ToolTypes] | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Create a Agent from an Agent SDK object. - - Args: - agent: The Agent SDK object. - provided_tools: User-provided tools (including function implementations). - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: List of middleware to intercept agent and function invocations. - context_providers: Context providers to include during agent invocation. - """ - # Create the underlying client - client = AzureAIAgentClient( # pyright: ignore[reportDeprecated] - agents_client=self._agents_client, - agent_id=agent.id, - agent_name=agent.name, - agent_description=agent.description, - should_cleanup_agent=False, # Provider manages agent lifecycle - ) - - # Merge tools: convert agent's hosted tools + user-provided function tools - merged_tools = self._merge_tools(agent.tools, provided_tools) - merged_default_options: dict[str, Any] = dict(default_options) if default_options is not None else {} - merged_default_options.setdefault("model_id", agent.model) - - return Agent( # type: ignore[return-value] - client=client, - id=agent.id, - name=agent.name, - description=agent.description, - instructions=agent.instructions, - tools=merged_tools, - default_options=cast(Any, merged_default_options), - middleware=middleware, - context_providers=context_providers, - ) - - def _merge_tools( - self, - agent_tools: Sequence[Any] | None, - provided_tools: Sequence[ToolTypes] | None, - ) -> list[ToolTypes]: - """Merge hosted tools from agent with user-provided function tools. - - Args: - agent_tools: Tools from the agent definition (Azure AI format). - provided_tools: User-provided tools (Agent Framework format). - - Returns: - Combined list of tools for the Agent. - """ - merged: list[ToolTypes] = [] - - # Hosted tools (file_search, code_interpreter, bing_grounding, openapi, etc.) - # are already defined on the server agent and will be read back by the client - # at run time via agent_definition.tools. We skip them here to avoid sending - # them again at request time (which causes API errors like unknown vector_store_ids). - - # Add user-provided function tools and MCP tools - if provided_tools: - for provided_tool in provided_tools: - # FunctionTool - has implementation for function calling - # MCPTool - Agent handles MCP connection and tool discovery at runtime - if isinstance(provided_tool, (FunctionTool, MCPTool)): - merged.append(provided_tool) # type: ignore[reportUnknownArgumentType] - - return merged - - def _validate_function_tools( - self, - agent_tools: Sequence[Any] | None, - provided_tools: Sequence[ToolTypes] | None, - ) -> None: - """Validate that required function tools are provided. - - Raises: - ValueError: If agent has function tools but user - didn't provide implementations. - """ - if not agent_tools: - return - - # Get function tool names from agent definition - function_tool_names: set[str] = set() - for tool in agent_tools: - if isinstance(tool, dict): - tool_dict = cast(dict[str, Any], tool) - if tool_dict.get("type") == "function": - func_def = cast(dict[str, Any], tool_dict.get("function", {})) - name = func_def.get("name") - if isinstance(name, str): - function_tool_names.add(name) - elif hasattr(tool, "type") and tool.type == "function": - func_attr = getattr(tool, "function", None) - if func_attr and hasattr(func_attr, "name"): - function_tool_names.add(str(func_attr.name)) - - if not function_tool_names: - return - - # Get provided function names - provided_names: set[str] = set() - if provided_tools: - for tool in provided_tools: - if isinstance(tool, FunctionTool): - provided_names.add(tool.name) - - # Check for missing implementations - missing = function_tool_names - provided_names - if missing: - raise ValueError( - f"Agent has function tools that require implementations: {missing}. " - "Provide these functions via the 'tools' parameter." - ) - - def _create_response_format_config( - self, - response_format: type[BaseModel], - ) -> ResponseFormatJsonSchemaType: - """Create response format configuration for Azure AI. - - Args: - response_format: Pydantic model for structured output. - - Returns: - Azure AI response format configuration. - """ - return ResponseFormatJsonSchemaType( - json_schema=ResponseFormatJsonSchema( - name=response_format.__name__, - schema=response_format.model_json_schema(), - ) - ) diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py deleted file mode 100644 index 7faba8c47c..0000000000 --- a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py +++ /dev/null @@ -1,1557 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import ast -import json -import logging -import os -import re -import sys -import warnings -from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence -from typing import Any, ClassVar, Generic, TypedDict, cast - -from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, - Agent, - Annotation, - BaseChatClient, - BaseContextProvider, - ChatAndFunctionMiddlewareTypes, - ChatMiddlewareLayer, - ChatOptions, - ChatResponse, - ChatResponseUpdate, - Content, - FunctionInvocationConfiguration, - FunctionInvocationLayer, - FunctionTool, - Message, - MiddlewareTypes, - ResponseStream, - Role, - TextSpanRegion, - UsageDetails, -) -from agent_framework._settings import load_settings -from agent_framework._tools import ToolTypes -from agent_framework.exceptions import ( - ChatClientException, - ChatClientInvalidRequestException, -) -from agent_framework.observability import ChatTelemetryLayer -from azure.ai.agents.aio import AgentsClient -from azure.ai.agents.models import ( - Agent as AzureAgent, -) -from azure.ai.agents.models import ( - AgentsNamedToolChoice, - AgentsNamedToolChoiceType, - AgentsToolChoiceOptionMode, - AgentStreamEvent, - AsyncAgentEventHandler, - AsyncAgentRunStream, - BingCustomSearchTool, - BingGroundingTool, - CodeInterpreterTool, - FileSearchTool, - FunctionName, - FunctionToolDefinition, - ListSortOrder, - McpTool, - MessageDeltaChunk, - MessageDeltaTextContent, - MessageDeltaTextFileCitationAnnotation, - MessageDeltaTextFilePathAnnotation, - MessageDeltaTextUrlCitationAnnotation, - MessageImageUrlParam, - MessageInputContentBlock, - MessageInputImageUrlBlock, - MessageInputTextBlock, - MessageRole, - RequiredFunctionToolCall, - RequiredMcpToolCall, - ResponseFormatJsonSchema, - ResponseFormatJsonSchemaType, - RunStatus, - RunStep, - RunStepDeltaChunk, - RunStepDeltaCodeInterpreterImageOutput, - RunStepDeltaCodeInterpreterLogOutput, - RunStepDeltaToolCall, - SubmitToolApprovalAction, - SubmitToolOutputsAction, - ThreadMessageOptions, - ThreadRun, - ToolApproval, - ToolDefinition, - ToolOutput, - VectorStoreDataSource, -) -from pydantic import BaseModel - -from ._entra_id_authentication import AzureCredentialTypes -from ._shared import AzureAISettings, resolve_file_ids, to_azure_ai_agent_tools - -if sys.version_info >= (3, 13): - from typing import TypeVar # type: ignore # pragma: no cover - from warnings import deprecated # type: ignore # pragma: no cover -else: - from typing_extensions import TypeVar, deprecated # type: ignore # pragma: no cover -if sys.version_info >= (3, 12): - from typing import override # type: ignore # pragma: no cover -else: - from typing_extensions import override # type: ignore[import] # pragma: no cover -if sys.version_info >= (3, 11): - from typing import Self, TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import Self, TypedDict # type: ignore # pragma: no cover - - -logger = logging.getLogger("agent_framework.azure") - -__all__ = ["AzureAIAgentClient", "AzureAIAgentOptions"] - - -# region Azure AI Agent Options TypedDict - - -class AzureAIAgentOptions(ChatOptions, total=False): - """Azure AI Foundry Agent Service-specific options dict. - - .. deprecated:: - AzureAIAgentOptions is deprecated and will be removed in a future release. - Use :class:`AzureAIProjectAgentOptions` instead for the V2 (Projects/Responses) API. - - Extends base ChatOptions with Azure AI Agent Service parameters. - Azure AI Agents provides a managed agent runtime with built-in - tools for code interpreter, file search, and web search. - - See: https://learn.microsoft.com/azure/ai-services/agents/ - - Keys: - # Inherited from ChatOptions: - model_id: The model deployment name, - translates to ``model`` in Azure AI API. - temperature: Sampling temperature between 0 and 2. - top_p: Nucleus sampling parameter. - max_tokens: Maximum number of tokens to generate, - translates to ``max_completion_tokens`` in Azure AI API. - tools: List of tools available to the agent. - tool_choice: How the model should use tools. - allow_multiple_tool_calls: Whether to allow parallel tool calls, - translates to ``parallel_tool_calls`` in Azure AI API. - response_format: Structured output schema. - metadata: Request metadata for tracking. - instructions: System instructions for the agent. - - # Options not supported in Azure AI Agent Service: - stop: Not supported. - seed: Not supported. - frequency_penalty: Not supported. - presence_penalty: Not supported. - user: Not supported. - store: Not supported. - logit_bias: Not supported. - - # Azure AI Agent-specific options: - conversation_id: Thread ID to continue conversation in. - tool_resources: Resources for tools (file IDs, vector stores). - """ - - # Azure AI Agent-specific options - conversation_id: str # type: ignore[misc] - """Thread ID to continue a conversation in an existing thread.""" - - tool_resources: dict[str, Any] - """Tool-specific resources for code_interpreter and file_search. - For code_interpreter: {"file_ids": ["file-abc123"]} - For file_search: {"vector_store_ids": ["vs-abc123"]} - """ - - # ChatOptions fields not supported in Azure AI Agent Service - stop: None # type: ignore[misc] - """Not supported in Azure AI Agent Service.""" - - seed: None # type: ignore[misc] - """Not supported in Azure AI Agent Service.""" - - frequency_penalty: None # type: ignore[misc] - """Not supported in Azure AI Agent Service.""" - - presence_penalty: None # type: ignore[misc] - """Not supported in Azure AI Agent Service.""" - - user: None # type: ignore[misc] - """Not supported in Azure AI Agent Service.""" - - store: None # type: ignore[misc] - """Not supported in Azure AI Agent Service.""" - - logit_bias: None # type: ignore[misc] - """Not supported in Azure AI Agent Service.""" - - -AZURE_AI_AGENT_OPTION_TRANSLATIONS: dict[str, str] = { - "model_id": "model", - "max_tokens": "max_completion_tokens", - "allow_multiple_tool_calls": "parallel_tool_calls", -} -"""Maps ChatOptions keys to Azure AI Agents API parameter names.""" - -AzureAIAgentOptionsT = TypeVar( - "AzureAIAgentOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="AzureAIAgentOptions", - covariant=True, -) - - -# endregion - - -@deprecated( - "AzureAIAgentClient is deprecated. " - "It targets the V1 Agents Service API and has no direct replacement; " - "for new Foundry projects, use FoundryAgent." -) -class AzureAIAgentClient( - FunctionInvocationLayer[AzureAIAgentOptionsT], - ChatMiddlewareLayer[AzureAIAgentOptionsT], - ChatTelemetryLayer[AzureAIAgentOptionsT], - BaseChatClient[AzureAIAgentOptionsT], - Generic[AzureAIAgentOptionsT], -): - """Azure AI Agent Chat client with middleware, telemetry, and function invocation support. - - .. deprecated:: - AzureAIAgentClient is deprecated and will be removed in a future release. - It targets the V1 Agents Service API and has no direct replacement. - For new Foundry projects, use :class:`FoundryAgent`. - """ - - OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc] - STORES_BY_DEFAULT: ClassVar[bool] = True # type: ignore[reportIncompatibleVariableOverride, misc] - - # region Hosted Tool Factory Methods - - @staticmethod - def get_code_interpreter_tool( - *, - file_ids: list[str | Content] | None = None, - data_sources: list[VectorStoreDataSource] | None = None, - ) -> CodeInterpreterTool: - """Create a code interpreter tool configuration for Azure AI Agents. - - .. deprecated:: - This method is deprecated and will be removed in a future release. - For new Foundry projects, configure hosted tools on the Foundry agent definition - in the service instead. - - Keyword Args: - file_ids: List of uploaded file IDs or Content objects to make available to - the code interpreter. Accepts plain strings or Content.from_hosted_file() - instances. The underlying SDK raises ValueError if both file_ids and - data_sources are provided. - data_sources: List of vector store data sources for enterprise file search. - Mutually exclusive with file_ids. - - Returns: - A CodeInterpreterTool instance ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIAgentClient - - # Basic code interpreter - tool = AzureAIAgentClient.get_code_interpreter_tool() - - # With uploaded file IDs - tool = AzureAIAgentClient.get_code_interpreter_tool(file_ids=["file-abc123"]) - - # With Content objects - from agent_framework import Content - - tool = AzureAIAgentClient.get_code_interpreter_tool(file_ids=[Content.from_hosted_file("file-abc123")]) - - agent = ChatAgent(client, tools=[tool]) - """ - warnings.warn( - "AzureAIAgentClient.get_code_interpreter_tool() is deprecated and will be removed in a future release; " - "for new Foundry projects, configure hosted tools on the Foundry agent definition in the service instead.", - DeprecationWarning, - stacklevel=2, - ) - resolved = resolve_file_ids(file_ids) - return CodeInterpreterTool(file_ids=resolved, data_sources=data_sources) - - @staticmethod - def get_file_search_tool( - *, - vector_store_ids: list[str], - ) -> FileSearchTool: - """Create a file search tool configuration for Azure AI Agents. - - .. deprecated:: - This method is deprecated and will be removed in a future release. - For new Foundry projects, configure hosted tools on the Foundry agent definition - in the service instead. - - Keyword Args: - vector_store_ids: List of vector store IDs to search within. - - Returns: - A FileSearchTool instance ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIAgentClient - - tool = AzureAIAgentClient.get_file_search_tool( - vector_store_ids=["vs_abc123"], - ) - agent = ChatAgent(client, tools=[tool]) - """ - warnings.warn( - "AzureAIAgentClient.get_file_search_tool() is deprecated and will be removed in a future release; " - "for new Foundry projects, configure hosted tools on the Foundry agent definition in the service instead.", - DeprecationWarning, - stacklevel=2, - ) - return FileSearchTool(vector_store_ids=vector_store_ids) - - @staticmethod - def get_web_search_tool( - *, - bing_connection_id: str | None = None, - bing_custom_connection_id: str | None = None, - bing_custom_instance_id: str | None = None, - ) -> BingGroundingTool | BingCustomSearchTool: - """Create a web search tool configuration for Azure AI Agents. - - .. deprecated:: - This method is deprecated and will be removed in a future release. - For new Foundry projects, configure hosted tools on the Foundry agent definition - in the service instead. - - For Azure AI Agents, web search uses Bing Grounding or Bing Custom Search. - If no arguments are provided, attempts to read from environment variables. - If no connection IDs are found, raises ValueError. - - Keyword Args: - bing_connection_id: The Bing Grounding connection ID for standard web search. - Falls back to BING_CONNECTION_ID environment variable. - bing_custom_connection_id: The Bing Custom Search connection ID. - Falls back to BING_CUSTOM_CONNECTION_ID environment variable. - bing_custom_instance_id: The Bing Custom Search instance ID. - Falls back to BING_CUSTOM_INSTANCE_NAME environment variable. - - Returns: - A BingGroundingTool or BingCustomSearchTool instance ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIAgentClient - - # Bing Grounding (explicit) - tool = AzureAIAgentClient.get_web_search_tool( - bing_connection_id="conn_bing_123", - ) - - # Bing Grounding (from environment variable) - tool = AzureAIAgentClient.get_web_search_tool() - - # Bing Custom Search (explicit) - tool = AzureAIAgentClient.get_web_search_tool( - bing_custom_connection_id="conn_custom_123", - bing_custom_instance_id="instance_456", - ) - - # Bing Custom Search (from environment variables) - # Set BING_CUSTOM_CONNECTION_ID and BING_CUSTOM_INSTANCE_NAME - tool = AzureAIAgentClient.get_web_search_tool() - - agent = ChatAgent(client, tools=[tool]) - """ - warnings.warn( - "AzureAIAgentClient.get_web_search_tool() is deprecated and will be removed in a future release; " - "for new Foundry projects, configure hosted tools on the Foundry agent definition in the service instead.", - DeprecationWarning, - stacklevel=2, - ) - # Try explicit Bing Custom Search parameters first, then environment variables - resolved_custom_connection = bing_custom_connection_id or os.environ.get("BING_CUSTOM_CONNECTION_ID") - resolved_custom_instance = bing_custom_instance_id or os.environ.get("BING_CUSTOM_INSTANCE_NAME") - - if resolved_custom_connection and resolved_custom_instance: - return BingCustomSearchTool( - connection_id=resolved_custom_connection, - instance_name=resolved_custom_instance, - ) - - # Try explicit Bing Grounding parameter first, then environment variable - resolved_connection_id = bing_connection_id or os.environ.get("BING_CONNECTION_ID") - if resolved_connection_id: - return BingGroundingTool(connection_id=resolved_connection_id) - - # Azure AI Agents requires Bing connection for web search - raise ValueError( - "Azure AI Agents requires a Bing connection for web search. " - "Provide bing_connection_id (or set BING_CONNECTION_ID env var) for Bing Grounding, " - "or provide both bing_custom_connection_id and bing_custom_instance_id " - "(or set BING_CUSTOM_CONNECTION_ID and BING_CUSTOM_INSTANCE_NAME env vars) for Bing Custom Search." - ) - - @staticmethod - def get_mcp_tool( - *, - name: str, - url: str | None = None, - description: str | None = None, - approval_mode: str | dict[str, list[str]] | None = None, - allowed_tools: list[str] | None = None, - headers: dict[str, str] | None = None, - ) -> McpTool: - """Create a hosted MCP tool configuration for Azure AI Agents. - - .. deprecated:: - This method is deprecated and will be removed in a future release. - For new Foundry projects, configure hosted tools on the Foundry agent definition - in the service instead. - - This configures an MCP (Model Context Protocol) server that will be called - by Azure AI's service. The tools from this MCP server are executed remotely - by Azure AI, not locally by your application. - - Note: - For local MCP execution where your application calls the MCP server - directly, use the MCP client tools instead of this method. - - Keyword Args: - name: A label/name for the MCP server. - url: The URL of the MCP server. - description: A description of what the MCP server provides. - approval_mode: Tool approval mode. Use "always_require" or "never_require" for all tools, - or provide a dict with "always_require_approval" and/or "never_require_approval" - keys mapping to lists of tool names. - allowed_tools: List of tool names that are allowed to be used from this MCP server. - headers: HTTP headers to include in requests to the MCP server. - - Returns: - An McpTool instance ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIAgentClient - - tool = AzureAIAgentClient.get_mcp_tool( - name="my_mcp", - url="https://mcp.example.com", - ) - agent = ChatAgent(client, tools=[tool]) - """ - warnings.warn( - "AzureAIAgentClient.get_mcp_tool() is deprecated and will be removed in a future release; " - "for new Foundry projects, configure hosted tools on the Foundry agent definition in the service instead.", - DeprecationWarning, - stacklevel=2, - ) - mcp_tool = McpTool( - server_label=name.replace(" ", "_"), - server_url=url or "", - allowed_tools=list(allowed_tools) if allowed_tools else [], - ) - - # Set approval mode if provided - # The SDK's set_approval_mode() accepts dict at runtime even though type hints say str. - if approval_mode: - if isinstance(approval_mode, str): - if approval_mode == "never_require": - mcp_tool.set_approval_mode("never") - elif approval_mode == "always_require": - mcp_tool.set_approval_mode("always") - else: - mcp_tool.set_approval_mode(approval_mode) - elif isinstance(approval_mode, dict): - # Handle dict-based approval mode (per-tool approval settings) - if "never_require_approval" in approval_mode: - mcp_tool.set_approval_mode({"never": {"tool_names": approval_mode["never_require_approval"]}}) # type: ignore[arg-type] - elif "always_require_approval" in approval_mode: - mcp_tool.set_approval_mode({"always": {"tool_names": approval_mode["always_require_approval"]}}) # type: ignore[arg-type] - - # Set headers if provided - if headers: - for key, value in headers.items(): - mcp_tool.update_headers(key, value) - - return mcp_tool - - # endregion - - def __init__( - self, - *, - agents_client: AgentsClient | None = None, - agent_id: str | None = None, - agent_name: str | None = None, - agent_description: str | None = None, - thread_id: str | None = None, - project_endpoint: str | None = None, - model_deployment_name: str | None = None, - credential: AzureCredentialTypes | None = None, - should_cleanup_agent: bool = True, - additional_properties: dict[str, Any] | None = None, - middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - function_invocation_configuration: FunctionInvocationConfiguration | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize an Azure AI Agent client. - - Keyword Args: - agents_client: An existing AgentsClient to use. If not provided, one will be created. - agent_id: The ID of an existing agent to use. If not provided and agents_client is provided, - a new agent will be created (and deleted after the request). If neither agents_client - nor agent_id is provided, both will be created and managed automatically. - agent_name: The name to use when creating new agents. - agent_description: The description to use when creating new agents. - thread_id: Default thread ID to use for conversations. Can be overridden by - conversation_id property when making a request. - project_endpoint: The Azure AI Project endpoint URL. - Can also be set via environment variable AZURE_AI_PROJECT_ENDPOINT. - Ignored when a agents_client is passed. - model_deployment_name: The model deployment name to use for agent creation. - Can also be set via environment variable AZURE_AI_MODEL_DEPLOYMENT_NAME. - credential: Azure credential for authentication. Accepts a TokenCredential, - AsyncTokenCredential, or a callable token provider. - should_cleanup_agent: Whether to cleanup (delete) agents created by this client when - the client is closed or context is exited. Defaults to True. Only affects agents - created by this client instance; existing agents passed via agent_id are never deleted. - additional_properties: Additional properties stored on the client instance. - middleware: Optional sequence of middlewares to include. - function_invocation_configuration: Optional function invocation configuration. - env_file_path: Path to environment file for loading settings. - env_file_encoding: Encoding of the environment file. - - Examples: - .. code-block:: python - - from agent_framework_azure_ai import AzureAIAgentClient - from azure.identity.aio import DefaultAzureCredential - - # Using environment variables - # Set AZURE_AI_PROJECT_ENDPOINT=https://your-project.cognitiveservices.azure.com - # Set AZURE_AI_MODEL_DEPLOYMENT_NAME= - credential = DefaultAzureCredential() - client = AzureAIAgentClient(credential=credential) - - # Or passing parameters directly - client = AzureAIAgentClient( - project_endpoint="https://your-project.cognitiveservices.azure.com", - model_deployment_name="", - credential=credential, - ) - - # Or loading from a .env file - client = AzureAIAgentClient(credential=credential, env_file_path="path/to/.env") - - # Using custom ChatOptions with type safety: - from typing import TypedDict - from agent_framework_azure_ai import AzureAIAgentOptions - - - class MyOptions(AzureAIAgentOptions, total=False): - my_custom_option: str - - - client: AzureAIAgentClient[MyOptions] = AzureAIAgentClient(credential=credential) - response = await client.get_response("Hello", options={"my_custom_option": "value"}) - """ - azure_ai_settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - project_endpoint=project_endpoint, - model_deployment_name=model_deployment_name, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - - # If no agents_client is provided, create one - should_close_client = False - if agents_client is None: - resolved_endpoint = azure_ai_settings.get("project_endpoint") - if not resolved_endpoint: - raise ValueError( - "Azure AI project endpoint is required. Set via 'project_endpoint' parameter " - "or 'AZURE_AI_PROJECT_ENDPOINT' environment variable." - ) - - if agent_id is None and not azure_ai_settings.get("model_deployment_name"): - raise ValueError( - "Azure AI model deployment name is required. Set via 'model_deployment_name' parameter " - "or 'AZURE_AI_MODEL_DEPLOYMENT_NAME' environment variable." - ) - - # Use provided credential - if not credential: - raise ValueError("Azure credential is required when agents_client is not provided.") - agents_client = AgentsClient( - endpoint=resolved_endpoint, - credential=credential, # type: ignore[arg-type] - user_agent=AGENT_FRAMEWORK_USER_AGENT, - ) - should_close_client = True - - # Initialize parent - super().__init__( - additional_properties=additional_properties, - middleware=middleware, - function_invocation_configuration=function_invocation_configuration, - ) - - # Initialize instance variables - self.agents_client = agents_client - self.credential = credential - self.agent_id = agent_id - self.agent_name = agent_name - self.agent_description = agent_description - self.model_id = azure_ai_settings.get("model_deployment_name") - self.thread_id = thread_id - self.should_cleanup_agent = should_cleanup_agent # Track whether we should delete the agent - self._agent_created = False # Track whether agent was created inside this class - self._should_close_client = should_close_client # Track whether we should close client connection - self._agent_definition: AzureAgent | None = None # Cached definition for existing agent - - async def __aenter__(self) -> Self: - """Async context manager entry.""" - return self - - async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None: - """Async context manager exit - clean up any agents we created.""" - await self.close() - - async def close(self) -> None: - """Close the agents_client and clean up any agents we created.""" - await self._cleanup_agent_if_needed() - await self._close_client_if_needed() - - @override - def _inner_get_response( - self, - *, - messages: Sequence[Message], - options: Mapping[str, Any], - stream: bool = False, - **kwargs: Any, - ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: - if stream: - # Streaming mode - return the async generator directly - async def _stream() -> AsyncIterable[ChatResponseUpdate]: - # prepare - run_options, required_action_results = await self._prepare_options(messages, options, **kwargs) - agent_id = await self._get_agent_id_or_create(run_options) - - # execute and process - async for update in self._process_stream( - *(await self._create_agent_stream(agent_id, run_options, required_action_results)) - ): - yield update - - return self._build_response_stream(_stream(), response_format=options.get("response_format")) - - # Non-streaming mode - collect updates and convert to response - async def _get_response() -> ChatResponse: - async def _get_streaming() -> AsyncIterable[ChatResponseUpdate]: - # prepare - run_options, required_action_results = await self._prepare_options(messages, options, **kwargs) - agent_id = await self._get_agent_id_or_create(run_options) - - # execute and process - async for update in self._process_stream( - *(await self._create_agent_stream(agent_id, run_options, required_action_results)) - ): - yield update - - return await ChatResponse.from_update_generator( - updates=_get_streaming(), - output_format_type=options.get("response_format"), - ) - - return _get_response() - - async def _get_agent_id_or_create(self, run_options: dict[str, Any] | None = None) -> str: - """Determine which agent to use and create if needed. - - Returns: - str: The agent_id to use - """ - run_options = run_options or {} - # If no agent_id is provided, create a temporary agent - if self.agent_id is None: - if "model" not in run_options or not run_options["model"]: - raise ValueError( - "Model deployment name is required for agent creation, " - "can also be passed to the get_response methods." - ) - - agent_name: str = self.agent_name or "UnnamedAgent" - args: dict[str, Any] = { - "model": run_options["model"], - "name": agent_name, - "description": self.agent_description, - } - if "tools" in run_options: - args["tools"] = run_options["tools"] - if "tool_resources" in run_options: - args["tool_resources"] = run_options["tool_resources"] - if "instructions" in run_options: - args["instructions"] = run_options["instructions"] - if "response_format" in run_options: - args["response_format"] = run_options["response_format"] - - if "temperature" in run_options: - args["temperature"] = run_options["temperature"] - if "top_p" in run_options: - args["top_p"] = run_options["top_p"] - - created_agent = await self.agents_client.create_agent(**args) - - self.agent_id = str(created_agent.id) - self._agent_definition = created_agent - self._agent_created = True - - return self.agent_id - - async def _create_agent_stream( - self, - agent_id: str, - run_options: dict[str, Any], - required_action_results: list[Content] | None, - ) -> tuple[AsyncAgentRunStream[AsyncAgentEventHandler[Any]] | AsyncAgentEventHandler[Any], str]: - """Create the agent stream for processing. - - Returns: - tuple: (stream, final_thread_id) - """ - thread_id = run_options.pop("thread_id", None) - - # Get any active run for this thread - thread_run = await self._get_active_thread_run(thread_id) - - stream: AsyncAgentRunStream[AsyncAgentEventHandler[Any]] | AsyncAgentEventHandler[Any] - handler: AsyncAgentEventHandler[Any] = AsyncAgentEventHandler() - tool_run_id, tool_outputs, tool_approvals = self._prepare_tool_outputs_for_azure_ai(required_action_results) - - if ( - thread_run is not None - and tool_run_id is not None - and tool_run_id == thread_run.id - and (tool_outputs or tool_approvals) - ): # type: ignore[reportUnknownMemberType] - # There's an active run and we have tool results to submit, so submit the results. - args: dict[str, Any] = { - "thread_id": thread_run.thread_id, - "run_id": tool_run_id, - "event_handler": handler, - } - if tool_outputs: - args["tool_outputs"] = tool_outputs - if tool_approvals: - args["tool_approvals"] = tool_approvals - await self.agents_client.runs.submit_tool_outputs_stream(**args) # type: ignore[reportUnknownMemberType] - # Pass the handler to the stream to continue processing - stream = handler - final_thread_id = thread_run.thread_id - else: - # Handle thread creation or cancellation - final_thread_id = await self._prepare_thread(thread_id, thread_run, run_options) - - # Now create a new run and stream the results. - run_options.pop("conversation_id", None) - stream = await self.agents_client.runs.stream( # type: ignore[reportUnknownMemberType] - final_thread_id, agent_id=agent_id, **run_options - ) - - return stream, final_thread_id - - async def _get_active_thread_run(self, thread_id: str | None) -> ThreadRun | None: - """Get any active run for the given thread.""" - if thread_id is None: - return None - - async for run in self.agents_client.runs.list(thread_id=thread_id, limit=1, order=ListSortOrder.DESCENDING): # type: ignore[reportUnknownMemberType] - if run.status not in [ - RunStatus.COMPLETED, - RunStatus.CANCELLED, - RunStatus.FAILED, - RunStatus.EXPIRED, - ]: - return run - return None - - async def _prepare_thread( - self, thread_id: str | None, thread_run: ThreadRun | None, run_options: dict[str, Any] - ) -> str: - """Prepare the thread for a new run, creating or cleaning up as needed.""" - if thread_id is not None: - if thread_run is not None: - # There was an active run; we need to cancel it before starting a new run. - await self.agents_client.runs.cancel(thread_id, thread_run.id) - - return thread_id - - # No thread ID was provided, so create a new thread. - thread = await self.agents_client.threads.create( - tool_resources=run_options.get("tool_resources"), - metadata=run_options.get("metadata"), - messages=run_options.get("additional_messages"), - ) - return thread.id - - def _extract_url_citations( - self, message_delta_chunk: MessageDeltaChunk, azure_search_tool_calls: list[dict[str, Any]] - ) -> list[Annotation]: - """Extract URL citations from MessageDeltaChunk.""" - url_citations: list[Annotation] = [] - - # Process each content item in the delta to find citations - for content in message_delta_chunk.delta.content: - if isinstance(content, MessageDeltaTextContent) and content.text and content.text.annotations: - for annotation in content.text.annotations: - if isinstance(annotation, MessageDeltaTextUrlCitationAnnotation): - # Create annotated regions only if both start and end indices are available - annotated_regions = [] - if annotation.start_index and annotation.end_index: - annotated_regions = [ - TextSpanRegion( - type="text_span", - start_index=annotation.start_index, - end_index=annotation.end_index, - ) - ] - - # Extract real URL from Azure AI Search tool calls - real_url = self._get_real_url_from_citation_reference( - annotation.url_citation.url, azure_search_tool_calls - ) - - # Create Annotation with real URL - citation = Annotation( - type="citation", - title=annotation.url_citation.title, # type: ignore[typeddict-item] - url=real_url, - snippet=None, # type: ignore[typeddict-item] - annotated_regions=annotated_regions, - raw_representation=annotation, - ) - url_citations.append(citation) - - return url_citations - - def _extract_file_path_contents(self, message_delta_chunk: MessageDeltaChunk) -> list[Content]: - """Extract file references from MessageDeltaChunk annotations. - - Code interpreter generates files that are referenced via file path or file citation - annotations in the message content. This method extracts those file IDs and returns - them as HostedFileContent objects. - - Handles two annotation types: - - MessageDeltaTextFilePathAnnotation: Contains file_path.file_id - - MessageDeltaTextFileCitationAnnotation: Contains file_citation.file_id - - Args: - message_delta_chunk: The message delta chunk to process - - Returns: - List of HostedFileContent objects for any files referenced in annotations - """ - file_contents: list[Content] = [] - - for content in message_delta_chunk.delta.content: - if isinstance(content, MessageDeltaTextContent) and content.text and content.text.annotations: - for annotation in content.text.annotations: - if isinstance(annotation, MessageDeltaTextFilePathAnnotation): - # Extract file_id from the file_path annotation - file_path = getattr(annotation, "file_path", None) - if file_path is not None: - file_id = getattr(file_path, "file_id", None) - if file_id: - file_contents.append(Content.from_hosted_file(file_id=file_id)) - elif isinstance(annotation, MessageDeltaTextFileCitationAnnotation): - # Extract file_id from the file_citation annotation - file_citation = getattr(annotation, "file_citation", None) - if file_citation is not None: - file_id = getattr(file_citation, "file_id", None) - if file_id: - file_contents.append(Content.from_hosted_file(file_id=file_id)) - - return file_contents - - def _get_real_url_from_citation_reference( - self, citation_url: str, azure_search_tool_calls: list[dict[str, Any]] - ) -> str: - """Extract real URL from Azure AI Search tool calls based on citation reference. - - Args: - citation_url: Citation reference URL (e.g., "doc_0", "#doc_1", or full URL with doc_N) - azure_search_tool_calls: List of captured Azure AI Search tool calls - - Returns: - Real document URL if found, otherwise original citation_url - """ - # Extract document index from citation URL (e.g., "doc_0" -> 0) - match = re.search(r"doc_(\d+)", citation_url) - if not match: - return citation_url - - doc_index = int(match.group(1)) - - # Get Azure AI Search tool calls - if not azure_search_tool_calls: - return citation_url - - try: - # Extract URLs from the most recent Azure AI Search tool call - tool_call = azure_search_tool_calls[-1] # Most recent call - output_str = tool_call["azure_ai_search"]["output"] - - # Parse the tool call output to get URLs - output_data = ast.literal_eval(output_str) - all_urls = output_data["metadata"]["get_urls"] - - # Return the URL at the specified index, if it exists - if 0 <= doc_index < len(all_urls): - return str(all_urls[doc_index]) - - except (KeyError, IndexError, TypeError, ValueError, SyntaxError) as ex: - logger.debug(f"Failed to extract real URL for {citation_url}: {ex}") - - return citation_url - - async def _process_stream( - self, stream: AsyncAgentRunStream[AsyncAgentEventHandler[Any]] | AsyncAgentEventHandler[Any], thread_id: str - ) -> AsyncIterable[ChatResponseUpdate]: - """Process events from the stream iterator and yield ChatResponseUpdate objects.""" - response_id: str | None = None - # Track Azure Search tool calls for this stream only - azure_search_tool_calls: list[dict[str, Any]] = [] - response_stream = await stream.__aenter__() if isinstance(stream, AsyncAgentRunStream) else stream # type: ignore[no-untyped-call] - try: - async for event_type, event_data, _ in response_stream: - match event_data: - case MessageDeltaChunk(): - # only one event_type: AgentStreamEvent.THREAD_MESSAGE_DELTA - role: Role = "user" if event_data.delta.role == "user" else "assistant" # type: ignore[assignment] - - # Extract URL citations from the delta chunk - url_citations = self._extract_url_citations(event_data, azure_search_tool_calls) - - # Extract file path contents from code interpreter outputs - file_contents = self._extract_file_path_contents(event_data) - - # Create contents with citations if any exist - citation_content: list[Content] = [] - if event_data.text or url_citations: - text_content_obj = Content.from_text(text=event_data.text or "") - if url_citations: - text_content_obj.annotations = url_citations - citation_content.append(text_content_obj) - - # Add file contents from file path annotations - citation_content.extend(file_contents) - - yield ChatResponseUpdate( - role=role, - contents=citation_content if citation_content else None, - conversation_id=thread_id, - message_id=response_id, - raw_representation=event_data, - response_id=response_id, - ) - case ThreadRun(): - # possible event_types: - # AgentStreamEvent.THREAD_RUN_CREATED - # AgentStreamEvent.THREAD_RUN_QUEUED - # AgentStreamEvent.THREAD_RUN_INCOMPLETE - # AgentStreamEvent.THREAD_RUN_IN_PROGRESS - # AgentStreamEvent.THREAD_RUN_REQUIRES_ACTION - # AgentStreamEvent.THREAD_RUN_COMPLETED - # AgentStreamEvent.THREAD_RUN_FAILED - # AgentStreamEvent.THREAD_RUN_CANCELLING - # AgentStreamEvent.THREAD_RUN_CANCELLED - # AgentStreamEvent.THREAD_RUN_EXPIRED - match event_type: - case AgentStreamEvent.THREAD_RUN_REQUIRES_ACTION: - if event_data.required_action and event_data.required_action.type in [ - "submit_tool_outputs", - "submit_tool_approval", - ]: - function_call_contents = self._parse_function_calls_from_azure_ai( - event_data, response_id - ) - if function_call_contents: - yield ChatResponseUpdate( - role="assistant", - contents=function_call_contents, - conversation_id=thread_id, - message_id=response_id, - raw_representation=event_data, - response_id=response_id, - ) - case AgentStreamEvent.THREAD_RUN_FAILED: - raise ChatClientException(event_data.last_error.message) - case _: - yield ChatResponseUpdate( - contents=[], - conversation_id=event_data.thread_id, - message_id=response_id, - raw_representation=event_data, - response_id=response_id, - role="assistant", - model_id=event_data.model, - ) - - case RunStep(): - # possible event_types: - # AgentStreamEvent.THREAD_RUN_STEP_CREATED, - # AgentStreamEvent.THREAD_RUN_STEP_IN_PROGRESS, - # AgentStreamEvent.THREAD_RUN_STEP_COMPLETED, - # AgentStreamEvent.THREAD_RUN_STEP_FAILED, - # AgentStreamEvent.THREAD_RUN_STEP_CANCELLED, - # AgentStreamEvent.THREAD_RUN_STEP_EXPIRED, - match event_type: - case AgentStreamEvent.THREAD_RUN_STEP_CREATED: - response_id = event_data.run_id - case AgentStreamEvent.THREAD_RUN_COMPLETED | AgentStreamEvent.THREAD_RUN_STEP_COMPLETED: - # Capture Azure AI Search tool calls when steps complete - if event_type == AgentStreamEvent.THREAD_RUN_STEP_COMPLETED: - self._capture_azure_search_tool_calls(event_data, azure_search_tool_calls) - - if event_data.usage: - usage_content = Content.from_usage( - UsageDetails( - input_token_count=event_data.usage.prompt_tokens, - output_token_count=event_data.usage.completion_tokens, - total_token_count=event_data.usage.total_tokens, - ) - ) - yield ChatResponseUpdate( - role="assistant", - contents=[usage_content], - conversation_id=thread_id, - message_id=response_id, - raw_representation=event_data, - response_id=response_id, - ) - case _: - yield ChatResponseUpdate( - contents=[], - conversation_id=thread_id, - message_id=response_id, - raw_representation=event_data, - response_id=response_id, - role="assistant", - ) - case RunStepDeltaChunk(): # type: ignore - step_details = event_data.delta.step_details - if step_details is not None and step_details.type == "tool_calls": - tool_calls = cast(list[RunStepDeltaToolCall], step_details.tool_calls) # type: ignore - for tool_call in tool_calls: - if tool_call.type == "code_interpreter" and tool_call.code_interpreter is not None: # type: ignore[attr-defined, reportUnknownMemberType] - code_contents: list[Content] = [] - if tool_call.code_interpreter.input is not None: # type: ignore[attr-defined, reportUnknownMemberType] - logger.debug(f"Code Interpreter Input: {tool_call.code_interpreter.input}") # type: ignore[attr-defined, reportUnknownMemberType] - if tool_call.code_interpreter.outputs is not None: # type: ignore[attr-defined, reportUnknownMemberType] - for output in tool_call.code_interpreter.outputs: # type: ignore[attr-defined, reportUnknownMemberType] - if isinstance(output, RunStepDeltaCodeInterpreterLogOutput) and output.logs: - code_contents.append(Content.from_text(text=output.logs)) - if ( - isinstance(output, RunStepDeltaCodeInterpreterImageOutput) - and output.image is not None - and output.image.file_id is not None - ): - code_contents.append( - Content.from_hosted_file(file_id=output.image.file_id) - ) - yield ChatResponseUpdate( - role="assistant", - contents=code_contents, - conversation_id=thread_id, - message_id=response_id, - raw_representation=tool_call.code_interpreter, # type: ignore[attr-defined, reportUnknownMemberType] - response_id=response_id, - ) - case _: # ThreadMessage or string - # possible event_types for ThreadMessage: - # AgentStreamEvent.THREAD_MESSAGE_CREATED - # AgentStreamEvent.THREAD_MESSAGE_IN_PROGRESS - # AgentStreamEvent.THREAD_MESSAGE_COMPLETED - # AgentStreamEvent.THREAD_MESSAGE_INCOMPLETE - yield ChatResponseUpdate( - contents=[], - conversation_id=thread_id, - message_id=response_id, - raw_representation=event_data, # type: ignore - response_id=response_id, - role="assistant", - ) - except Exception as ex: - logger.error(f"Error processing stream: {ex}") - raise - finally: - if isinstance(stream, AsyncAgentRunStream): - await stream.__aexit__(None, None, None) # type: ignore[no-untyped-call] - - def _capture_azure_search_tool_calls( - self, step_data: RunStep, azure_search_tool_calls: list[dict[str, Any]] - ) -> None: - """Capture Azure AI Search tool call data from completed steps.""" - try: - step_details = getattr(step_data, "step_details", None) - tool_calls = getattr(step_details, "tool_calls", None) if step_details is not None else None - if isinstance(tool_calls, list): - for tool_call in cast(list[object], tool_calls): - if getattr(tool_call, "type", None) == "azure_ai_search": - # Store the complete tool call as a dictionary - tool_call_dict = { - "id": getattr(tool_call, "id", None), - "type": getattr(tool_call, "type", None), - "azure_ai_search": getattr(tool_call, "azure_ai_search", None), - } - azure_search_tool_calls.append(tool_call_dict) - logger.debug(f"Captured Azure AI Search tool call: {tool_call_dict['id']}") - except Exception as ex: - logger.debug(f"Failed to capture Azure AI Search tool call: {ex}") - - def _parse_function_calls_from_azure_ai(self, event_data: ThreadRun, response_id: str | None) -> list[Content]: - """Parse function call contents from an Azure AI tool action event.""" - if isinstance(event_data, ThreadRun) and event_data.required_action is not None: - if isinstance(event_data.required_action, SubmitToolOutputsAction): - return [ - Content.from_function_call( - call_id=f'["{response_id}", "{tool.id}"]', - name=tool.function.name, - arguments=tool.function.arguments, - ) - for tool in event_data.required_action.submit_tool_outputs.tool_calls - if isinstance(tool, RequiredFunctionToolCall) - ] - if isinstance(event_data.required_action, SubmitToolApprovalAction): - return [ - Content.from_function_approval_request( - id=f'["{response_id}", "{tool.id}"]', - function_call=Content.from_function_call( - call_id=f'["{response_id}", "{tool.id}"]', - name=tool.name, - arguments=tool.arguments, - raw_representation=tool, - ), - raw_representation=tool, - ) - for tool in event_data.required_action.submit_tool_approval.tool_calls - if isinstance(tool, RequiredMcpToolCall) - ] - return [] - - async def _close_client_if_needed(self) -> None: - """Close agents_client session if we created it.""" - if self._should_close_client: - await self.agents_client.close() - - async def _cleanup_agent_if_needed(self) -> None: - """Clean up the agent if we created it.""" - if self._agent_created and self.should_cleanup_agent and self.agent_id is not None: - await self.agents_client.delete_agent(self.agent_id) - self.agent_id = None - self._agent_created = False - - async def _load_agent_definition_if_needed(self) -> AzureAgent | None: - """Load and cache agent details if not already loaded.""" - if self._agent_definition is None and self.agent_id is not None: - self._agent_definition = await self.agents_client.get_agent(self.agent_id) - return self._agent_definition - - async def _prepare_options( - self, - messages: Sequence[Message], - options: Mapping[str, Any], - **kwargs: Any, - ) -> tuple[dict[str, Any], list[Content] | None]: - agent_definition = await self._load_agent_definition_if_needed() - - # Build run_options from options dict, excluding specific keys - exclude_keys = { - "type", - "instructions", # handled via messages - "tools", # handled separately - "tool_choice", # handled separately - "response_format", # handled separately - "additional_properties", # handled separately - "frequency_penalty", # not supported - "presence_penalty", # not supported - "user", # not supported - "stop", # not supported - "logit_bias", # not supported - "seed", # not supported - "store", # not supported - } - run_options: dict[str, Any] = {k: v for k, v in options.items() if k not in exclude_keys and v is not None} - - # Translation between ChatOptions and Azure AI Agents API - translations = { - "model_id": "model", - "allow_multiple_tool_calls": "parallel_tool_calls", - "max_tokens": "max_completion_tokens", - } - for old_key, new_key in translations.items(): - if old_key in run_options and old_key != new_key: - run_options[new_key] = run_options.pop(old_key) - - # model id fallback - if not run_options.get("model"): - run_options["model"] = self.model_id - - # tools and tool_choice - if tool_definitions := await self._prepare_tool_definitions_and_resources( - options, agent_definition, run_options - ): - run_options["tools"] = tool_definitions - - if tool_choice := self._prepare_tool_choice_mode(options): - run_options["tool_choice"] = tool_choice - - # response format - response_format = options.get("response_format") - if response_format is not None: - if isinstance(response_format, type) and issubclass(response_format, BaseModel): - # Pydantic model - convert to Azure format - run_options["response_format"] = ResponseFormatJsonSchemaType( - json_schema=ResponseFormatJsonSchema( - name=response_format.__name__, - schema=response_format.model_json_schema(), - ) - ) - elif isinstance(response_format, Mapping): - # Runtime JSON schema dict - pass through as-is - run_options["response_format"] = response_format - else: - raise ChatClientInvalidRequestException( - "response_format must be a Pydantic BaseModel class or a dict with runtime JSON schema." - ) - - # messages - additional_messages, instructions, required_action_results = self._prepare_messages(messages) - if additional_messages: - run_options["additional_messages"] = additional_messages - - # Add instructions from options (agent's instructions set via as_agent()) - if options_instructions := options.get("instructions"): - instructions.append(options_instructions) - - # Add instruction from existing agent at the beginning - if ( - agent_definition is not None - and agent_definition.instructions - and agent_definition.instructions not in instructions - ): - instructions.insert(0, agent_definition.instructions) - - if instructions: - run_options["instructions"] = "\n".join(instructions) - - # thread_id resolution (conversation_id takes precedence, then kwargs, then instance default) - run_options["thread_id"] = options.get("conversation_id") or kwargs.get("conversation_id") or self.thread_id - - return run_options, required_action_results - - def _prepare_tool_choice_mode( - self, options: Mapping[str, Any] - ) -> AgentsToolChoiceOptionMode | AgentsNamedToolChoice | None: - """Prepare the tool choice mode for Azure AI Agents API.""" - tool_choice = cast(str | dict[str, str] | None, options.get("tool_choice")) - if tool_choice is None: - return None - if isinstance(tool_choice, str) and tool_choice in {"none", "auto"}: - return AgentsToolChoiceOptionMode(tool_choice) - if isinstance(tool_choice, dict): - mode = tool_choice.get("mode") - req_fn = tool_choice.get("required_function_name") - if mode == "required" and req_fn is not None: - return AgentsNamedToolChoice( - type=AgentsNamedToolChoiceType.FUNCTION, - function=FunctionName(name=req_fn), - ) - return None - - async def _prepare_tool_definitions_and_resources( - self, - options: Mapping[str, Any], - agent_definition: AzureAgent | None, - run_options: dict[str, Any], - ) -> list[ToolDefinition | dict[str, Any]]: - """Prepare tool definitions and resources for the run options.""" - tool_definitions: list[ToolDefinition | dict[str, Any]] = [] - - # Add tools from existing agent (exclude function tools - passed via options.get("tools")) - if agent_definition is not None: - agent_tools = [tool for tool in agent_definition.tools if not isinstance(tool, FunctionToolDefinition)] - if agent_tools: - tool_definitions.extend(agent_tools) - if agent_definition.tool_resources: - run_options["tool_resources"] = agent_definition.tool_resources - - # Add run tools - always include tools if provided, regardless of tool_choice - # tool_choice="none" means the model won't call tools, but tools should still be available - tools = options.get("tools") - if tools: - tool_definitions.extend(to_azure_ai_agent_tools(tools, run_options)) - - # Handle MCP tool resources - mcp_resources = self._prepare_mcp_resources(tools) - if mcp_resources: - if "tool_resources" not in run_options: - run_options["tool_resources"] = {} - run_options["tool_resources"]["mcp"] = mcp_resources - - return tool_definitions - - def _prepare_mcp_resources(self, tools: Sequence[Any]) -> list[dict[str, Any]]: - """Prepare MCP tool resources for approval mode configuration. - - Extracts MCP resources from McpTool instances including server_label, - require_approval, and headers. - """ - mcp_resources: list[dict[str, Any]] = [] - for tool in tools: - if isinstance(tool, McpTool): - # Use the resources property which includes all config (approval, headers) - tool_resources = tool.resources - if tool_resources and tool_resources.mcp: - for mcp_resource in tool_resources.mcp: - resource_dict: dict[str, Any] = {"server_label": mcp_resource.server_label} - if mcp_resource.require_approval: - resource_dict["require_approval"] = mcp_resource.require_approval - if mcp_resource.headers: - resource_dict["headers"] = mcp_resource.headers - mcp_resources.append(resource_dict) - return mcp_resources - - def _prepare_messages( - self, messages: Sequence[Message] - ) -> tuple[ - list[ThreadMessageOptions] | None, - list[str], - list[Content] | None, - ]: - """Prepare messages for Azure AI Agents API. - - System/developer messages are turned into instructions, since there is no such message roles in Azure AI. - All other messages are added 1:1, treating assistant messages as agent messages - and everything else as user messages. - - Returns: - Tuple of (additional_messages, instructions, required_action_results) - """ - instructions: list[str] = [] - required_action_results: list[Content] | None = None - additional_messages: list[ThreadMessageOptions] | None = None - - for chat_message in messages: - if chat_message.role in ["system", "developer"]: - for text_content in [content for content in chat_message.contents if content.type == "text"]: - instructions.append(text_content.text) # type: ignore[arg-type] - continue - - message_contents: list[MessageInputContentBlock] = [] - - for content in chat_message.contents: - match content.type: - case "text": - message_contents.append(MessageInputTextBlock(text=content.text)) # type: ignore[arg-type] - case "data" | "uri": - if content.has_top_level_media_type("image"): - message_contents.append( - MessageInputImageUrlBlock(image_url=MessageImageUrlParam(url=content.uri)) # type: ignore[arg-type] - ) - # Only images are supported. Other media types are ignored. - case "function_result" | "function_approval_response": - if required_action_results is None: - required_action_results = [] - required_action_results.append(content) - case _: - if isinstance(content.raw_representation, MessageInputContentBlock): - message_contents.append(content.raw_representation) - - if message_contents: - if additional_messages is None: - additional_messages = [] - additional_messages.append( - ThreadMessageOptions( - role=MessageRole.AGENT if chat_message.role == "assistant" else MessageRole.USER, - content=message_contents, - ) - ) - - return additional_messages, instructions, required_action_results - - async def _prepare_tools_for_azure_ai( - self, tools: Sequence[Any], run_options: dict[str, Any] | None = None - ) -> list[Any]: - """Prepare tool definitions for the Azure AI Agents API. - - Converts FunctionTool to JSON schema format. SDK Tool wrappers with .definitions - are unpacked. All other tools (ToolDefinition, dict, etc.) pass through unchanged. - - Args: - tools: Sequence of tools to prepare. - run_options: Optional run options dict that may be updated with tool_resources. - - Returns: - List of tool definitions ready for the Azure AI API. - """ - tool_definitions: list[Any] = [] - for tool in tools: - if isinstance(tool, FunctionTool): - tool_definitions.append(tool.to_json_schema_spec()) - elif hasattr(tool, "definitions") and not isinstance(tool, MutableMapping): - # SDK Tool wrappers (McpTool, FileSearchTool, BingGroundingTool, etc.) - tool_definitions.extend(tool.definitions) - # Handle tool resources (MCP resources handled separately by _prepare_mcp_resources) - resources = getattr(tool, "resources", None) - if run_options is not None and resources and isinstance(resources, Mapping) and "mcp" not in resources: - run_options.setdefault("tool_resources", {}) - run_options["tool_resources"].update(tool.resources) - else: - # Pass through ToolDefinition, dict, and other types unchanged - tool_definitions.append(tool) - return tool_definitions - - def _prepare_tool_outputs_for_azure_ai( - self, - required_action_results: list[Content] | None, - ) -> tuple[str | None, list[ToolOutput] | None, list[ToolApproval] | None]: - """Prepare function results and approvals for submission to the Azure AI API.""" - run_id: str | None = None - tool_outputs: list[ToolOutput] | None = None - tool_approvals: list[ToolApproval] | None = None - - if required_action_results: - for content in required_action_results: - # When creating the FunctionCallContent/ApprovalRequestContent, - # we created it with a CallId == [runId, callId]. - # We need to extract the run ID and ensure that the Output/Approval we send back to Azure - # is only the call ID. - run_and_call_ids: list[str] = ( - json.loads(content.call_id) if content.type == "function_result" else json.loads(content.id) # type: ignore[arg-type] - ) - - if ( - not run_and_call_ids - or len(run_and_call_ids) != 2 - or not run_and_call_ids[0] - or not run_and_call_ids[1] - or (run_id is not None and run_id != run_and_call_ids[0]) - ): - continue - - run_id = run_and_call_ids[0] - call_id = run_and_call_ids[1] - - if content.type == "function_result": - if content.items: - text_parts = [item.text or "" for item in content.items if item.type == "text"] - rich_items = [item for item in content.items if item.type in ("data", "uri")] - if rich_items: - logger.warning( - "Azure AI Agents does not support rich content (images, audio) in tool results. " - "Rich content items will be omitted." - ) - output_text = "\n".join(text_parts) if text_parts else "" - else: - output_text = content.result if content.result is not None else "" - if tool_outputs is None: - tool_outputs = [] - tool_outputs.append(ToolOutput(tool_call_id=call_id, output=output_text)) - elif content.type == "function_approval_response": - if tool_approvals is None: - tool_approvals = [] - tool_approvals.append(ToolApproval(tool_call_id=call_id, approve=content.approved)) # type: ignore[arg-type] - - return run_id, tool_outputs, tool_approvals - - def _update_agent_name_and_description(self, agent_name: str | None, description: str | None) -> None: - """Update the agent name in the chat client. - - Args: - agent_name: The new name for the agent. - description: The new description for the agent. - """ - # This is a no-op in the base class, but can be overridden by subclasses - # to update the agent name in the client. - if agent_name and not self.agent_name: - self.agent_name = agent_name - if description and not self.agent_description: - self.agent_description = description - - def service_url(self) -> str: - """Get the service URL for the chat client. - - Returns: - The service URL for the chat client, or None if not set. - """ - return self.agents_client._config.endpoint # type: ignore - - @override - def as_agent( - self, - *, - id: str | None = None, - name: str | None = None, - description: str | None = None, - instructions: str | None = None, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - default_options: AzureAIAgentOptionsT | Mapping[str, Any] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - **kwargs: Any, - ) -> Agent[AzureAIAgentOptionsT]: - """Convert this chat client to a Agent. - - This method creates a Agent instance with this client pre-configured. - It does NOT create an agent on the Azure AI service - the actual agent - will be created on the server during the first invocation (run). - - For creating and managing persistent agents on the server, use - :class:`~agent_framework_azure_ai.AzureAIAgentsProvider` instead. - - Keyword Args: - id: The unique identifier for the agent. Will be created automatically if not provided. - name: The name of the agent. Defaults to the client's ``agent_name`` when None. - description: A brief description of the agent's purpose. Defaults to the client's - ``agent_description`` when None. - instructions: Optional instructions for the agent. - tools: The tools to use for the request. - default_options: A TypedDict containing chat options. - context_providers: Context providers to include during agent invocation. - middleware: List of middleware to intercept agent and function invocations. - kwargs: Any additional keyword arguments. - - Returns: - A Agent instance configured with this chat client. - """ - return super().as_agent( - id=id, - name=self.agent_name if name is None else name, - description=self.agent_description if description is None else description, - instructions=instructions, - tools=tools, - default_options=default_options, - context_providers=context_providers, - middleware=middleware, - **kwargs, - ) diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_client.py deleted file mode 100644 index f4ceb92b5b..0000000000 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ /dev/null @@ -1,1329 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import json -import logging -import re -import sys -from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence -from contextlib import suppress -from typing import Any, ClassVar, Generic, Literal, TypedDict, TypeVar, cast - -from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, - Agent, - Annotation, - BaseContextProvider, - ChatAndFunctionMiddlewareTypes, - ChatMiddlewareLayer, - ChatResponse, - ChatResponseUpdate, - Content, - FunctionInvocationConfiguration, - FunctionInvocationLayer, - FunctionTool, - Message, - MiddlewareTypes, - ResponseStream, - TextSpanRegion, -) -from agent_framework._settings import load_settings -from agent_framework._tools import ToolTypes -from agent_framework.observability import ChatTelemetryLayer -from agent_framework.openai import OpenAIResponsesOptions -from agent_framework_openai._chat_client import RawOpenAIChatClient -from azure.ai.projects.aio import AIProjectClient -from azure.ai.projects.models import ( - ApproximateLocation, - AutoCodeInterpreterToolParam, - CodeInterpreterTool, - ImageGenTool, - MCPTool, - PromptAgentDefinition, - PromptAgentDefinitionTextOptions, - RaiConfig, - Reasoning, - WebSearchPreviewTool, -) -from azure.ai.projects.models import FileSearchTool as ProjectsFileSearchTool -from azure.core.exceptions import ResourceNotFoundError - -from ._entra_id_authentication import AzureCredentialTypes -from ._shared import AzureAISettings, create_text_format_config, resolve_file_ids - -if sys.version_info >= (3, 13): - from typing import TypeVar # type: ignore # pragma: no cover - from warnings import deprecated # type: ignore # pragma: no cover -else: - from typing_extensions import TypeVar, deprecated # type: ignore # pragma: no cover -if sys.version_info >= (3, 12): - from typing import override # type: ignore # pragma: no cover -else: - from typing_extensions import override # type: ignore[import] # pragma: no cover -if sys.version_info >= (3, 11): - from typing import Self, TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import Self, TypedDict # type: ignore # pragma: no cover - -logger = logging.getLogger("agent_framework.azure") - - -class AzureAIProjectAgentOptions(OpenAIResponsesOptions, total=False): # type: ignore[misc, call-arg] - """Azure AI Project Agent options.""" - - rai_config: RaiConfig - """Configuration for Responsible AI (RAI) content filtering and safety features.""" - - reasoning: Reasoning # type: ignore[misc] - """Configuration for enabling reasoning capabilities (requires azure.ai.projects.models.Reasoning).""" - - -AzureAIClientOptionsT = TypeVar( - "AzureAIClientOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="AzureAIProjectAgentOptions", - covariant=True, -) - -_DOC_INDEX_PATTERN = re.compile(r"doc_(\d+)") - - -@deprecated( - "RawAzureAIClient is deprecated. " - "Use RawFoundryAgentChatClient for low-level Foundry agent client customization, " - "or FoundryAgent for the recommended production API." -) -class RawAzureAIClient(RawOpenAIChatClient[AzureAIClientOptionsT], Generic[AzureAIClientOptionsT]): - """Deprecated raw Azure AI client without middleware, telemetry, or function invocation layers. - - Warning: - **This class should not normally be used directly.** It does not include middleware, - telemetry, or function invocation support that you most likely need. If you do use it, - you should consider which additional layers to apply. There is a defined ordering that - you should follow: - - 1. **FunctionInvocationLayer** - Owns the tool/function calling loop and routes function middleware - 2. **ChatMiddlewareLayer** - Applies chat middleware per model call and stays outside telemetry - 3. **ChatTelemetryLayer** - Must stay inside chat middleware for correct per-call telemetry - - Use ``RawFoundryAgentChatClient`` for low-level Foundry agent customization, or - ``FoundryAgent`` for the recommended production API. - """ - - OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc] - - def __init__( - self, - *, - project_client: AIProjectClient | None = None, - agent_name: str | None = None, - agent_version: str | None = None, - agent_description: str | None = None, - conversation_id: str | None = None, - project_endpoint: str | None = None, - model_deployment_name: str | None = None, - credential: AzureCredentialTypes | None = None, - use_latest_version: bool | None = None, - allow_preview: bool | None = None, - additional_properties: dict[str, Any] | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize a bare Azure AI client. - - This is the core implementation without middleware, telemetry, or function invocation layers. - For most use cases, prefer :class:`AzureAIClient` which includes all standard layers. - - Keyword Args: - project_client: An existing AIProjectClient to use. If not provided, one will be created. - agent_name: The name to use when creating new agents or using existing agents. - agent_version: The version of the agent to use. - agent_description: The description to use when creating new agents. - conversation_id: Default conversation ID to use for conversations. Can be overridden by - conversation_id property when making a request. - project_endpoint: The Azure AI Project endpoint URL. - Can also be set via environment variable AZURE_AI_PROJECT_ENDPOINT. - Ignored when a project_client is passed. - model_deployment_name: The model deployment name to use for agent creation. - Can also be set via environment variable AZURE_AI_MODEL_DEPLOYMENT_NAME. - credential: Azure credential for authentication. Accepts a TokenCredential, - AsyncTokenCredential, or a callable token provider. - use_latest_version: Boolean flag that indicates whether to use latest agent version - if it exists in the service. - allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``. - additional_properties: Additional properties stored on the client instance. - env_file_path: Path to environment file for loading settings. - env_file_encoding: Encoding of the environment file. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIClient - from azure.identity.aio import DefaultAzureCredential - - # Using environment variables - # Set AZURE_AI_PROJECT_ENDPOINT=https://your-project.cognitiveservices.azure.com - # Set AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4 - credential = DefaultAzureCredential() - client = AzureAIClient(credential=credential) - - # Or passing parameters directly - client = AzureAIClient( - project_endpoint="https://your-project.cognitiveservices.azure.com", - model_deployment_name="gpt-4", - credential=credential, - ) - - # Or loading from a .env file - client = AzureAIClient(credential=credential, env_file_path="path/to/.env") - - # Using custom ChatOptions with type safety: - from typing import TypedDict - from agent_framework import ChatOptions - - - class MyOptions(ChatOptions, total=False): - my_custom_option: str - - - client: AzureAIClient[MyOptions] = AzureAIClient(credential=credential) - response = await client.get_response("Hello", options={"my_custom_option": "value"}) - """ - azure_ai_settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - project_endpoint=project_endpoint, - model_deployment_name=model_deployment_name, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - - # If no project_client is provided, create one - should_close_client = False - if project_client is None: - resolved_endpoint = azure_ai_settings.get("project_endpoint") - if not resolved_endpoint: - raise ValueError( - "Azure AI project endpoint is required. Set via 'project_endpoint' parameter " - "or 'AZURE_AI_PROJECT_ENDPOINT' environment variable." - ) - - # Use provided credential - if not credential: - raise ValueError("Azure credential is required when project_client is not provided.") - project_client_kwargs: dict[str, Any] = { - "endpoint": resolved_endpoint, - "credential": credential, # type: ignore[arg-type] - "user_agent": AGENT_FRAMEWORK_USER_AGENT, - } - if allow_preview is not None: - project_client_kwargs["allow_preview"] = allow_preview - project_client = AIProjectClient(**project_client_kwargs) - should_close_client = True - - # Initialize parent with OpenAI client from project - super().__init__( # type: ignore - async_client=project_client.get_openai_client(), - model=azure_ai_settings.get("model"), # type: ignore[arg-type] - additional_properties=additional_properties, - ) - - # Initialize instance variables - self.agent_name = agent_name - self.agent_version = agent_version - self.agent_description = agent_description - self.use_latest_version = use_latest_version - self.project_client = project_client - self.credential = credential - self.model_id = azure_ai_settings.get("model_deployment_name") - self.conversation_id = conversation_id - - # Track whether the application endpoint is used - self._is_application_endpoint = "/applications/" in project_client._config.endpoint # type: ignore - # Track whether we should close client connection - self._should_close_client = should_close_client - # Track creation-time agent configuration for runtime mismatch warnings. - self.warn_runtime_tools_and_structure_changed = False - self._created_agent_tool_names: set[str] = set() - self._created_agent_structured_output_signature: str | None = None - - async def configure_azure_monitor( - self, - enable_sensitive_data: bool = False, - **kwargs: Any, - ) -> None: - """Setup observability with Azure Monitor (Azure AI Foundry integration). - - This method configures Azure Monitor for telemetry collection using the - connection string from the Azure AI project client. - - Args: - enable_sensitive_data: Enable sensitive data logging (prompts, responses). - Should only be enabled in development/test environments. Default is False. - **kwargs: Additional arguments passed to configure_azure_monitor(). - Common options include: - - enable_live_metrics (bool): Enable Azure Monitor Live Metrics - - credential (TokenCredential): Azure credential for Entra ID auth - - resource (Resource): Custom OpenTelemetry resource - See https://learn.microsoft.com/python/api/azure-monitor-opentelemetry/azure.monitor.opentelemetry.configure_azure_monitor - for full list of options. - - Raises: - ImportError: If azure-monitor-opentelemetry-exporter is not installed. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIClient - from azure.ai.projects.aio import AIProjectClient - from azure.identity.aio import DefaultAzureCredential - - async with ( - DefaultAzureCredential() as credential, - AIProjectClient( - endpoint="https://your-project.api.azureml.ms", credential=credential - ) as project_client, - AzureAIClient(project_client=project_client) as client, - ): - # Setup observability with defaults - await client.configure_azure_monitor() - - # With live metrics enabled - await client.configure_azure_monitor(enable_live_metrics=True) - - # With sensitive data logging (dev/test only) - await client.configure_azure_monitor(enable_sensitive_data=True) - - Note: - This method retrieves the Application Insights connection string from the - Azure AI project client automatically. You must have Application Insights - configured in your Azure AI project for this to work. - """ - # Get connection string from project client - try: - conn_string = await self.project_client.telemetry.get_application_insights_connection_string() - except ResourceNotFoundError: - logger.warning( - "No Application Insights connection string found for the Azure AI Project. " - "Please ensure Application Insights is configured in your Azure AI project, " - "or call configure_otel_providers() manually with custom exporters." - ) - return - - # Import Azure Monitor with proper error handling - try: - from azure.monitor.opentelemetry import configure_azure_monitor # type: ignore[import] - except ImportError as exc: - raise ImportError( - "azure-monitor-opentelemetry is required for Azure Monitor integration. " - "Install it with: pip install azure-monitor-opentelemetry" - ) from exc - - from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation - - # Create resource if not provided in kwargs - if "resource" not in kwargs: - kwargs["resource"] = create_resource() - - # Configure Azure Monitor with connection string and kwargs - configure_azure_monitor( - connection_string=conn_string, - views=create_metric_views(), - **kwargs, - ) - - # Complete setup with core observability - enable_instrumentation(enable_sensitive_data=enable_sensitive_data) - - async def __aenter__(self) -> Self: - """Async context manager entry.""" - return self - - async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None: - """Async context manager exit.""" - await self.close() - - async def close(self) -> None: - """Close the project_client.""" - await self._close_client_if_needed() - - async def _get_agent_reference_or_create( - self, - run_options: dict[str, Any], - messages_instructions: str | None, - chat_options: Mapping[str, Any] | None = None, - ) -> dict[str, str]: - """Determine which agent to use and create if needed. - - Args: - run_options: The prepared options for the API call. - messages_instructions: Instructions extracted from messages. - chat_options: The chat options containing response_format and other settings. - - Returns: - dict[str, str]: The agent reference to use. - """ - # Agent name must be explicitly provided by the user. - if self.agent_name is None: - raise ValueError( - "Agent name is required. Provide 'agent_name' when initializing AzureAIClient " - "or 'name' when initializing Agent." - ) - # If the agent exists and we do not want to track agent configuration, return early - if self.agent_version is not None and not self.warn_runtime_tools_and_structure_changed: - return {"name": self.agent_name, "version": self.agent_version, "type": "agent_reference"} - - # If no agent_version is provided, either use latest version or create a new agent: - if self.agent_version is None: - # Try to use latest version if requested and agent exists - if self.use_latest_version: - with suppress(ResourceNotFoundError): - existing_agent = await self.project_client.agents.get(self.agent_name) - self.agent_version = existing_agent.versions.latest.version - return {"name": self.agent_name, "version": self.agent_version, "type": "agent_reference"} - - if "model" not in run_options or not run_options["model"]: - raise ValueError( - "Model deployment name is required for agent creation, " - "can also be passed to the get_response methods." - ) - - args: dict[str, Any] = {"model": run_options["model"]} - - if "tools" in run_options: - args["tools"] = run_options["tools"] - if "temperature" in run_options: - args["temperature"] = run_options["temperature"] - if "top_p" in run_options: - args["top_p"] = run_options["top_p"] - if "reasoning" in run_options: - args["reasoning"] = run_options["reasoning"] - if "rai_config" in run_options: - args["rai_config"] = run_options["rai_config"] - - # response_format is accessed from chat_options or additional_properties - # since the base class excludes it from run_options - if chat_options and (response_format := chat_options.get("response_format")): - args["text"] = PromptAgentDefinitionTextOptions(format=create_text_format_config(response_format)) - - # Combine instructions from messages and options - # instructions is accessed from chat_options since the base class excludes it from run_options - combined_instructions = [ - instructions - for instructions in [messages_instructions, chat_options.get("instructions") if chat_options else None] - if instructions - ] - if combined_instructions: - args["instructions"] = "".join(combined_instructions) - - create_version_kwargs: dict[str, Any] = { - "agent_name": self.agent_name, - "definition": PromptAgentDefinition(**args), - "description": self.agent_description, - } - - created_agent = await self.project_client.agents.create_version(**create_version_kwargs) - - self.agent_version = created_agent.version - self.warn_runtime_tools_and_structure_changed = True - self._created_agent_tool_names = self._extract_tool_names(run_options.get("tools")) - self._created_agent_structured_output_signature = self._get_structured_output_signature(chat_options) - return {"name": self.agent_name, "version": self.agent_version, "type": "agent_reference"} - - async def _close_client_if_needed(self) -> None: - """Close project_client session if we created it.""" - if self._should_close_client: - await self.project_client.close() - - def _extract_tool_names(self, tools: Any) -> set[str]: - """Extract comparable tool names from runtime tool payloads.""" - if not isinstance(tools, Sequence) or isinstance(tools, str | bytes): - return set() - tool_names: set[str] = set() - for tool_item in cast(Sequence[object], tools): - tool_names.add(self._get_tool_name(tool_item)) - return tool_names - - def _get_tool_name(self, tool: Any) -> str: - """Get a stable name for a tool for runtime comparison.""" - if isinstance(tool, FunctionTool): - return tool.name - - if isinstance(tool, Mapping): - tool_type = tool.get("type") # type: ignore[reportUnknownMemberType] - if tool_type == "function": - function_data = tool.get("function") # type: ignore[reportUnknownMemberType] - if isinstance(function_data, Mapping) and (function_name := function_data.get("name")): # type: ignore[assignment] - return function_name # type: ignore[no-any-return] - if tool_name := tool.get("name"): # type: ignore[reportUnknownMemberType] - return tool_name # type: ignore[no-any-return] - if server_label := tool.get("server_label"): # type: ignore[reportUnknownMemberType] - return f"mcp:{server_label}" - if tool_type: - return tool_type # type: ignore[no-any-return] - raise ValueError("Dict based tool definitions must include a 'name' property for runtime comparison.") - - if name_value := getattr(tool, "name", None): - return name_value # type: ignore[no-any-return] - if server_label_value := getattr(tool, "server_label", None): - return f"mcp:{server_label_value}" - if tool_type_value := getattr(tool, "type", None): - return tool_type_value # type: ignore[no-any-return] - return type(tool).__name__ - - def _get_structured_output_signature(self, chat_options: Mapping[str, Any] | None) -> str | None: - """Build a stable signature for structured_output/response_format values.""" - if not chat_options: - return None - response_format = chat_options.get("response_format") - if response_format is None: - return None - if isinstance(response_format, type): - return f"{response_format.__module__}.{response_format.__qualname__}" - if isinstance(response_format, Mapping): - return json.dumps(response_format, sort_keys=True, default=str) - return str(response_format) - - def _remove_agent_level_run_options( - self, - run_options: dict[str, Any], - chat_options: Mapping[str, Any] | None = None, - ) -> None: - """Remove request-level options that Azure AI only supports at agent creation time.""" - runtime_tools = run_options.get("tools") - runtime_structured_output = self._get_structured_output_signature(chat_options) - - if runtime_tools is not None or runtime_structured_output is not None: - tools_changed = runtime_tools is not None - structured_output_changed = runtime_structured_output is not None - - if self.warn_runtime_tools_and_structure_changed: - if runtime_tools is not None: - tools_changed = self._extract_tool_names(runtime_tools) != self._created_agent_tool_names - if runtime_structured_output is not None: - structured_output_changed = ( - runtime_structured_output != self._created_agent_structured_output_signature - ) - - if tools_changed or structured_output_changed: - logger.warning( - "AzureAIClient does not support runtime tools or structured_output overrides after agent creation. " - "Use AzureOpenAIResponsesClient instead." - ) - - agent_level_option_to_run_keys = { - "model_id": ("model",), - "tools": ("tools",), - "response_format": ("response_format", "text", "text_format"), - "rai_config": ("rai_config",), - "temperature": ("temperature",), - "top_p": ("top_p",), - "reasoning": ("reasoning",), - "allow_preview": ("allow_preview",), - } - - for run_keys in agent_level_option_to_run_keys.values(): - for run_key in run_keys: - run_options.pop(run_key, None) - - @override - async def _prepare_options( - self, - messages: Sequence[Message], - options: Mapping[str, Any], - **kwargs: Any, - ) -> dict[str, Any]: - """Take ChatOptions and create the specific options for Azure AI.""" - prepared_messages, instructions = self._prepare_messages_for_azure_ai(messages) - run_options = await super()._prepare_options(prepared_messages, options, **kwargs) - - # WORKAROUND: Azure AI Projects 'create responses' API has schema divergence from OpenAI's - # Responses API. Azure requires 'type' at item level and 'annotations' in content items. - # See: https://github.com/Azure/azure-sdk-for-python/issues/44493 - # See: https://github.com/microsoft/agent-framework/issues/2926 - # TODO(agent-framework#2926): Remove this workaround when Azure SDK aligns with OpenAI schema. - if "input" in run_options and isinstance(run_options["input"], list): - run_options["input"] = self._transform_input_for_azure_ai(cast(list[dict[str, Any]], run_options["input"])) - - if not self._is_application_endpoint: - # Application-scoped response APIs do not support "agent_reference" property. - agent_reference = await self._get_agent_reference_or_create(run_options, instructions, options) - run_options["extra_body"] = {"agent_reference": agent_reference} - - # Remove only keys that map to this client's declared options TypedDict. - self._remove_agent_level_run_options(run_options, options) - - return run_options - - @override - def _check_model_presence(self, options: dict[str, Any]) -> None: - # Skip model check for application endpoints - model is pre-configured on server - if self._is_application_endpoint: - return - if not options.get("model"): - if not self.model_id: - raise ValueError("model_deployment_name must be a non-empty string") - options["model"] = self.model_id - - def _transform_input_for_azure_ai(self, input_items: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Transform input items to match Azure AI Projects expected schema. - - WORKAROUND: Azure AI Projects 'create responses' API expects a different schema than OpenAI's - Responses API. Azure requires 'type' at the item level, and requires 'annotations' - only for output_text content items (assistant messages), not for input_text content items - (user messages). This helper adapts the OpenAI-style input to the Azure schema. - - See: https://github.com/Azure/azure-sdk-for-python/issues/44493 - TODO(agent-framework#2926): Remove when Azure SDK aligns with OpenAI schema. - """ - transformed: list[dict[str, Any]] = [] - for item in input_items: - new_item: dict[str, Any] = dict(item) - - # Add 'type': 'message' at item level for role-based items - if "role" in new_item and "type" not in new_item: - new_item["type"] = "message" - - # Add 'annotations' only to output_text content items (assistant messages) - # User messages (input_text) do NOT support annotations in Azure AI - if (content := new_item.get("content")) and isinstance(content, list): - new_content: list[Any] = [] - for content_item in content: # type: ignore[list-item] - if isinstance(content_item, MutableMapping): - # Only add annotations to output_text (assistant content) - if content_item.get("type") == "output_text" and "annotations" not in content_item: # type: ignore[reportUnknownMemberType] - content_item["annotations"] = [] - new_content.append(content_item) - else: - new_content.append(content_item) - new_item["content"] = new_content - - transformed.append(new_item) - - return transformed - - @override - def _parse_response_from_openai( - self, - response: Any, - options: dict[str, Any], - ) -> ChatResponse: - """Parse an Azure AI Responses API response, handling Azure-specific output item types.""" - result = super()._parse_response_from_openai(response, options) - - if result.messages: - for item in response.output: - if item.type == "oauth_consent_request": - consent_link = item.consent_link - if consent_link and not consent_link.startswith("https://"): - logger.warning("Skipping oauth_consent_request with non-HTTPS consent_link: %s", item) - consent_link = "" - if consent_link: - result.messages[0].contents.append( - Content.from_oauth_consent_request( - consent_link=consent_link, - raw_representation=item, - ) - ) - else: - logger.warning("Received oauth_consent_request output without consent_link: %s", item) - - return result - - @override - def _parse_chunk_from_openai( - self, - event: Any, - options: dict[str, Any], - function_call_ids: dict[int, tuple[str, str]], - ) -> ChatResponseUpdate: - """Parse an Azure AI streaming event, handling Azure-specific event types.""" - # Intercept output_item.added events for Azure-specific item types - if event.type == "response.output_item.added" and event.item.type == "oauth_consent_request": - event_item = event.item - consent_link = event_item.consent_link - if consent_link and not consent_link.startswith("https://"): - logger.warning("Skipping oauth_consent_request with non-HTTPS consent_link: %s", event_item) - consent_link = "" - contents: list[Content] = [] - if consent_link: - contents.append( - Content.from_oauth_consent_request( - consent_link=consent_link, - raw_representation=event_item, - ) - ) - else: - logger.warning("Received oauth_consent_request output without consent_link: %s", event_item) - return ChatResponseUpdate( - contents=contents, - role="assistant", - model_id=self.model_id, - raw_representation=event, - ) - - return super()._parse_chunk_from_openai(event, options, function_call_ids) - - def _prepare_messages_for_azure_ai(self, messages: Sequence[Message]) -> tuple[list[Message], str | None]: - """Prepare input from messages and convert system/developer messages to instructions.""" - result: list[Message] = [] - instructions_list: list[str] = [] - instructions: str | None = None - - # System/developer messages are turned into instructions, since there is no such message roles in Azure AI. - for message in messages: - if message.role in ["system", "developer"]: - for text_content in [content for content in message.contents if content.type == "text"]: - instructions_list.append(text_content.text) # type: ignore[arg-type] - else: - result.append(message) - - if len(instructions_list) > 0: - instructions = "".join(instructions_list) - - return result, instructions - - def _update_agent_name_and_description(self, agent_name: str | None, description: str | None = None) -> None: - """Update the agent name in the chat client. - - Args: - agent_name: The new name for the agent. - description: The new description for the agent. - """ - # This is a no-op in the base class, but can be overridden by subclasses - # to update the agent name in the client. - if agent_name and not self.agent_name: - self.agent_name = agent_name - if description and not self.agent_description: - self.agent_description = description - - # region Azure AI Search Citation Enhancement - - def _extract_azure_search_urls(self, output_items: Any) -> list[str]: - """Extract document URLs from azure_ai_search_call_output items. - - Args: - output_items: The response output items to scan. - - Returns: - A flat list of get_urls from all azure_ai_search_call_output items. - """ - get_urls: list[str] = [] - for item in output_items: - if item.type != "azure_ai_search_call_output": - continue - output = item.output - if isinstance(output, str): - try: - output = json.loads(output) - except (json.JSONDecodeError, TypeError): - continue - if isinstance(output, list): - # Streaming "added" events send output as an empty list; skip. - continue - if output is not None: - urls = output.get("get_urls") if isinstance(output, Mapping) else getattr(output, "get_urls", None) # type: ignore - if isinstance(urls, list): - string_urls: list[str] = [] - for url_item in urls: # type: ignore[list-item] - if isinstance(url_item, str): - string_urls.append(url_item) - get_urls.extend(string_urls) - return get_urls - - def _get_search_doc_url(self, citation_title: str | None, get_urls: list[str]) -> str | None: - """Map a citation title like 'doc_0' to its corresponding get_url. - - Args: - citation_title: The annotation title (e.g., "doc_0"). - get_urls: The list of document URLs from azure_ai_search_call_output. - - Returns: - The matching document URL if found, otherwise None. - """ - if not citation_title or not get_urls: - return None - match = _DOC_INDEX_PATTERN.search(citation_title) - if not match: - return None - doc_index = int(match.group(1)) - if 0 <= doc_index < len(get_urls): - return str(get_urls[doc_index]) - return None - - def _enrich_annotations_with_search_urls(self, contents: list[Content], get_urls: list[str]) -> None: - """Enrich citation annotations in contents with real document URLs from Azure AI Search. - - Looks for annotations with ``type == "citation"`` and a ``title`` matching ``doc_N``, - then adds the corresponding document URL from *get_urls* to ``additional_properties["get_url"]``. - - Args: - contents: The parsed content list from a ChatResponse or ChatResponseUpdate. - get_urls: Document URLs extracted from azure_ai_search_call_output. - """ - if not get_urls: - return - for content in contents: - if not content.annotations: - continue - for annotation in content.annotations: - if not isinstance(annotation, dict): - continue - if annotation.get("type") != "citation": - continue - title = annotation.get("title") - doc_url = self._get_search_doc_url(title, get_urls) - if doc_url: - annotation.setdefault("additional_properties", {})["get_url"] = doc_url - - def _build_url_citation_content( - self, annotation_data: dict[str, Any], get_urls: list[str], raw_event: Any - ) -> Content: - """Build a Content with a citation Annotation from a url_citation streaming event. - - The base class does not handle ``url_citation`` annotations in streaming, so this - method creates the appropriate framework content for them. - - Args: - annotation_data: The raw annotation dict from the streaming event. - get_urls: Captured document URLs for enrichment. - raw_event: The raw streaming event for raw_representation. - - Returns: - A Content object containing the citation annotation. - """ - ann_title = str(annotation_data.get("title") or "") - ann_url = str(annotation_data.get("url") or "") - ann_start = annotation_data.get("start_index") - ann_end = annotation_data.get("end_index") - - additional_props: dict[str, Any] = { - "annotation_index": raw_event.annotation_index, - } - doc_url = self._get_search_doc_url(ann_title, get_urls) - if doc_url: - additional_props["get_url"] = doc_url - - annotation_obj = Annotation( - type="citation", - title=ann_title, - url=ann_url, - additional_properties=additional_props, - raw_representation=annotation_data, - ) - if ann_start is not None and ann_end is not None: - annotation_obj["annotated_regions"] = [ - TextSpanRegion(type="text_span", start_index=ann_start, end_index=ann_end) - ] - - return Content.from_text(text="", annotations=[annotation_obj], raw_representation=raw_event) - - @override - def _inner_get_response( - self, - *, - messages: Sequence[Message], - options: Mapping[str, Any], - stream: bool = False, - **kwargs: Any, - ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: - """Wrap base response to enrich Azure AI Search citation annotations. - - For non-streaming responses, the ``ChatResponse.raw_representation`` carries the - full response including ``azure_ai_search_call_output`` items. After the base class - parses the response, ``url_citation`` annotations are enriched with per-document URLs. - - For streaming responses, a transform hook is registered on the ``ResponseStream`` to - capture ``get_urls`` from search output events and enrich ``url_citation`` annotations - as they arrive. The captured URL state is local to the stream closure, so concurrent - streams do not interfere. - """ - if not stream: - - async def _enrich_response() -> ChatResponse: - response = await super(RawAzureAIClient, self)._inner_get_response( # pyright: ignore[reportDeprecated] - messages=messages, options=options, stream=False, **kwargs - ) - get_urls = self._extract_azure_search_urls(response.raw_representation.output) # type: ignore[union-attr] - if get_urls: - for msg in response.messages: - self._enrich_annotations_with_search_urls(list(msg.contents or []), get_urls) - return response - - return _enrich_response() - - # Streaming: use a closure-local list so concurrent streams don't interfere - stream_result = super()._inner_get_response( # type: ignore[assignment] - messages=messages, options=options, stream=True, **kwargs - ) - search_get_urls: list[str] = [] - - def _enrich_update(update: ChatResponseUpdate) -> ChatResponseUpdate: - raw = update.raw_representation - if raw is None: - return update - event_type = raw.type - - # Capture get_urls from azure_ai_search_call_output items. - # Check both "added" and "done" events because the output data (including - # get_urls) may only be fully populated in the "done" event. - if event_type in ("response.output_item.added", "response.output_item.done"): - urls = self._extract_azure_search_urls([raw.item]) - if urls: - search_get_urls.extend(urls) - - # Handle url_citation annotations (not handled by the base class in streaming) - if event_type == "response.output_text.annotation.added": - ann = raw.annotation - if ann.get("type") == "url_citation": - citation_content = self._build_url_citation_content(ann, search_get_urls, raw) - contents_list = list(update.contents or []) - contents_list.append(citation_content) - return ChatResponseUpdate( - contents=contents_list, - conversation_id=update.conversation_id, - response_id=update.response_id, - role=update.role, # type: ignore[union-attr] - model_id=update.model_id, - continuation_token=update.continuation_token, - additional_properties=update.additional_properties, - raw_representation=update.raw_representation, - ) - - # Enrich any citation annotations already parsed by the base class - if update.contents and search_get_urls: - self._enrich_annotations_with_search_urls(list(update.contents), search_get_urls) - - return update - - stream_result.with_transform_hook(_enrich_update) # type: ignore[union-attr] - return stream_result - - # endregion - - # region Hosted Tool Factory Methods (Azure-specific overrides) - - @staticmethod - def get_code_interpreter_tool( # type: ignore[override] - *, - file_ids: list[str | Content] | None = None, - container: Literal["auto"] | dict[str, Any] = "auto", - **kwargs: Any, - ) -> CodeInterpreterTool: - """Create a code interpreter tool configuration for Azure AI Projects. - - Keyword Args: - file_ids: Optional list of file IDs or Content objects to make available to - the code interpreter. Accepts plain strings or Content.from_hosted_file() - instances. - container: Container configuration. Use "auto" for automatic container management. - Note: Custom container settings from this parameter are not used by Azure AI Projects; - use file_ids instead. - **kwargs: Additional arguments passed to the SDK CodeInterpreterTool constructor. - - Returns: - A CodeInterpreterTool ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIClient - - tool = AzureAIClient.get_code_interpreter_tool() - agent = ChatAgent(client, tools=[tool]) - """ - # Extract file_ids from container if provided as dict and file_ids not explicitly set - if file_ids is None and isinstance(container, dict): - file_ids = container.get("file_ids") - resolved = resolve_file_ids(file_ids) - tool_container = AutoCodeInterpreterToolParam(file_ids=resolved) - return CodeInterpreterTool(container=tool_container, **kwargs) - - @staticmethod - def get_file_search_tool( - *, - vector_store_ids: list[str], - max_num_results: int | None = None, - ranking_options: dict[str, Any] | None = None, - filters: dict[str, Any] | None = None, - **kwargs: Any, - ) -> ProjectsFileSearchTool: - """Create a file search tool configuration for Azure AI Projects. - - Keyword Args: - vector_store_ids: List of vector store IDs to search. - max_num_results: Maximum number of results to return (1-50). - ranking_options: Ranking options for search results. - filters: A filter to apply (ComparisonFilter or CompoundFilter). - **kwargs: Additional arguments passed to the SDK FileSearchTool constructor. - - Returns: - A FileSearchTool ready to pass to ChatAgent. - - Raises: - ValueError: If vector_store_ids is empty. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIClient - - tool = AzureAIClient.get_file_search_tool( - vector_store_ids=["vs_abc123"], - ) - agent = ChatAgent(client, tools=[tool]) - """ - if not vector_store_ids: - raise ValueError("File search tool requires 'vector_store_ids' to be specified.") - return ProjectsFileSearchTool( - vector_store_ids=vector_store_ids, - max_num_results=max_num_results, - ranking_options=ranking_options, # type: ignore[arg-type] - filters=filters, # type: ignore[arg-type] - **kwargs, - ) - - @staticmethod - def get_web_search_tool( # type: ignore[override] - *, - user_location: dict[str, str] | None = None, - search_context_size: Literal["low", "medium", "high"] | None = None, - **kwargs: Any, - ) -> WebSearchPreviewTool: - """Create a web search preview tool configuration for Azure AI Projects. - - Keyword Args: - user_location: Location context for search results. Dict with keys like - "city", "country", "region", "timezone". - search_context_size: Amount of context to include from search results. - One of "low", "medium", or "high". Defaults to "medium". - **kwargs: Additional arguments passed to the SDK WebSearchPreviewTool constructor. - - Returns: - A WebSearchPreviewTool ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIClient - - tool = AzureAIClient.get_web_search_tool() - agent = ChatAgent(client, tools=[tool]) - - # With location and context size - tool = AzureAIClient.get_web_search_tool( - user_location={"city": "Seattle", "country": "US"}, - search_context_size="high", - ) - """ - ws_tool = WebSearchPreviewTool(search_context_size=search_context_size, **kwargs) - - if user_location: - ws_tool.user_location = ApproximateLocation( - city=user_location.get("city"), - country=user_location.get("country"), - region=user_location.get("region"), - timezone=user_location.get("timezone"), - ) - - return ws_tool - - @staticmethod - def get_image_generation_tool( # type: ignore[override] - *, - model: Literal["gpt-image-1"] | str | None = None, - size: Literal["1024x1024", "1024x1536", "1536x1024", "auto"] | None = None, - output_format: Literal["png", "webp", "jpeg"] | None = None, - quality: Literal["low", "medium", "high", "auto"] | None = None, - background: Literal["transparent", "opaque", "auto"] | None = None, - partial_images: int | None = None, - moderation: Literal["auto", "low"] | None = None, - output_compression: int | None = None, - **kwargs: Any, - ) -> ImageGenTool: - """Create an image generation tool configuration for Azure AI Projects. - - Keyword Args: - model: The model to use for image generation. - size: Output image size. - output_format: Output image format. - quality: Output image quality. - background: Background transparency setting. - partial_images: Number of partial images to return during generation. - moderation: Moderation level. - output_compression: Compression level. - **kwargs: Additional arguments passed to the SDK ImageGenTool constructor. - - Returns: - An ImageGenTool ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIClient - - tool = AzureAIClient.get_image_generation_tool() - agent = ChatAgent(client, tools=[tool]) - """ - return ImageGenTool( # type: ignore[misc] - model=model, # type: ignore[arg-type] - size=size, - output_format=output_format, - quality=quality, - background=background, - partial_images=partial_images, - moderation=moderation, - output_compression=output_compression, - **kwargs, - ) - - @staticmethod - def get_mcp_tool( - *, - name: str, - url: str | None = None, - description: str | None = None, - approval_mode: Literal["always_require", "never_require"] | dict[str, list[str]] | None = None, - allowed_tools: list[str] | None = None, - headers: dict[str, str] | None = None, - project_connection_id: str | None = None, - **kwargs: Any, - ) -> MCPTool: - """Create a hosted MCP tool configuration for Azure AI. - - This configures an MCP (Model Context Protocol) server that will be called - by Azure AI's service. The tools from this MCP server are executed remotely - by Azure AI, not locally by your application. - - Note: - For local MCP execution where your application calls the MCP server - directly, use the MCP client tools instead of this method. - - Keyword Args: - name: A label/name for the MCP server. - url: The URL of the MCP server. Required if project_connection_id is not provided. - description: A description of what the MCP server provides. - approval_mode: Tool approval mode. Use "always_require" or "never_require" for all tools, - or provide a dict with "always_require_approval" and/or "never_require_approval" - keys mapping to lists of tool names. - allowed_tools: List of tool names that are allowed to be used from this MCP server. - headers: HTTP headers to include in requests to the MCP server. - project_connection_id: Azure AI Foundry connection ID for managed MCP connections. - If provided, url and headers are not required. - **kwargs: Additional arguments passed to the SDK MCPTool constructor. - - Returns: - An MCPTool configuration ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.azure import AzureAIClient - - # With URL - tool = AzureAIClient.get_mcp_tool( - name="my_mcp", - url="https://mcp.example.com", - ) - - # With Azure AI Foundry connection - tool = AzureAIClient.get_mcp_tool( - name="github_mcp", - project_connection_id="conn_abc123", - description="GitHub MCP via Azure AI Foundry", - ) - - agent = ChatAgent(client, tools=[tool]) - """ - mcp = MCPTool(server_label=name.replace(" ", "_"), server_url=url or "", **kwargs) - - if description: - mcp["server_description"] = description - - if project_connection_id: - mcp["project_connection_id"] = project_connection_id - elif headers: - mcp["headers"] = headers - - if allowed_tools: - mcp["allowed_tools"] = allowed_tools - - if approval_mode: - if isinstance(approval_mode, str): - mcp["require_approval"] = "always" if approval_mode == "always_require" else "never" - else: - if always_require := approval_mode.get("always_require_approval"): - mcp["require_approval"] = {"always": {"tool_names": always_require}} - if never_require := approval_mode.get("never_require_approval"): - mcp["require_approval"] = {"never": {"tool_names": never_require}} - - return mcp - - # endregion - - @override - def as_agent( - self, - *, - id: str | None = None, - name: str | None = None, - description: str | None = None, - instructions: str | None = None, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - default_options: AzureAIClientOptionsT | Mapping[str, Any] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - **kwargs: Any, - ) -> Agent[AzureAIClientOptionsT]: - """Convert this chat client to a Agent. - - This method creates a Agent instance with this client pre-configured. - It does NOT create an agent on the Azure AI service - the actual agent - will be created on the server during the first invocation (run). - - For working with pre-configured persistent agents on the server, use - :class:`~agent_framework_azure_ai.FoundryAgent` instead. - - Keyword Args: - id: The unique identifier for the agent. Will be created automatically if not provided. - name: The name of the agent. Defaults to the client's ``agent_name`` when None. - description: A brief description of the agent's purpose. Defaults to the client's - ``agent_description`` when None. - instructions: Optional instructions for the agent. - tools: The tools to use for the request. - default_options: A TypedDict containing chat options. - context_providers: Context providers to include during agent invocation. - middleware: List of middleware to intercept agent and function invocations. - kwargs: Any additional keyword arguments. - - Returns: - A Agent instance configured with this chat client. - """ - return super().as_agent( - id=id, - name=self.agent_name if name is None else name, - description=self.agent_description if description is None else description, - instructions=instructions, - tools=tools, - default_options=default_options, - context_providers=context_providers, - middleware=middleware, - **kwargs, - ) - - -@deprecated("AzureAIClient is deprecated. Use FoundryAgent instead.") -class AzureAIClient( - FunctionInvocationLayer[AzureAIClientOptionsT], - ChatMiddlewareLayer[AzureAIClientOptionsT], - ChatTelemetryLayer[AzureAIClientOptionsT], - RawAzureAIClient[AzureAIClientOptionsT], # pyright: ignore[reportDeprecated] - Generic[AzureAIClientOptionsT], -): - """Deprecated Azure AI client with middleware, telemetry, and function invocation support. - - This class is deprecated. Use ``FoundryAgent`` instead for connecting to - pre-configured agents in Foundry. It includes: - - Chat middleware support for request/response interception - - OpenTelemetry-based telemetry for observability - - Automatic function/tool invocation handling - - For a minimal implementation without these features, use :class:`RawFoundryAgentChatClient`. - """ - - def __init__( - self, - *, - project_client: AIProjectClient | None = None, - agent_name: str | None = None, - agent_version: str | None = None, - agent_description: str | None = None, - conversation_id: str | None = None, - project_endpoint: str | None = None, - model_deployment_name: str | None = None, - credential: AzureCredentialTypes | None = None, - use_latest_version: bool | None = None, - allow_preview: bool | None = None, - additional_properties: dict[str, Any] | None = None, - middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - function_invocation_configuration: FunctionInvocationConfiguration | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize an Azure AI client with full layer support. - - Keyword Args: - project_client: An existing AIProjectClient to use. If not provided, one will be created. - agent_name: The name to use when creating new agents or using existing agents. - agent_version: The version of the agent to use. - agent_description: The description to use when creating new agents. - conversation_id: Default conversation ID to use for conversations. Can be overridden by - conversation_id property when making a request. - project_endpoint: The Azure AI Project endpoint URL. - Can also be set via environment variable AZURE_AI_PROJECT_ENDPOINT. - Ignored when a project_client is passed. - model_deployment_name: The model deployment name to use for agent creation. - Can also be set via environment variable AZURE_AI_MODEL_DEPLOYMENT_NAME. - credential: Azure credential for authentication. Accepts a TokenCredential - or AsyncTokenCredential. - use_latest_version: Boolean flag that indicates whether to use latest agent version - if it exists in the service. - allow_preview: Enables preview opt-in on internally-created ``AIProjectClient`` - additional_properties: Additional properties stored on the client instance. - middleware: Optional sequence of chat middlewares to include. - function_invocation_configuration: Optional function invocation configuration. - env_file_path: Path to environment file for loading settings. - env_file_encoding: Encoding of the environment file. - - Examples: - .. code-block:: python - - from agent_framework_azure_ai import AzureAIClient - from azure.identity.aio import DefaultAzureCredential - - # Using environment variables - # Set AZURE_AI_PROJECT_ENDPOINT=https://your-project.cognitiveservices.azure.com - # Set AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4 - credential = DefaultAzureCredential() - client = AzureAIClient(credential=credential) - - # Or passing parameters directly - client = AzureAIClient( - project_endpoint="https://your-project.cognitiveservices.azure.com", - model_deployment_name="gpt-4", - credential=credential, - ) - - # Or loading from a .env file - client = AzureAIClient(credential=credential, env_file_path="path/to/.env") - - # Using custom ChatOptions with type safety: - from typing import TypedDict - from agent_framework import ChatOptions - - - class MyOptions(ChatOptions, total=False): - my_custom_option: str - - - client: AzureAIClient[MyOptions] = AzureAIClient(credential=credential) - response = await client.get_response("Hello", options={"my_custom_option": "value"}) - """ - super().__init__( - project_client=project_client, - agent_name=agent_name, - agent_version=agent_version, - agent_description=agent_description, - conversation_id=conversation_id, - project_endpoint=project_endpoint, - model_deployment_name=model_deployment_name, - credential=credential, - use_latest_version=use_latest_version, - allow_preview=allow_preview, - additional_properties=additional_properties, - middleware=middleware, - function_invocation_configuration=function_invocation_configuration, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py b/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py deleted file mode 100644 index 8a3a9833ac..0000000000 --- a/python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py +++ /dev/null @@ -1,918 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Deprecated Azure OpenAI client classes. - -All classes in this module are deprecated and will be removed in a future release. -Migrate to the ``agent_framework_openai`` package equivalents with an ``AsyncAzureOpenAI`` client, -or use ``FoundryChatClient`` for Azure AI Foundry projects. -""" - -from __future__ import annotations - -import json -import logging -import sys -from collections.abc import Mapping, Sequence -from contextlib import contextmanager -from copy import copy -from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, cast -from urllib.parse import urljoin, urlparse - -from agent_framework._middleware import ChatMiddlewareLayer -from agent_framework._settings import SecretString, load_settings -from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT, APP_INFO, prepend_agent_framework_to_user_agent -from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer -from agent_framework._types import Annotation, Content -from agent_framework.observability import ChatTelemetryLayer, EmbeddingTelemetryLayer -from agent_framework_openai._assistants_client import ( - OpenAIAssistantsClient, # type: ignore[reportDeprecated] - OpenAIAssistantsOptions, -) -from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient -from agent_framework_openai._chat_completion_client import OpenAIChatCompletionOptions, RawOpenAIChatCompletionClient -from agent_framework_openai._embedding_client import OpenAIEmbeddingOptions, RawOpenAIEmbeddingClient -from agent_framework_openai._shared import OpenAIBase -from azure.ai.projects.aio import AIProjectClient -from openai import AsyncOpenAI -from openai.lib.azure import AsyncAzureOpenAI -from pydantic import BaseModel - -from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider, resolve_credential_to_token_provider - -if sys.version_info >= (3, 13): - from typing import TypeVar # type: ignore # pragma: no cover - from warnings import deprecated # type: ignore # pragma: no cover -else: - from typing_extensions import TypeVar, deprecated # type: ignore # pragma: no cover -if sys.version_info >= (3, 12): - from typing import override # type: ignore # pragma: no cover -else: - from typing_extensions import override # type: ignore # pragma: no cover -if sys.version_info >= (3, 11): - from typing import TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover - -if TYPE_CHECKING: - from agent_framework._middleware import MiddlewareTypes - from openai.types.chat.chat_completion import Choice - from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice - -logger: logging.Logger = logging.getLogger(__name__) - - -# region Constants and Settings - -DEFAULT_AZURE_API_VERSION: Final[str] = "2024-10-21" -DEFAULT_AZURE_TOKEN_ENDPOINT: Final[str] = "https://cognitiveservices.azure.com/.default" # noqa: S105 - - -class AzureOpenAISettings(TypedDict, total=False): - """AzureOpenAI model settings. - - Settings are resolved in this order: explicit keyword arguments, values from an - explicitly provided .env file, then environment variables with the prefix - 'AZURE_OPENAI_'. If settings are missing after resolution, validation will fail. - - Keyword Args: - endpoint: The endpoint of the Azure deployment. - chat_deployment_name: The name of the Azure Chat deployment. - responses_deployment_name: The name of the Azure Responses deployment. - embedding_deployment_name: The name of the Azure Embedding deployment. - api_key: The API key for the Azure deployment. - api_version: The API version to use. - base_url: The url of the Azure deployment. - token_endpoint: The token endpoint to use to retrieve the authentication token. - """ - - chat_deployment_name: str | None - responses_deployment_name: str | None - embedding_deployment_name: str | None - endpoint: str | None - base_url: str | None - api_key: SecretString | None - api_version: str | None - token_endpoint: str | None - - -def _apply_azure_defaults( - settings: AzureOpenAISettings, - default_api_version: str = DEFAULT_AZURE_API_VERSION, - default_token_endpoint: str = DEFAULT_AZURE_TOKEN_ENDPOINT, -) -> None: - """Apply default values for api_version and token_endpoint after loading settings. - - Args: - settings: The loaded Azure OpenAI settings dict. - default_api_version: The default API version to use if not set. - default_token_endpoint: The default token endpoint to use if not set. - """ - if not settings.get("api_version"): - settings["api_version"] = default_api_version - if not settings.get("token_endpoint"): - settings["token_endpoint"] = default_token_endpoint - - -@contextmanager -def _prefer_single_azure_endpoint_env(*, endpoint: str | None, base_url: str | None) -> Any: - """Preserve the legacy call shape without mutating process-wide environment state.""" - yield - - -# endregion - - -# region AzureOpenAIConfigMixin - - -class AzureOpenAIConfigMixin(OpenAIBase): - """Internal class for configuring a connection to an Azure OpenAI service.""" - - OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai" - - def __init__( - self, - deployment_name: str, - endpoint: str | None = None, - base_url: str | None = None, - api_version: str = DEFAULT_AZURE_API_VERSION, - api_key: str | None = None, - token_endpoint: str | None = None, - credential: AzureCredentialTypes | AzureTokenProvider | None = None, - default_headers: Mapping[str, str] | None = None, - client: AsyncOpenAI | None = None, - instruction_role: str | None = None, - **kwargs: Any, - ) -> None: - """Configure a connection to an Azure OpenAI service. - - Args: - deployment_name: Name of the deployment. - endpoint: The specific endpoint URL for the deployment. - base_url: The base URL for Azure services. - api_version: Azure API version. - api_key: API key for Azure services. - token_endpoint: Azure AD token scope. - credential: Azure credential or token provider for authentication. - default_headers: Default headers for HTTP requests. - client: An existing client to use. - instruction_role: The role to use for 'instruction' messages. - kwargs: Additional keyword arguments. - """ - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - if not client: - ad_token_provider = None - if not api_key and credential: - ad_token_provider = resolve_credential_to_token_provider(credential, token_endpoint) - - if not api_key and not ad_token_provider: - raise ValueError("Please provide either api_key, credential, or a client.") - - if not endpoint and not base_url: - raise ValueError("Please provide an endpoint or a base_url") - - args: dict[str, Any] = { - "default_headers": merged_headers, - } - if api_version: - args["api_version"] = api_version - if ad_token_provider: - args["azure_ad_token_provider"] = ad_token_provider - if api_key: - args["api_key"] = api_key - if base_url: - args["base_url"] = str(base_url) - if endpoint and not base_url: - args["azure_endpoint"] = str(endpoint) - if deployment_name: - args["azure_deployment"] = deployment_name - if "websocket_base_url" in kwargs: - args["websocket_base_url"] = kwargs.pop("websocket_base_url") - - client = AsyncAzureOpenAI(**args) - - self.endpoint = str(endpoint) - self.base_url = str(base_url) - self.api_version = api_version - self.deployment_name = deployment_name - self.instruction_role = instruction_role - if default_headers: - from agent_framework._telemetry import USER_AGENT_KEY - - def_headers = {k: v for k, v in default_headers.items() if k != USER_AGENT_KEY} - else: - def_headers = None - self.default_headers = def_headers - - super().__init__(model_id=deployment_name, client=client, **kwargs) - - -# endregion - - -# region AzureOpenAIResponsesClient - - -AzureOpenAIResponsesOptionsT = TypeVar( - "AzureOpenAIResponsesOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="OpenAIChatOptions", - covariant=True, -) - -AzureOpenAIResponsesOptions = OpenAIChatOptions - - -@deprecated( - "AzureOpenAIResponsesClient is deprecated. " - "Use OpenAIChatClient with an AsyncAzureOpenAI client, or FoundryChatClient for Foundry projects." -) -class AzureOpenAIResponsesClient( # type: ignore[misc] - FunctionInvocationLayer[AzureOpenAIResponsesOptionsT], - ChatMiddlewareLayer[AzureOpenAIResponsesOptionsT], - ChatTelemetryLayer[AzureOpenAIResponsesOptionsT], - RawOpenAIChatClient[AzureOpenAIResponsesOptionsT], - Generic[AzureOpenAIResponsesOptionsT], -): - """Deprecated Azure Responses client. Use OpenAIChatClient with an AsyncAzureOpenAI client instead.""" - - OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai" - - def __init__( - self, - *, - api_key: str | None = None, - deployment_name: str | None = None, - endpoint: str | None = None, - base_url: str | None = None, - api_version: str | None = None, - token_endpoint: str | None = None, - credential: AzureCredentialTypes | AzureTokenProvider | None = None, - default_headers: Mapping[str, str] | None = None, - async_client: AsyncOpenAI | None = None, - project_client: Any | None = None, - project_endpoint: str | None = None, - allow_preview: bool | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - instruction_role: str | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - function_invocation_configuration: FunctionInvocationConfiguration | None = None, - **kwargs: Any, - ) -> None: - """Initialize an Azure OpenAI Responses client. - - Keyword Args: - api_key: The API key. - deployment_name: The deployment name. - endpoint: The deployment endpoint. - base_url: The deployment base URL. - api_version: The deployment API version. - token_endpoint: The token endpoint to request an Azure token. - credential: Azure credential or token provider for authentication. - default_headers: Default headers for HTTP requests. - async_client: An existing client to use. - project_client: An existing AIProjectClient to use. - project_endpoint: The Azure AI Foundry project endpoint URL. - allow_preview: Enables preview opt-in on internally-created AIProjectClient. - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. - instruction_role: The role to use for 'instruction' messages. - middleware: Optional sequence of middleware. - function_invocation_configuration: Optional function invocation configuration. - kwargs: Additional keyword arguments. - """ - if (model_id := kwargs.pop("model_id", None)) and not deployment_name: - deployment_name = str(model_id) - - if async_client is None and (project_client is not None or project_endpoint is not None): - async_client = self._create_client_from_project( - project_client=project_client, - project_endpoint=project_endpoint, - credential=credential, - allow_preview=allow_preview, - ) - - azure_openai_settings = load_settings( - AzureOpenAISettings, - env_prefix="AZURE_OPENAI_", - api_key=api_key, - base_url=base_url, - endpoint=endpoint, - responses_deployment_name=deployment_name, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - token_endpoint=token_endpoint, - ) - _apply_azure_defaults(azure_openai_settings, default_api_version="preview") - endpoint_value = azure_openai_settings.get("endpoint") - if ( - not azure_openai_settings.get("base_url") - and endpoint_value - and (hostname := urlparse(str(endpoint_value)).hostname) - and hostname.endswith(".openai.azure.com") - ): - azure_openai_settings["base_url"] = urljoin(str(endpoint_value), "/openai/v1/") - - responses_deployment_name = azure_openai_settings.get("responses_deployment_name") - if not responses_deployment_name: - raise ValueError( - "Azure OpenAI deployment name is required. Set via 'deployment_name' parameter " - "or 'AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME' environment variable." - ) - - endpoint_value = azure_openai_settings.get("endpoint") - client_base_url = azure_openai_settings.get("base_url") - if not async_client: - # Create the Azure OpenAI client directly - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - - api_key_secret = azure_openai_settings.get("api_key") - ad_token_provider = None - if not api_key_secret and credential: - ad_token_provider = resolve_credential_to_token_provider( - credential, azure_openai_settings.get("token_endpoint") - ) - - if not api_key_secret and not ad_token_provider: - raise ValueError("Please provide either api_key, credential, or a client.") - - if not endpoint_value and not client_base_url: - raise ValueError("Please provide an endpoint or a base_url") - - client_args: dict[str, Any] = {"default_headers": merged_headers} - if resolved_api_version := azure_openai_settings.get("api_version"): - client_args["api_version"] = resolved_api_version - if ad_token_provider: - client_args["azure_ad_token_provider"] = ad_token_provider - if api_key_secret: - client_args["api_key"] = api_key_secret.get_secret_value() - if client_base_url: - client_args["base_url"] = str(client_base_url) - if endpoint_value and not client_base_url: - client_args["azure_endpoint"] = str(endpoint_value) - if responses_deployment_name: - client_args["azure_deployment"] = responses_deployment_name - if "websocket_base_url" in kwargs: - client_args["websocket_base_url"] = kwargs.pop("websocket_base_url") - - async_client = AsyncAzureOpenAI(**client_args) - - # Store Azure-specific attributes for serialization - self.endpoint = str(endpoint_value) if endpoint_value else None - self.api_version = azure_openai_settings.get("api_version") or "" - self.deployment_name = responses_deployment_name - - with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=client_base_url): - super().__init__( - async_client=async_client, - model=responses_deployment_name, - azure_endpoint=str(endpoint_value) if endpoint_value else None, - base_url=str(client_base_url) if client_base_url else None, - api_version=azure_openai_settings.get("api_version"), - instruction_role=instruction_role, - default_headers=default_headers, - middleware=middleware, # type: ignore[arg-type] - function_invocation_configuration=function_invocation_configuration, - **kwargs, - ) - - @staticmethod - def _create_client_from_project( - *, - project_client: AIProjectClient | None, - project_endpoint: str | None, - credential: AzureCredentialTypes | AzureTokenProvider | None, - allow_preview: bool | None = None, - ) -> AsyncOpenAI: - """Create an AsyncOpenAI client from an Azure AI Foundry project.""" - if project_client is not None: - return project_client.get_openai_client() - - if not project_endpoint: - raise ValueError("Azure AI project endpoint is required when project_client is not provided.") - if not credential: - raise ValueError("Azure credential is required when using project_endpoint without a project_client.") - project_client_kwargs: dict[str, Any] = { - "endpoint": project_endpoint, - "credential": credential, # type: ignore[arg-type] - "user_agent": AGENT_FRAMEWORK_USER_AGENT, - } - if allow_preview is not None: - project_client_kwargs["allow_preview"] = allow_preview - project_client = AIProjectClient(**project_client_kwargs) - return project_client.get_openai_client() - - @override - def _check_model_presence(self, options: dict[str, Any]) -> None: - if not options.get("model"): - if not self.model: - raise ValueError("deployment_name must be a non-empty string") - options["model"] = self.model - - -# endregion - - -# region AzureOpenAIChatClient - - -ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) - - -class AzureUserSecurityContext(TypedDict, total=False): - """User security context for Azure AI applications. - - These fields help security operations teams investigate and mitigate security - incidents by providing context about the application and end user. - """ - - application_name: str - """Name of the application making the request.""" - - end_user_id: str - """Unique identifier for the end user (recommend hashing username/email).""" - - end_user_tenant_id: str - """Microsoft 365 tenant ID the end user belongs to. Required for multi-tenant apps.""" - - source_ip: str - """The original client's IP address.""" - - -class AzureOpenAIChatOptions(OpenAIChatCompletionOptions[ResponseModelT], Generic[ResponseModelT], total=False): - """Azure OpenAI-specific chat options dict. - - Extends OpenAIChatCompletionOptions with Azure-specific options including - the "On Your Data" feature and enhanced security context. - """ - - data_sources: list[dict[str, Any]] - """Azure "On Your Data" data sources for retrieval-augmented generation.""" - - user_security_context: AzureUserSecurityContext - """Enhanced security context for Azure Defender integration.""" - - n: int - """Number of chat completion choices to generate for each input message.""" - - -AzureOpenAIChatOptionsT = TypeVar( - "AzureOpenAIChatOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="AzureOpenAIChatOptions", - covariant=True, -) - - -@deprecated("AzureOpenAIChatClient is deprecated. Use OpenAIChatCompletionClient with an AsyncAzureOpenAI client.") -class AzureOpenAIChatClient( # type: ignore[misc] - FunctionInvocationLayer[AzureOpenAIChatOptionsT], - ChatMiddlewareLayer[AzureOpenAIChatOptionsT], - ChatTelemetryLayer[AzureOpenAIChatOptionsT], - RawOpenAIChatCompletionClient[AzureOpenAIChatOptionsT], - Generic[AzureOpenAIChatOptionsT], -): - """Deprecated Azure OpenAI Chat client. Use OpenAIChatCompletionClient with AsyncAzureOpenAI instead.""" - - OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai" - - def __init__( - self, - *, - api_key: str | None = None, - deployment_name: str | None = None, - endpoint: str | None = None, - base_url: str | None = None, - api_version: str | None = None, - token_endpoint: str | None = None, - credential: AzureCredentialTypes | AzureTokenProvider | None = None, - default_headers: Mapping[str, str] | None = None, - async_client: AsyncAzureOpenAI | None = None, - additional_properties: dict[str, Any] | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - instruction_role: str | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - function_invocation_configuration: FunctionInvocationConfiguration | None = None, - ) -> None: - """Initialize an Azure OpenAI Chat completion client. - - Keyword Args: - api_key: The API key. - deployment_name: The deployment name. - endpoint: The deployment endpoint. - base_url: The deployment base URL. - api_version: The deployment API version. - token_endpoint: The token endpoint to request an Azure token. - credential: Azure credential or token provider for authentication. - default_headers: Default headers for HTTP requests. - async_client: An existing client to use. - additional_properties: Additional properties stored on the client instance. - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. - instruction_role: The role to use for 'instruction' messages. - middleware: Optional sequence of middleware. - function_invocation_configuration: Optional function invocation configuration. - """ - azure_openai_settings = load_settings( - AzureOpenAISettings, - env_prefix="AZURE_OPENAI_", - api_key=api_key, - base_url=base_url, - endpoint=endpoint, - chat_deployment_name=deployment_name, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - token_endpoint=token_endpoint, - ) - _apply_azure_defaults(azure_openai_settings) - - chat_deployment_name = azure_openai_settings.get("chat_deployment_name") - if not chat_deployment_name: - raise ValueError( - "Azure OpenAI deployment name is required. Set via 'deployment_name' parameter " - "or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable." - ) - - endpoint_value = azure_openai_settings.get("endpoint") - base_url_value = azure_openai_settings.get("base_url") - if not async_client: - # Create the Azure OpenAI client directly - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - - api_key_secret = azure_openai_settings.get("api_key") - ad_token_provider = None - if not api_key_secret and credential: - ad_token_provider = resolve_credential_to_token_provider( - credential, azure_openai_settings.get("token_endpoint") - ) - - if not api_key_secret and not ad_token_provider: - raise ValueError("Please provide either api_key, credential, or a client.") - - if not endpoint_value and not base_url_value: - raise ValueError("Please provide an endpoint or a base_url") - - client_args: dict[str, Any] = {"default_headers": merged_headers} - if resolved_api_version := azure_openai_settings.get("api_version"): - client_args["api_version"] = resolved_api_version - if ad_token_provider: - client_args["azure_ad_token_provider"] = ad_token_provider - if api_key_secret: - client_args["api_key"] = api_key_secret.get_secret_value() - if base_url_value: - client_args["base_url"] = str(base_url_value) - if endpoint_value and not base_url_value: - client_args["azure_endpoint"] = str(endpoint_value) - if chat_deployment_name: - client_args["azure_deployment"] = chat_deployment_name - - async_client = AsyncAzureOpenAI(**client_args) - - # Store Azure-specific attributes for serialization - self.endpoint = str(azure_openai_settings.get("endpoint") or "") - self.api_version = azure_openai_settings.get("api_version") or "" - self.deployment_name = chat_deployment_name - - with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=base_url_value): - super().__init__( - async_client=async_client, - model=chat_deployment_name, - azure_endpoint=str(endpoint_value) if endpoint_value else None, - base_url=str(base_url_value) if base_url_value else None, - api_version=azure_openai_settings.get("api_version"), - instruction_role=instruction_role, - default_headers=default_headers, - additional_properties=additional_properties, - middleware=middleware, # type: ignore[arg-type] - function_invocation_configuration=function_invocation_configuration, - ) - - @override - def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> Content | None: - """Parse the choice into a Content object with type='text'. - - Overwritten from RawOpenAIChatCompletionClient to deal with Azure On Your Data function. - """ - message = getattr(choice, "message", None) - if message is None: - message = getattr(choice, "delta", None) - if message is None: # type: ignore - return None - if hasattr(message, "refusal") and message.refusal: - return Content.from_text(text=message.refusal, raw_representation=choice) - if not message.content: - return None - text_content = Content.from_text(text=message.content, raw_representation=choice) - if not message.model_extra or "context" not in message.model_extra: - return text_content - - context_raw: object = cast(object, message.context) # type: ignore[union-attr] - if isinstance(context_raw, str): - try: - context_raw = json.loads(context_raw) - except json.JSONDecodeError: - logger.warning("Context is not a valid JSON string, ignoring context.") - return text_content - if not isinstance(context_raw, dict): - logger.warning("Context is not a valid dictionary, ignoring context.") - return text_content - context = cast(dict[str, Any], context_raw) - if intent := context.get("intent"): - text_content.additional_properties = {"intent": intent} - citations = context.get("citations") - if isinstance(citations, list) and citations: - annotations: list[Annotation] = [] - for citation_raw in cast(list[object], citations): - if not isinstance(citation_raw, dict): - continue - citation = cast(dict[str, Any], citation_raw) - annotations.append( - Annotation( - type="citation", - title=citation.get("title", ""), - url=citation.get("url", ""), - snippet=citation.get("content", ""), - file_id=citation.get("filepath", ""), - tool_name="Azure-on-your-Data", - additional_properties={"chunk_id": citation.get("chunk_id", "")}, - raw_representation=citation, - ) - ) - text_content.annotations = annotations - return text_content - - -# endregion - - -# region AzureOpenAIAssistantsClient - - -AzureOpenAIAssistantsOptionsT = TypeVar( - "AzureOpenAIAssistantsOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="OpenAIAssistantsOptions", - covariant=True, -) - -AzureOpenAIAssistantsOptions = OpenAIAssistantsOptions - - -@deprecated( - "AzureOpenAIAssistantsClient is deprecated. " - "Use OpenAIAssistantsClient (also deprecated) or migrate to OpenAIChatClient." -) -class AzureOpenAIAssistantsClient( - OpenAIAssistantsClient[AzureOpenAIAssistantsOptionsT], # type: ignore[reportDeprecated] - Generic[AzureOpenAIAssistantsOptionsT], -): - """Deprecated Azure OpenAI Assistants client. Use OpenAIAssistantsClient or migrate to OpenAIChatClient.""" - - DEFAULT_AZURE_API_VERSION: ClassVar[str] = "2024-05-01-preview" - - def __init__( - self, - *, - deployment_name: str | None = None, - assistant_id: str | None = None, - assistant_name: str | None = None, - assistant_description: str | None = None, - thread_id: str | None = None, - api_key: str | None = None, - endpoint: str | None = None, - base_url: str | None = None, - api_version: str | None = None, - token_endpoint: str | None = None, - credential: AzureCredentialTypes | AzureTokenProvider | None = None, - default_headers: Mapping[str, str] | None = None, - async_client: AsyncAzureOpenAI | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize an Azure OpenAI Assistants client. - - Keyword Args: - deployment_name: The Azure OpenAI deployment name. - assistant_id: The ID of an Azure OpenAI assistant to use. - assistant_name: The name to use when creating new assistants. - assistant_description: The description to use when creating new assistants. - thread_id: Default thread ID to use for conversations. - api_key: The API key to use. - endpoint: The deployment endpoint. - base_url: The deployment base URL. - api_version: The deployment API version. - token_endpoint: The token endpoint to request an Azure token. - credential: Azure credential or token provider for authentication. - default_headers: Default headers for HTTP requests. - async_client: An existing client to use. - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. - """ - azure_openai_settings = load_settings( - AzureOpenAISettings, - env_prefix="AZURE_OPENAI_", - api_key=api_key, - base_url=base_url, - endpoint=endpoint, - chat_deployment_name=deployment_name, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - token_endpoint=token_endpoint, - ) - _apply_azure_defaults(azure_openai_settings, default_api_version=self.DEFAULT_AZURE_API_VERSION) - - chat_deployment_name = azure_openai_settings.get("chat_deployment_name") - if not chat_deployment_name: - raise ValueError( - "Azure OpenAI deployment name is required. Set via 'deployment_name' parameter " - "or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable." - ) - - api_key_secret = azure_openai_settings.get("api_key") - token_scope = azure_openai_settings.get("token_endpoint") - - ad_token_provider = None - if not async_client and not api_key_secret and credential: - ad_token_provider = resolve_credential_to_token_provider(credential, token_scope) - - if not async_client and not api_key_secret and not ad_token_provider: - raise ValueError("Please provide either api_key, credential, or a client.") - - if not async_client: - client_params: dict[str, Any] = { - "default_headers": default_headers, - } - if resolved_api_version := azure_openai_settings.get("api_version"): - client_params["api_version"] = resolved_api_version - - if api_key_secret: - client_params["api_key"] = api_key_secret.get_secret_value() - elif ad_token_provider: - client_params["azure_ad_token_provider"] = ad_token_provider - - if resolved_base_url := azure_openai_settings.get("base_url"): - client_params["base_url"] = str(resolved_base_url) - elif resolved_endpoint := azure_openai_settings.get("endpoint"): - client_params["azure_endpoint"] = str(resolved_endpoint) - - async_client = AsyncAzureOpenAI(**client_params) - - super().__init__( - model_id=chat_deployment_name, - assistant_id=assistant_id, - assistant_name=assistant_name, - assistant_description=assistant_description, - thread_id=thread_id, - async_client=async_client, # type: ignore[reportArgumentType] - default_headers=default_headers, - ) - - -# endregion - - -# region AzureOpenAIEmbeddingClient - - -AzureOpenAIEmbeddingOptionsT = TypeVar( - "AzureOpenAIEmbeddingOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="OpenAIEmbeddingOptions", - covariant=True, -) - - -@deprecated("AzureOpenAIEmbeddingClient is deprecated. Use OpenAIEmbeddingClient with an AsyncAzureOpenAI client.") -class AzureOpenAIEmbeddingClient( - EmbeddingTelemetryLayer[str, list[float], AzureOpenAIEmbeddingOptionsT], - RawOpenAIEmbeddingClient[AzureOpenAIEmbeddingOptionsT], - Generic[AzureOpenAIEmbeddingOptionsT], -): - """Deprecated Azure OpenAI embedding client. Use OpenAIEmbeddingClient with AsyncAzureOpenAI instead.""" - - OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai" - - def __init__( - self, - *, - api_key: str | None = None, - deployment_name: str | None = None, - endpoint: str | None = None, - base_url: str | None = None, - api_version: str | None = None, - token_endpoint: str | None = None, - credential: AzureCredentialTypes | AzureTokenProvider | None = None, - default_headers: Mapping[str, str] | None = None, - async_client: AsyncAzureOpenAI | None = None, - otel_provider_name: str | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize an Azure OpenAI embedding client. - - Keyword Args: - api_key: The API key. - deployment_name: The deployment name. - endpoint: The deployment endpoint. - base_url: The deployment base URL. - api_version: The deployment API version. - token_endpoint: The token endpoint to request an Azure token. - credential: Azure credential or token provider for authentication. - default_headers: Default headers for HTTP requests. - async_client: An existing client to use. - otel_provider_name: Override the OpenTelemetry provider name. - env_file_path: Path to .env file for settings. - env_file_encoding: Encoding for .env file. - """ - azure_openai_settings = load_settings( - AzureOpenAISettings, - env_prefix="AZURE_OPENAI_", - api_key=api_key, - base_url=base_url, - endpoint=endpoint, - embedding_deployment_name=deployment_name, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - token_endpoint=token_endpoint, - ) - _apply_azure_defaults(azure_openai_settings) - - embedding_deployment_name = azure_openai_settings.get("embedding_deployment_name") - if not embedding_deployment_name: - raise ValueError( - "Azure OpenAI embedding deployment name is required. Set via 'deployment_name' parameter " - "or 'AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME' environment variable." - ) - - endpoint_value = azure_openai_settings.get("endpoint") - base_url_value = azure_openai_settings.get("base_url") - if not async_client: - # Create the Azure OpenAI client directly - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - - api_key_secret = azure_openai_settings.get("api_key") - ad_token_provider = None - if not api_key_secret and credential: - ad_token_provider = resolve_credential_to_token_provider( - credential, azure_openai_settings.get("token_endpoint") - ) - - if not api_key_secret and not ad_token_provider: - raise ValueError("Please provide either api_key, credential, or a client.") - - if not endpoint_value and not base_url_value: - raise ValueError("Please provide an endpoint or a base_url") - - client_args: dict[str, Any] = {"default_headers": merged_headers} - if resolved_api_version := azure_openai_settings.get("api_version"): - client_args["api_version"] = resolved_api_version - if ad_token_provider: - client_args["azure_ad_token_provider"] = ad_token_provider - if api_key_secret: - client_args["api_key"] = api_key_secret.get_secret_value() - if base_url_value: - client_args["base_url"] = str(base_url_value) - if endpoint_value and not base_url_value: - client_args["azure_endpoint"] = str(endpoint_value) - if embedding_deployment_name: - client_args["azure_deployment"] = embedding_deployment_name - - async_client = AsyncAzureOpenAI(**client_args) - - # Store Azure-specific attributes for serialization - self.endpoint = str(azure_openai_settings.get("endpoint") or "") - self.api_version = azure_openai_settings.get("api_version") or "" - self.deployment_name = embedding_deployment_name - - with _prefer_single_azure_endpoint_env(endpoint=endpoint_value, base_url=base_url_value): - super().__init__( - async_client=async_client, - model=embedding_deployment_name, - azure_endpoint=str(endpoint_value) if endpoint_value else None, - base_url=str(base_url_value) if base_url_value else None, - api_version=azure_openai_settings.get("api_version"), - default_headers=default_headers, - ) - if otel_provider_name is not None: - self.OTEL_PROVIDER_NAME = otel_provider_name # type: ignore[misc] - - -# endregion diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py b/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py deleted file mode 100644 index c0430fd47c..0000000000 --- a/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py +++ /dev/null @@ -1,488 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import logging -import sys -from collections.abc import Callable, Mapping, MutableMapping, Sequence -from typing import Any, Generic, cast - -from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, - Agent, - BaseContextProvider, - FunctionTool, - MiddlewareTypes, - normalize_tools, -) -from agent_framework._mcp import MCPTool -from agent_framework._settings import load_settings -from agent_framework._tools import ToolTypes -from azure.ai.projects.aio import AIProjectClient -from azure.ai.projects.models import ( - AgentVersionDetails, - PromptAgentDefinition, - PromptAgentDefinitionTextOptions, -) -from azure.ai.projects.models import ( - FunctionTool as AzureFunctionTool, -) - -from ._client import AzureAIClient, AzureAIProjectAgentOptions # pyright: ignore[reportDeprecated] -from ._entra_id_authentication import AzureCredentialTypes -from ._shared import AzureAISettings, create_text_format_config, from_azure_ai_tools, to_azure_ai_tools - -if sys.version_info >= (3, 13): - from typing import TypeVar # type: ignore # pragma: no cover - from warnings import deprecated # type: ignore # pragma: no cover -else: - from typing_extensions import TypeVar, deprecated # type: ignore # pragma: no cover -if sys.version_info >= (3, 11): - from typing import Self, TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import Self, TypedDict # type: ignore # pragma: no cover - - -logger = logging.getLogger("agent_framework.azure") - - -# Type variable for options - allows typed Agent[OptionsT] returns -# Default matches AzureAIClient's default options type -OptionsCoT = TypeVar( - "OptionsCoT", - bound=TypedDict, # type: ignore[valid-type] - default="AzureAIProjectAgentOptions", - covariant=True, -) - - -@deprecated("AzureAIProjectAgentProvider is deprecated. Use FoundryAgent instead.") -class AzureAIProjectAgentProvider(Generic[OptionsCoT]): - """Deprecated provider for Azure AI Agent Service (Responses API). - - This provider is deprecated. Use ``FoundryAgent`` instead to connect to - pre-configured agents in Foundry. - - Examples: - Using with explicit AIProjectClient: - - .. code-block:: python - - from agent_framework.azure import AzureAIProjectAgentProvider - from azure.ai.projects.aio import AIProjectClient - from azure.identity.aio import DefaultAzureCredential - - async with AIProjectClient(endpoint, credential) as client: - provider = AzureAIProjectAgentProvider(client) - agent = await provider.create_agent( - name="MyAgent", - model="gpt-4", - instructions="You are a helpful assistant.", - ) - response = await agent.run("Hello!") - - Using with credential and endpoint (auto-creates client): - - .. code-block:: python - - from agent_framework.azure import AzureAIProjectAgentProvider - from azure.identity.aio import DefaultAzureCredential - - async with AzureAIProjectAgentProvider(credential=credential) as provider: - agent = await provider.create_agent( - name="MyAgent", - model="gpt-4", - instructions="You are a helpful assistant.", - ) - response = await agent.run("Hello!") - """ - - def __init__( - self, - project_client: AIProjectClient | None = None, - *, - project_endpoint: str | None = None, - model: str | None = None, - credential: AzureCredentialTypes | None = None, - allow_preview: bool | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize an Azure AI Project Agent Provider. - - Args: - project_client: An existing AIProjectClient to use. If not provided, one will be created. - project_endpoint: The Azure AI Project endpoint URL. - Can also be set via environment variable AZURE_AI_PROJECT_ENDPOINT. - Ignored when a project_client is passed. - model: The default model deployment name to use for agent creation. - Can also be set via environment variable AZURE_AI_MODEL_DEPLOYMENT_NAME. - credential: Azure credential for authentication. Accepts a TokenCredential, - AsyncTokenCredential, or a callable token provider. - Required when project_client is not provided. - allow_preview: Enables preview opt-in on internally-created ``AIProjectClient``. - env_file_path: Path to environment file for loading settings. - env_file_encoding: Encoding of the environment file. - - Raises: - ValueError: If required parameters are missing or invalid. - """ - self._settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - project_endpoint=project_endpoint, - model_deployment_name=model, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - - # Track whether we should close client connection - self._should_close_client = False - - if project_client is None: - resolved_endpoint = self._settings.get("project_endpoint") - if not resolved_endpoint: - raise ValueError( - "Azure AI project endpoint is required. Set via 'project_endpoint' parameter " - "or 'AZURE_AI_PROJECT_ENDPOINT' environment variable." - ) - - if not credential: - raise ValueError("Azure credential is required when project_client is not provided.") - - project_client_kwargs: dict[str, Any] = { - "endpoint": resolved_endpoint, - "credential": credential, # type: ignore[arg-type] - "user_agent": AGENT_FRAMEWORK_USER_AGENT, - } - if allow_preview is not None: - project_client_kwargs["allow_preview"] = allow_preview - project_client = AIProjectClient(**project_client_kwargs) - self._should_close_client = True - - self._project_client = project_client - - async def create_agent( - self, - name: str, - model: str | None = None, - instructions: str | None = None, - description: str | None = None, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Create a new agent on the Azure AI service and return a local Agent wrapper. - - Args: - name: The name of the agent to create. - model: The model deployment name to use. Falls back to AZURE_AI_MODEL_DEPLOYMENT_NAME - environment variable if not provided. - instructions: Instructions for the agent. - description: A description of the agent. - tools: Tools to make available to the agent. - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: List of middleware to intercept agent and function invocations. - context_providers: Context providers to include during agent invocation. - - Returns: - Agent: A Agent instance configured with the created agent. - - Raises: - ValueError: If required parameters are missing. - """ - # Resolve model from parameter or environment variable - resolved_model = model or self._settings.get("model_deployment_name") - if not resolved_model: - raise ValueError( - "Model deployment name is required. Provide 'model' parameter " - "or set 'AZURE_AI_MODEL_DEPLOYMENT_NAME' environment variable." - ) - - # Extract options from default_options if present - opts: dict[str, Any] = dict(default_options) if default_options else {} - response_format = opts.get("response_format") - rai_config = opts.get("rai_config") - reasoning = opts.get("reasoning") - - args: dict[str, Any] = {"model": resolved_model} - - if instructions: - args["instructions"] = instructions - if response_format and isinstance(response_format, (type, dict)): - args["text"] = PromptAgentDefinitionTextOptions( - format=create_text_format_config(response_format) # type: ignore[arg-type] - ) - if rai_config: - args["rai_config"] = rai_config - if reasoning: - args["reasoning"] = reasoning - - # Normalize tools and separate MCP tools from other tools - normalized_tools = normalize_tools(tools) - mcp_tools: list[MCPTool] = [] - non_mcp_tools: list[FunctionTool | MutableMapping[str, Any]] = [] - - if normalized_tools: - for tool in normalized_tools: - if isinstance(tool, MCPTool): - mcp_tools.append(tool) - elif isinstance(tool, (FunctionTool, MutableMapping)): - non_mcp_tools.append(tool) # type: ignore[reportUnknownArgumentType] - - # Connect MCP tools and discover their functions BEFORE creating the agent - # This is required because Azure AI Responses API doesn't accept tools at request time - mcp_discovered_functions: list[FunctionTool] = [] - for mcp_tool in mcp_tools: - if not mcp_tool.is_connected: - await mcp_tool.connect() - mcp_discovered_functions.extend(mcp_tool.functions) - - # Combine non-MCP tools with discovered MCP functions for Azure AI - all_tools_for_azure: list[FunctionTool | MutableMapping[str, Any]] = list(non_mcp_tools) - all_tools_for_azure.extend(mcp_discovered_functions) - - if all_tools_for_azure: - args["tools"] = to_azure_ai_tools(all_tools_for_azure) - - create_version_kwargs: dict[str, Any] = { - "agent_name": name, - "definition": PromptAgentDefinition(**args), - "description": description, - } - - created_agent = await self._project_client.agents.create_version(**create_version_kwargs) - - return self._to_chat_agent_from_details( - created_agent, - normalized_tools, - default_options=default_options, - middleware=middleware, - context_providers=context_providers, - ) - - async def get_agent( - self, - *, - name: str | None = None, - reference: Mapping[str, str | None] | None = None, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Retrieve an existing agent from the Azure AI service and return a local Agent wrapper. - - You must provide either name or reference. Use `as_agent()` if you already have - AgentVersionDetails and want to avoid an async call. - - Args: - name: The name of the agent to retrieve (fetches latest version). - reference: Mapping containing the agent's ``name`` and optionally a specific ``version``. - tools: Tools to make available to the agent. Required if the agent has function tools. - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: List of middleware to intercept agent and function invocations. - context_providers: Context providers to include during agent invocation. - - Returns: - Agent: A Agent instance configured with the retrieved agent. - - Raises: - ValueError: If no identifier is provided or required tools are missing. - """ - existing_agent: AgentVersionDetails - - reference_name = str(reference.get("name")) if reference and reference.get("name") else None - reference_version = str(reference.get("version")) if reference and reference.get("version") else None - - if reference_name and reference_version: - # Fetch specific version - existing_agent = await self._project_client.agents.get_version( - agent_name=reference_name, agent_version=reference_version - ) - elif agent_name := (reference_name if reference_name else name): - # Fetch latest version - details = await self._project_client.agents.get(agent_name=agent_name) - existing_agent = details.versions.latest - else: - raise ValueError("Either name or reference must be provided to get an agent.") - - if not isinstance(existing_agent.definition, PromptAgentDefinition): - raise ValueError("Agent definition must be PromptAgentDefinition to get a Agent.") - - # Validate that required function tools are provided - self._validate_function_tools(existing_agent.definition.tools, tools) - - return self._to_chat_agent_from_details( - existing_agent, - normalize_tools(tools), - default_options=default_options, - middleware=middleware, - context_providers=context_providers, - ) - - def as_agent( - self, - details: AgentVersionDetails, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Wrap an SDK agent version object into a Agent without making HTTP calls. - - Use this when you already have an AgentVersionDetails from a previous API call. - - Args: - details: The AgentVersionDetails to wrap. - tools: Tools to make available to the agent. Required if the agent has function tools. - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: List of middleware to intercept agent and function invocations. - context_providers: Context providers to include during agent invocation. - - Returns: - Agent: A Agent instance configured with the agent version. - - Raises: - ValueError: If the agent definition is not a PromptAgentDefinition or required tools are missing. - """ - if not isinstance(details.definition, PromptAgentDefinition): - raise ValueError("Agent definition must be PromptAgentDefinition to create a Agent.") - - # Validate that required function tools are provided - self._validate_function_tools(details.definition.tools, tools) - - return self._to_chat_agent_from_details( - details, - normalize_tools(tools), - default_options=default_options, - middleware=middleware, - context_providers=context_providers, - ) - - def _to_chat_agent_from_details( - self, - details: AgentVersionDetails, - provided_tools: Sequence[ToolTypes] | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Create a Agent from an AgentVersionDetails. - - Args: - details: The AgentVersionDetails containing the agent definition. - provided_tools: User-provided tools (including function implementations). - These are merged with hosted tools from the definition. - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: List of middleware to intercept agent and function invocations. - context_providers: Context providers to include during agent invocation. - """ - if not isinstance(details.definition, PromptAgentDefinition): - raise ValueError("Agent definition must be PromptAgentDefinition to get a Agent.") - - client = AzureAIClient( # pyright: ignore[reportDeprecated] - project_client=self._project_client, - agent_name=details.name, - agent_version=details.version, - agent_description=details.description, - model_deployment_name=details.definition.model, - ) - - # Merge tools: hosted tools from definition + user-provided function tools - # from_azure_ai_tools converts hosted tools (MCP, code interpreter, file search, web search) - # but function tools need the actual implementations from provided_tools - merged_tools = self._merge_tools(details.definition.tools, provided_tools) - merged_default_options: dict[str, Any] = dict(default_options) if default_options is not None else {} - merged_default_options.setdefault("model_id", details.definition.model) - - return Agent( # type: ignore[return-value] - client=client, - id=details.id, - name=details.name, - description=details.description, - instructions=details.definition.instructions, - tools=merged_tools, - default_options=cast(Any, merged_default_options), - middleware=middleware, - context_providers=context_providers, - ) - - def _merge_tools( - self, - definition_tools: Sequence[Any] | None, - provided_tools: Sequence[ToolTypes] | None, - ) -> list[ToolTypes]: - """Merge hosted tools from definition with user-provided function tools. - - Args: - definition_tools: Tools from the agent definition (Azure AI format). - provided_tools: User-provided tools (Agent Framework format), including function implementations. - - Returns: - Combined list of tools for the Agent. - """ - merged: list[ToolTypes] = [] - - # Convert hosted tools from definition (MCP, code interpreter, file search, web search) - # Function tools from the definition are skipped - we use user-provided implementations instead - hosted_tools = from_azure_ai_tools(definition_tools) - for hosted_tool in hosted_tools: - # Skip function tool dicts - they don't have implementations - if isinstance(hosted_tool, dict) and hosted_tool.get("type") == "function": - continue - merged.append(hosted_tool) - - # Add user-provided function tools and MCP tools - if provided_tools: - for provided_tool in provided_tools: - # FunctionTool - has implementation for function calling - # MCPTool - Agent handles MCP connection and tool discovery at runtime - if isinstance(provided_tool, (FunctionTool, MCPTool)): - merged.append(provided_tool) # type: ignore[reportUnknownArgumentType] - - return merged - - def _validate_function_tools( - self, - agent_tools: Sequence[Any] | None, - provided_tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None, - ) -> None: - """Validate that required function tools are provided.""" - # Normalize and validate function tools - normalized_tools = normalize_tools(provided_tools) - tool_names = {tool.name for tool in normalized_tools if isinstance(tool, FunctionTool)} - - # If function tools exist in agent definition but were not provided, - # we need to raise an error, as it won't be possible to invoke the function. - missing_tools = [ - tool.name - for tool in (agent_tools or []) - if isinstance(tool, AzureFunctionTool) and tool.name not in tool_names - ] - - if missing_tools: - raise ValueError( - f"The following prompt agent definition required tools were not provided: {', '.join(missing_tools)}" - ) - - async def __aenter__(self) -> Self: - """Async context manager entry.""" - return self - - async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None: - """Async context manager exit.""" - await self.close() - - async def close(self) -> None: - """Close the provider and release resources. - - Only closes the underlying AIProjectClient if it was created by this provider. - """ - if self._should_close_client: - await self._project_client.close() diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py b/python/packages/azure-ai/agent_framework_azure_ai/_shared.py index 220640d270..66b92a9fd6 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_shared.py @@ -2,45 +2,13 @@ from __future__ import annotations -import logging import sys -import warnings -from collections.abc import Mapping, MutableMapping, Sequence -from typing import Any, cast - -from agent_framework import ( - Content, - FunctionTool, -) -from agent_framework.exceptions import IntegrationInvalidRequestException -from azure.ai.agents.models import ( - CodeInterpreterToolDefinition, - ToolDefinition, -) -from azure.ai.projects.models import ( - CodeInterpreterTool, - MCPTool, - TextResponseFormatJsonObject, - TextResponseFormatJsonSchema, - TextResponseFormatText, - Tool, - WebSearchPreviewTool, -) -from azure.ai.projects.models import ( - FileSearchTool as ProjectsFileSearchTool, -) -from azure.ai.projects.models import ( - FunctionTool as AzureFunctionTool, -) -from pydantic import BaseModel if sys.version_info >= (3, 11): from typing import TypedDict # pragma: no cover else: from typing_extensions import TypedDict # type: ignore # pragma: no cover -logger = logging.getLogger("agent_framework.azure") - class AzureAISettings(TypedDict, total=False): """Azure AI Project settings. @@ -78,518 +46,3 @@ class AzureAISettings(TypedDict, total=False): project_endpoint: str | None model_deployment_name: str | None - - -def _extract_project_connection_id(additional_properties: Mapping[str, Any] | None) -> str | None: - """Extract project_connection_id from tool additional_properties. - - Checks for both direct 'project_connection_id' key (programmatic usage) - and 'connection.name' structure (declarative/YAML usage). - - Args: - additional_properties: The additional_properties dict from a tool. - - Returns: - The project_connection_id if found, None otherwise. - """ - if not additional_properties: - return None - - # Check for direct project_connection_id (programmatic usage) - - if (proj_conn_id := additional_properties.get("project_connection_id")) and isinstance(proj_conn_id, str): - return proj_conn_id # type: ignore[no-any-return] - - # Check for connection.name structure (declarative/YAML usage) - if ( - (connection := additional_properties.get("connection")) - and isinstance(connection, Mapping) - and (name := connection.get("name")) # type: ignore - and isinstance(name, str) - ): - return name # type: ignore[no-any-return] - - return None - - -def resolve_file_ids(file_ids: Sequence[str | Content] | None) -> list[str] | None: - """Resolve a list of file ID values that may include Content objects. - - Accepts plain strings and Content objects with type "hosted_file", extracting - the file_id from each. This enables users to pass Content.from_hosted_file() - alongside plain file ID strings. - - Args: - file_ids: Sequence of file ID strings or Content objects, or None. - - Returns: - A list of resolved file ID strings, or None if input is None or empty. - - Raises: - ValueError: If a Content object has an unsupported type (not "hosted_file"). - """ - if not file_ids: - return None - - resolved: list[str] = [] - for item in file_ids: - if isinstance(item, str): - if not item: - raise ValueError("file_ids must not contain empty strings.") - resolved.append(item) - elif isinstance(item, Content): - if item.type != "hosted_file": - raise ValueError( - f"Unsupported Content type '{item.type}' for code interpreter file_ids. " - "Only Content.from_hosted_file() is supported." - ) - if item.file_id is None: - raise ValueError( - "Content.from_hosted_file() item is missing a file_id. " - "Ensure the Content object has a valid file_id before using it in file_ids." - ) - resolved.append(item.file_id) - - return resolved if resolved else None - - -def to_azure_ai_agent_tools( - tools: Sequence[FunctionTool | MutableMapping[str, Any]] | None, - run_options: dict[str, Any] | None = None, -) -> list[ToolDefinition | dict[str, Any]]: - """Convert Agent Framework tools to Azure AI V1 SDK tool definitions. - - .. deprecated:: - This function is deprecated and will be removed in a future release. - Use :func:`to_azure_ai_tools` instead for the V2 (Projects/Responses) API. - - Handles FunctionTool instances and dict-based tools from static factory methods. - - Args: - tools: Sequence of Agent Framework tools to convert. - run_options: Optional dict with run options. - - Returns: - List of Azure AI V1 SDK tool definitions. - - Raises: - ValueError: If tool configuration is invalid. - """ - warnings.warn( - "to_azure_ai_agent_tools() is deprecated and will be removed in a future release; " - "use to_azure_ai_tools() instead for the V2 (Projects/Responses) API.", - DeprecationWarning, - stacklevel=2, - ) - if not tools: - return [] - - tool_definitions: list[ToolDefinition | dict[str, Any]] = [] - for tool in tools: - if isinstance(tool, FunctionTool): - tool_definitions.append(tool.to_json_schema_spec()) # type: ignore[reportUnknownArgumentType] - elif isinstance(tool, ToolDefinition): - # Pass through ToolDefinition subclasses unchanged (includes CodeInterpreterToolDefinition, etc.) - tool_definitions.append(tool) - elif hasattr(tool, "definitions") and not isinstance(tool, (dict, MutableMapping)): - # SDK Tool wrappers (McpTool, FileSearchTool, BingGroundingTool, etc.) - tool_definitions.extend(tool.definitions) - # Handle tool resources (MCP resources handled separately) - if ( - run_options is not None - and hasattr(tool, "resources") - and tool.resources - and "mcp" not in tool.resources - ): - run_options.setdefault("tool_resources", {}) - if isinstance(tool.resources, Mapping): - run_options["tool_resources"].update(tool.resources) - elif isinstance(tool, (dict, MutableMapping)): - # Handle dict-based tools - pass through directly - tool_dict = tool if isinstance(tool, dict) else dict(tool) - tool_definitions.append(tool_dict) - else: - # Pass through other types unchanged - tool_definitions.append(tool) - return tool_definitions - - -def from_azure_ai_agent_tools( - tools: Sequence[ToolDefinition | dict[str, Any]] | None, -) -> list[dict[str, Any]]: - """Convert Azure AI V1 SDK tool definitions to dict-based tools. - - .. deprecated:: - This function is deprecated and will be removed in a future release. - Use :func:`from_azure_ai_tools` instead for the V2 (Projects/Responses) API. - - Args: - tools: Sequence of Azure AI V1 SDK tool definitions. - - Returns: - List of dict-based tool definitions. - """ - warnings.warn( - "from_azure_ai_agent_tools() is deprecated and will be removed in a future release; " - "use from_azure_ai_tools() instead for the V2 (Projects/Responses) API.", - DeprecationWarning, - stacklevel=2, - ) - if not tools: - return [] - - result: list[dict[str, Any]] = [] - for tool in tools: - # Handle SDK objects - if isinstance(tool, CodeInterpreterToolDefinition): - result.append({"type": "code_interpreter"}) - elif isinstance(tool, dict): - # Handle dict format - converted = _convert_dict_tool(tool) - if converted is not None: - result.append(converted) - elif hasattr(tool, "type"): - # Handle other SDK objects by type - converted = _convert_sdk_tool(tool) - if converted is not None: - result.append(converted) - return result - - -def _convert_dict_tool(tool: dict[str, Any]) -> dict[str, Any] | None: - """Convert a dict-format Azure AI tool to dict-based tool format.""" - tool_type = tool.get("type") - - if tool_type == "code_interpreter": - return {"type": "code_interpreter"} - - if tool_type == "file_search": - file_search_config = tool.get("file_search", {}) - vector_store_ids = file_search_config.get("vector_store_ids", []) - return {"type": "file_search", "vector_store_ids": vector_store_ids} - - if tool_type == "bing_grounding": - bing_config = tool.get("bing_grounding", {}) - connection_id = bing_config.get("connection_id") - return {"type": "bing_grounding", "connection_id": connection_id} if connection_id else None - - if tool_type == "bing_custom_search": - bing_config = tool.get("bing_custom_search", {}) - connection_id = bing_config.get("connection_id") - instance_name = bing_config.get("instance_name") - # Only return if both required fields are present - if connection_id and instance_name: - return { - "type": "bing_custom_search", - "connection_id": connection_id, - "instance_name": instance_name, - } - return None - - if tool_type == "mcp": - # MCP tools are defined on the Azure agent, no local handling needed - # Azure may not return full server_url, so skip conversion - return None - - if tool_type == "function": - # Function tools are returned as dicts - users must provide implementations - return tool - - # Unknown tool type - pass through - return tool - - -def _convert_sdk_tool(tool: ToolDefinition) -> dict[str, Any] | None: - """Convert an SDK-object Azure AI tool to dict-based tool format.""" - tool_type = getattr(tool, "type", None) - - if tool_type == "code_interpreter": - return {"type": "code_interpreter"} - - if tool_type == "file_search": - file_search_config = getattr(tool, "file_search", None) - vector_store_ids = getattr(file_search_config, "vector_store_ids", []) if file_search_config else [] - return {"type": "file_search", "vector_store_ids": vector_store_ids} - - if tool_type == "bing_grounding": - bing_config = getattr(tool, "bing_grounding", None) - connection_id = getattr(bing_config, "connection_id", None) if bing_config else None - return {"type": "bing_grounding", "connection_id": connection_id} if connection_id else None - - if tool_type == "bing_custom_search": - bing_config = getattr(tool, "bing_custom_search", None) - connection_id = getattr(bing_config, "connection_id", None) if bing_config else None - instance_name = getattr(bing_config, "instance_name", None) if bing_config else None - # Only return if both required fields are present - if connection_id and instance_name: - return { - "type": "bing_custom_search", - "connection_id": connection_id, - "instance_name": instance_name, - } - return None - - if tool_type == "mcp": - # MCP tools are defined on the Azure agent, no local handling needed - # Azure may not return full server_url, so skip conversion - return None - - if tool_type == "function": - # Function tools from SDK don't have implementations - skip - return None - - # Unknown tool type - convert to dict if possible - if hasattr(tool, "as_dict"): - return tool.as_dict() # type: ignore[union-attr] - return {"type": tool_type} if tool_type else {} - - -def from_azure_ai_tools(tools: Sequence[Tool | dict[str, Any]] | None) -> list[dict[str, Any]]: - """Parses and converts a sequence of Azure AI tools into dict-based tools. - - Args: - tools: A sequence of tool objects or dictionaries - defining the tools to be parsed. Can be None. - - Returns: - list[dict[str, Any]]: A list of dict-based tool definitions. - """ - agent_tools: list[dict[str, Any]] = [] - if not tools: - return agent_tools - for tool in tools: - # Handle raw dictionary tools - tool_dict = tool if isinstance(tool, dict) else dict(tool) - tool_type = tool_dict.get("type") - - if tool_type == "mcp": - mcp_tool = cast(MCPTool, tool_dict) - result: dict[str, Any] = { - "type": "mcp", - "server_label": mcp_tool.get("server_label", ""), - "server_url": mcp_tool.get("server_url", ""), - } - if description := mcp_tool.get("server_description"): - result["server_description"] = description - if headers := mcp_tool.get("headers"): - result["headers"] = headers - if allowed_tools := mcp_tool.get("allowed_tools"): - result["allowed_tools"] = allowed_tools - if require_approval := mcp_tool.get("require_approval"): - result["require_approval"] = require_approval - if project_connection_id := mcp_tool.get("project_connection_id"): - result["project_connection_id"] = project_connection_id - agent_tools.append(result) - elif tool_type == "code_interpreter": - ci_tool = cast(CodeInterpreterTool, tool_dict) - container = ci_tool.get("container", {}) - result = {"type": "code_interpreter"} - if "file_ids" in container: - result["file_ids"] = container["file_ids"] - agent_tools.append(result) - elif tool_type == "file_search": - fs_tool = cast(ProjectsFileSearchTool, tool_dict) - result = {"type": "file_search"} - if "vector_store_ids" in fs_tool: - result["vector_store_ids"] = fs_tool["vector_store_ids"] - if max_results := fs_tool.get("max_num_results"): - result["max_num_results"] = max_results - agent_tools.append(result) - elif tool_type == "web_search_preview": - ws_tool = cast(WebSearchPreviewTool, tool_dict) - result = {"type": "web_search_preview"} - if user_location := ws_tool.get("user_location"): - result["user_location"] = { - "city": user_location.get("city"), - "country": user_location.get("country"), - "region": user_location.get("region"), - "timezone": user_location.get("timezone"), - } - agent_tools.append(result) - else: - agent_tools.append(tool_dict) - return agent_tools - - -def to_azure_ai_tools( - tools: Sequence[FunctionTool | MutableMapping[str, Any] | Tool] | None, -) -> list[Tool | dict[str, Any]]: - """Converts Agent Framework tools into Azure AI compatible tools. - - Handles FunctionTool instances and passes through SDK Tool types directly. - - Args: - tools: A sequence of Agent Framework tool objects, SDK Tool types, or dictionaries - defining the tools to be converted. Can be None. - - Returns: - list[Tool | dict[str, Any]]: A list of converted tools compatible with Azure AI. - """ - azure_tools: list[Tool | dict[str, Any]] = [] - if not tools: - return azure_tools - - for tool in tools: - if isinstance(tool, FunctionTool): - params = tool.parameters() - params["additionalProperties"] = False - azure_tools.append( - AzureFunctionTool( - name=tool.name, - parameters=params, - strict=False, - description=tool.description, - ) - ) - elif isinstance(tool, Tool): - # Pass through SDK Tool types directly (CodeInterpreterTool, FileSearchTool, etc.) - azure_tools.append(tool) - elif isinstance(tool, MutableMapping): - # Convert mutable mappings into plain dicts for stable typing. - tool_dict: dict[str, Any] = dict(tool) - if tool_dict.get("type") == "mcp": - azure_tools.append(_prepare_mcp_tool_dict_for_azure_ai(tool_dict)) - else: - azure_tools.append(tool_dict) - else: - # Pass through any other supported tool objects unchanged. - azure_tools.append(tool) - - return azure_tools - - -def _prepare_mcp_tool_dict_for_azure_ai(tool_dict: dict[str, Any]) -> MCPTool: - """Convert dict-based MCP tool to Azure AI MCPTool format. - - Args: - tool_dict: The dict-based MCP tool configuration. - - Returns: - MCPTool: The converted Azure AI MCPTool. - """ - server_label = tool_dict.get("server_label", "") - server_url = tool_dict.get("server_url", "") - mcp: MCPTool = MCPTool(server_label=server_label, server_url=server_url) - - if description := tool_dict.get("server_description"): - mcp["server_description"] = description - - # Check for project_connection_id - project_connection_id = tool_dict.get("project_connection_id") - if not isinstance(project_connection_id, str): - additional_properties = tool_dict.get("additional_properties") - project_connection_id = ( - _extract_project_connection_id(additional_properties) # pyright: ignore[reportUnknownArgumentType] - if isinstance(additional_properties, Mapping) - else None - ) - - if project_connection_id: - mcp["project_connection_id"] = project_connection_id - elif headers := tool_dict.get("headers"): - mcp["headers"] = headers - - if allowed_tools := tool_dict.get("allowed_tools"): - mcp["allowed_tools"] = list(allowed_tools) - - if require_approval := tool_dict.get("require_approval"): - mcp["require_approval"] = require_approval - - return mcp - - -def create_text_format_config( - response_format: type[BaseModel] | Mapping[str, Any], -) -> TextResponseFormatJsonSchema | TextResponseFormatJsonObject | TextResponseFormatText: - """Convert response_format into Azure text format configuration.""" - if isinstance(response_format, type) and issubclass(response_format, BaseModel): - schema = response_format.model_json_schema() - # Ensure additionalProperties is explicitly false to satisfy Azure validation - if isinstance(schema, dict): - schema.setdefault("additionalProperties", False) - return TextResponseFormatJsonSchema( - name=response_format.__name__, - schema=schema, - strict=True, - ) - - if isinstance(response_format, Mapping): - format_config = _convert_response_format(response_format) - format_type = format_config.get("type") - if format_type == "json_schema": - # Ensure schema includes additionalProperties=False to satisfy Azure validation - schema = dict(format_config.get("schema", {})) # type: ignore[assignment] - schema.setdefault("additionalProperties", False) - config_kwargs: dict[str, Any] = { - "name": format_config.get("name") or "response", - "schema": schema, - } - if "strict" in format_config: - config_kwargs["strict"] = format_config["strict"] - if "description" in format_config: - config_kwargs["description"] = format_config["description"] - return TextResponseFormatJsonSchema(**config_kwargs) - if format_type == "json_object": - return TextResponseFormatJsonObject() - if format_type == "text": - return TextResponseFormatText() - - raise IntegrationInvalidRequestException("response_format must be a Pydantic model or mapping.") - - -def _convert_response_format(response_format: Mapping[str, Any]) -> dict[str, Any]: - """Convert Chat style response_format into Responses text format config.""" - if "format" in response_format and isinstance(response_format["format"], Mapping): - return dict(cast("Mapping[str, Any]", response_format["format"])) - - format_type = response_format.get("type") - if format_type == "json_schema": - schema_section = response_format.get("json_schema", response_format) - if not isinstance(schema_section, Mapping): - raise IntegrationInvalidRequestException("json_schema response_format must be a mapping.") - schema_section_typed = cast("Mapping[str, Any]", schema_section) - schema: Any = schema_section_typed.get("schema") - if schema is None: - raise IntegrationInvalidRequestException("json_schema response_format requires a schema.") - name: str = str( - schema_section_typed.get("name") - or schema_section_typed.get("title") - or (cast("Mapping[str, Any]", schema).get("title") if isinstance(schema, Mapping) else None) - or "response" - ) - format_config: dict[str, Any] = { - "type": "json_schema", - "name": name, - "schema": schema, - } - if "strict" in schema_section: - format_config["strict"] = schema_section["strict"] - if "description" in schema_section and schema_section["description"] is not None: - format_config["description"] = schema_section["description"] - return format_config - - if format_type in {"json_object", "text"}: - return {"type": format_type} - - # Handle raw JSON schemas (e.g. {"type": "object", "properties": {...}}) - # by wrapping them in the expected json_schema envelope. - # Detect by checking for JSON Schema primitive types or known schema keywords. - json_schema_keywords = {"properties", "anyOf", "oneOf", "allOf", "$ref", "$defs"} - json_schema_primitive_types = {"object", "array", "string", "number", "integer", "boolean", "null"} - if format_type in json_schema_primitive_types or ( - format_type is None and any(k in response_format for k in json_schema_keywords) - ): - schema = dict(response_format) - if schema.get("type") == "object" and "additionalProperties" not in schema: - schema["additionalProperties"] = False - # Pop title from schema since OpenAI strict mode rejects unknown keys; - # use it as the schema name in the envelope instead. - name = str(schema.pop("title", None) or "response") - return { - "type": "json_schema", - "name": name, - "schema": schema, - "strict": True, - } - - raise IntegrationInvalidRequestException("Unsupported response_format provided for Azure AI client.") diff --git a/python/packages/azure-ai/tests/azure_openai/conftest.py b/python/packages/azure-ai/tests/azure_openai/conftest.py deleted file mode 100644 index 8e32c53608..0000000000 --- a/python/packages/azure-ai/tests/azure_openai/conftest.py +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -from typing import Any - -from agent_framework import Message -from pytest import fixture - - -# 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 {} - - -# These two fixtures are used for multiple things, also non-connector tests -@fixture() -def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore - """Fixture to set environment variables for AzureOpenAISettings.""" - - if exclude_list is None: - exclude_list = [] - - if override_env_param_dict is None: - override_env_param_dict = {} - - env_vars = { - "AZURE_OPENAI_ENDPOINT": "https://test-endpoint.com", - "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "test_chat_deployment", - "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME": "test_chat_deployment", - "AZURE_OPENAI_TEXT_DEPLOYMENT_NAME": "test_text_deployment", - "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME": "test_embedding_deployment", - "AZURE_OPENAI_TEXT_TO_IMAGE_DEPLOYMENT_NAME": "test_text_to_image_deployment", - "AZURE_OPENAI_AUDIO_TO_TEXT_DEPLOYMENT_NAME": "test_audio_to_text_deployment", - "AZURE_OPENAI_TEXT_TO_AUDIO_DEPLOYMENT_NAME": "test_text_to_audio_deployment", - "AZURE_OPENAI_REALTIME_DEPLOYMENT_NAME": "test_realtime_deployment", - "AZURE_OPENAI_API_KEY": "test_api_key", - "AZURE_OPENAI_API_VERSION": "2023-03-15-preview", - "AZURE_OPENAI_BASE_URL": "https://test_text_deployment.test-base-url.com", - "AZURE_OPENAI_TOKEN_ENDPOINT": "https://test-token-endpoint.com", - } - - 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(scope="function") -def chat_history() -> list[Message]: - return [] diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_assistants_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_assistants_client.py deleted file mode 100644 index dd0e2f2d38..0000000000 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_assistants_client.py +++ /dev/null @@ -1,409 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from typing import Annotated -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from agent_framework import ( - SupportsChatGetResponse, - tool, -) -from agent_framework._settings import SecretString -from agent_framework.azure import AzureOpenAIAssistantsClient -from pydantic import Field - - -def create_test_azure_assistants_client( - mock_async_azure_openai: MagicMock, - deployment_name: str | None = None, - assistant_id: str | None = None, - assistant_name: str | None = None, - thread_id: str | None = None, - should_delete_assistant: bool = False, -) -> AzureOpenAIAssistantsClient: - """Helper function to create AzureOpenAIAssistantsClient instances for testing.""" - client = AzureOpenAIAssistantsClient( - deployment_name=deployment_name or "test_chat_deployment", - assistant_id=assistant_id, - assistant_name=assistant_name, - thread_id=thread_id, - api_key="test-api-key", - endpoint="https://test-endpoint.com", - async_client=mock_async_azure_openai, - ) - # Set the _should_delete_assistant flag directly if needed - if should_delete_assistant: - object.__setattr__(client, "_should_delete_assistant", True) - return client - - -@pytest.fixture -def mock_async_azure_openai() -> MagicMock: - """Mock AsyncAzureOpenAI client.""" - mock_client = MagicMock() - - # Mock beta.assistants - mock_client.beta.assistants.create = AsyncMock(return_value=MagicMock(id="test-assistant-id")) - mock_client.beta.assistants.delete = AsyncMock() - - # Mock beta.threads - mock_client.beta.threads.create = AsyncMock(return_value=MagicMock(id="test-thread-id")) - mock_client.beta.threads.delete = AsyncMock() - - # Mock beta.threads.runs - mock_client.beta.threads.runs.create = AsyncMock(return_value=MagicMock(id="test-run-id")) - mock_client.beta.threads.runs.retrieve = AsyncMock() - mock_client.beta.threads.runs.submit_tool_outputs = AsyncMock() - - # Mock beta.threads.messages - mock_client.beta.threads.messages.create = AsyncMock() - mock_client.beta.threads.messages.list = AsyncMock(return_value=MagicMock(data=[])) - - return mock_client - - -def test_azure_assistants_client_init_with_client(mock_async_azure_openai: MagicMock) -> None: - """Test AzureOpenAIAssistantsClient initialization with existing client.""" - client = create_test_azure_assistants_client( - mock_async_azure_openai, - deployment_name="test_chat_deployment", - assistant_id="existing-assistant-id", - thread_id="test-thread-id", - ) - - assert client.client is mock_async_azure_openai - assert client.model == "test_chat_deployment" - assert client.assistant_id == "existing-assistant-id" - assert client.thread_id == "test-thread-id" - assert not client._should_delete_assistant # type: ignore - assert isinstance(client, SupportsChatGetResponse) - - -def test_azure_assistants_client_init_auto_create_client( - azure_openai_unit_test_env: dict[str, str], - mock_async_azure_openai: MagicMock, -) -> None: - """Test AzureOpenAIAssistantsClient initialization with auto-created client.""" - client = AzureOpenAIAssistantsClient( - deployment_name=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - assistant_name="TestAssistant", - api_key=azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"], - endpoint=azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"], - async_client=mock_async_azure_openai, - ) - - assert client.client is mock_async_azure_openai - assert client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] - assert client.assistant_id is None - assert client.assistant_name == "TestAssistant" - assert not client._should_delete_assistant # type: ignore - - -def test_azure_assistants_client_init_validation_fail() -> None: - """Test AzureOpenAIAssistantsClient initialization with validation failure.""" - with pytest.raises(ValueError): - # Force failure by providing invalid deployment name type - this should cause validation to fail - AzureOpenAIAssistantsClient(deployment_name=123, api_key="valid-key") # type: ignore - - -@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]], indirect=True) -def test_azure_assistants_client_init_missing_deployment_name(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test AzureOpenAIAssistantsClient initialization with missing deployment name.""" - with pytest.raises(ValueError): - AzureOpenAIAssistantsClient(api_key=azure_openai_unit_test_env.get("AZURE_OPENAI_API_KEY", "test-key")) - - -def test_azure_assistants_client_init_with_default_headers(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test AzureOpenAIAssistantsClient initialization with default headers.""" - default_headers = {"X-Unit-Test": "test-guid"} - - client = AzureOpenAIAssistantsClient( - deployment_name="test_chat_deployment", - api_key=azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"], - endpoint=azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"], - default_headers=default_headers, - ) - - assert client.model == "test_chat_deployment" - assert isinstance(client, SupportsChatGetResponse) - - # Assert that the default header we added is present in the client's default headers - for key, value in default_headers.items(): - assert key in client.client.default_headers - assert client.client.default_headers[key] == value - - -async def test_azure_assistants_client_get_assistant_id_or_create_existing_assistant( - mock_async_azure_openai: MagicMock, -) -> None: - """Test _get_assistant_id_or_create when assistant_id is already provided.""" - client = create_test_azure_assistants_client(mock_async_azure_openai, assistant_id="existing-assistant-id") - - assistant_id = await client._get_assistant_id_or_create() # type: ignore - - assert assistant_id == "existing-assistant-id" - assert not client._should_delete_assistant # type: ignore - mock_async_azure_openai.beta.assistants.create.assert_not_called() - - -async def test_azure_assistants_client_get_assistant_id_or_create_create_new( - mock_async_azure_openai: MagicMock, -) -> None: - """Test _get_assistant_id_or_create when creating a new assistant.""" - client = create_test_azure_assistants_client( - mock_async_azure_openai, deployment_name="test_chat_deployment", assistant_name="TestAssistant" - ) - - assistant_id = await client._get_assistant_id_or_create() # type: ignore - - assert assistant_id == "test-assistant-id" - assert client._should_delete_assistant # type: ignore - mock_async_azure_openai.beta.assistants.create.assert_called_once() - - -async def test_azure_assistants_client_aclose_should_not_delete( - mock_async_azure_openai: MagicMock, -) -> None: - """Test close when assistant should not be deleted.""" - client = create_test_azure_assistants_client( - mock_async_azure_openai, assistant_id="assistant-to-keep", should_delete_assistant=False - ) - - await client.close() # type: ignore - - # Verify assistant deletion was not called - mock_async_azure_openai.beta.assistants.delete.assert_not_called() - assert not client._should_delete_assistant # type: ignore - - -async def test_azure_assistants_client_aclose_should_delete(mock_async_azure_openai: MagicMock) -> None: - """Test close method calls cleanup.""" - client = create_test_azure_assistants_client( - mock_async_azure_openai, assistant_id="assistant-to-delete", should_delete_assistant=True - ) - - await client.close() - - # Verify assistant deletion was called - mock_async_azure_openai.beta.assistants.delete.assert_called_once_with("assistant-to-delete") - assert not client._should_delete_assistant # type: ignore - - -async def test_azure_assistants_client_async_context_manager(mock_async_azure_openai: MagicMock) -> None: - """Test async context manager functionality.""" - client = create_test_azure_assistants_client( - mock_async_azure_openai, assistant_id="assistant-to-delete", should_delete_assistant=True - ) - - # Test context manager - async with client: - pass # Just test that we can enter and exit - - # Verify cleanup was called on exit - mock_async_azure_openai.beta.assistants.delete.assert_called_once_with("assistant-to-delete") - - -def test_azure_assistants_client_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test serialization of AzureOpenAIAssistantsClient.""" - default_headers = {"X-Unit-Test": "test-guid"} - - # Test basic initialization and to_dict - client = AzureOpenAIAssistantsClient( - deployment_name="test_chat_deployment", - assistant_id="test-assistant-id", - assistant_name="TestAssistant", - thread_id="test-thread-id", - api_key=azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"], - endpoint=azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"], - default_headers=default_headers, - ) - - dumped_settings = client.to_dict() - - assert dumped_settings["model"] == "test_chat_deployment" - assert dumped_settings["assistant_id"] == "test-assistant-id" - assert dumped_settings["assistant_name"] == "TestAssistant" - assert dumped_settings["thread_id"] == "test-thread-id" - - # Assert that the default header we added is present in the dumped_settings default headers - for key, value in default_headers.items(): - assert key in dumped_settings["default_headers"] - assert dumped_settings["default_headers"][key] == value - # Assert that the 'User-Agent' header is not present in the dumped_settings default headers - assert "User-Agent" not in dumped_settings["default_headers"] - - -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - return f"The weather in {location} is sunny with a high of 25°C." - - -def test_azure_assistants_client_entra_id_authentication() -> None: - """Test credential authentication path with sync credential.""" - mock_credential = MagicMock() - mock_provider = MagicMock(return_value="token-string") - - with ( - patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings, - patch( - "agent_framework_azure_ai._deprecated_azure_openai.resolve_credential_to_token_provider", - return_value=mock_provider, - ) as mock_resolve, - patch("agent_framework_azure_ai._deprecated_azure_openai.AsyncAzureOpenAI") as mock_azure_client, - patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None), - ): - mock_load_settings.return_value = { - "chat_deployment_name": "test-deployment", - "responses_deployment_name": None, - "api_key": None, - "token_endpoint": "https://cognitiveservices.azure.com/.default", - "api_version": "2024-05-01-preview", - "endpoint": "https://test-endpoint.openai.azure.com", - "base_url": None, - } - - client = AzureOpenAIAssistantsClient( - deployment_name="test-deployment", - endpoint="https://test-endpoint.openai.azure.com", - credential=mock_credential, - token_endpoint="https://cognitiveservices.azure.com/.default", - ) - - # Verify credential was resolved to a token provider - mock_resolve.assert_called_once_with(mock_credential, "https://cognitiveservices.azure.com/.default") - - # Verify client was created with the token provider - mock_azure_client.assert_called_once() - call_args = mock_azure_client.call_args[1] - assert call_args["azure_ad_token_provider"] is mock_provider - - assert client is not None - assert isinstance(client, AzureOpenAIAssistantsClient) - - -def test_azure_assistants_client_no_authentication_error() -> None: - """Test authentication validation error when no auth provided.""" - with patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings: - mock_load_settings.return_value = { - "chat_deployment_name": "test-deployment", - "responses_deployment_name": None, - "api_key": None, - "token_endpoint": None, - "api_version": "2024-05-01-preview", - "endpoint": "https://test-endpoint.openai.azure.com", - "base_url": None, - } - - # Test missing authentication raises error - with pytest.raises(ValueError, match="api_key, credential, or a client"): - AzureOpenAIAssistantsClient( - deployment_name="test-deployment", - endpoint="https://test-endpoint.openai.azure.com", - # No authentication provided at all - ) - - -def test_azure_assistants_client_callable_credential() -> None: - """Test callable token provider as credential.""" - mock_provider = MagicMock(return_value="my-token") - - with ( - patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings, - patch( - "agent_framework_azure_ai._deprecated_azure_openai.resolve_credential_to_token_provider", - return_value=mock_provider, - ), - patch("agent_framework_azure_ai._deprecated_azure_openai.AsyncAzureOpenAI") as mock_azure_client, - patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None), - ): - mock_load_settings.return_value = { - "chat_deployment_name": "test-deployment", - "responses_deployment_name": None, - "api_key": None, - "token_endpoint": "https://cognitiveservices.azure.com/.default", - "api_version": "2024-05-01-preview", - "endpoint": "https://test-endpoint.openai.azure.com", - "base_url": None, - } - - client = AzureOpenAIAssistantsClient( - deployment_name="test-deployment", - endpoint="https://test-endpoint.openai.azure.com", - credential=mock_provider, - token_endpoint="https://cognitiveservices.azure.com/.default", - ) - - # Verify client was created with the token provider - mock_azure_client.assert_called_once() - call_args = mock_azure_client.call_args[1] - assert call_args["azure_ad_token_provider"] is mock_provider - - assert client is not None - assert isinstance(client, AzureOpenAIAssistantsClient) - - -def test_azure_assistants_client_base_url_configuration() -> None: - """Test base_url client parameter path.""" - with ( - patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings, - patch("agent_framework_azure_ai._deprecated_azure_openai.AsyncAzureOpenAI") as mock_azure_client, - patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None), - ): - mock_load_settings.return_value = { - "chat_deployment_name": "test-deployment", - "responses_deployment_name": None, - "api_key": SecretString("test-api-key"), - "token_endpoint": None, - "api_version": "2024-05-01-preview", - "endpoint": None, - "base_url": "https://custom-base-url.com", - } - - client = AzureOpenAIAssistantsClient( - deployment_name="test-deployment", api_key="test-api-key", base_url="https://custom-base-url.com" - ) - - # base_url path - mock_azure_client.assert_called_once() - call_args = mock_azure_client.call_args[1] - assert call_args["base_url"] == "https://custom-base-url.com" - assert "azure_endpoint" not in call_args - - assert client is not None - assert isinstance(client, AzureOpenAIAssistantsClient) - - -def test_azure_assistants_client_azure_endpoint_configuration() -> None: - """Test azure_endpoint client parameter path.""" - with ( - patch("agent_framework_azure_ai._deprecated_azure_openai.load_settings") as mock_load_settings, - patch("agent_framework_azure_ai._deprecated_azure_openai.AsyncAzureOpenAI") as mock_azure_client, - patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None), - ): - mock_load_settings.return_value = { - "chat_deployment_name": "test-deployment", - "responses_deployment_name": None, - "api_key": SecretString("test-api-key"), - "token_endpoint": None, - "api_version": "2024-05-01-preview", - "endpoint": "https://test-endpoint.openai.azure.com", - "base_url": None, - } - - client = AzureOpenAIAssistantsClient( - deployment_name="test-deployment", - api_key="test-api-key", - endpoint="https://test-endpoint.openai.azure.com", - ) - - # azure_endpoint path - mock_azure_client.assert_called_once() - call_args = mock_azure_client.call_args[1] - assert call_args["azure_endpoint"] == "https://test-endpoint.openai.azure.com" - assert "base_url" not in call_args - - assert client is not None - assert isinstance(client, AzureOpenAIAssistantsClient) diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py deleted file mode 100644 index c1485d430d..0000000000 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_chat_client.py +++ /dev/null @@ -1,1102 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import json -import os -from functools import wraps -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import openai -import pytest -from agent_framework import ( - Agent, - AgentResponse, - AgentResponseUpdate, - ChatResponse, - ChatResponseUpdate, - Message, - SupportsChatGetResponse, - tool, -) -from agent_framework._telemetry import USER_AGENT_KEY -from agent_framework.azure import AzureOpenAIChatClient -from agent_framework.exceptions import ChatClientException -from agent_framework_openai import ( - ContentFilterResultSeverity, - OpenAIContentFilterException, -) -from azure.identity import AzureCliCredential -from httpx import Request, Response -from openai import AsyncAzureOpenAI, 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 - -pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIChatClient is deprecated\\..*:DeprecationWarning") - -# region Service Setup - -skip_if_azure_integration_tests_disabled = pytest.mark.skipif( - os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com"), - reason="No real AZURE_OPENAI_ENDPOINT provided; skipping integration tests.", -) - - -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_DEPLOYMENT_NAME") or os.getenv( - "AZURE_OPENAI_DEPLOYMENT_NAME", "" - ) - api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") - endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") - debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" - if hasattr(exc, "add_note"): - 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 test_init(azure_openai_unit_test_env: dict[str, str]) -> None: - # Test successful initialization - azure_chat_client = AzureOpenAIChatClient() - - assert azure_chat_client.client is not None - assert isinstance(azure_chat_client.client, AsyncAzureOpenAI) - assert azure_chat_client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] - assert isinstance(azure_chat_client, SupportsChatGetResponse) - - -def test_init_client(azure_openai_unit_test_env: dict[str, str]) -> None: - # Test successful initialization with client - client = MagicMock(spec=AsyncAzureOpenAI) - azure_chat_client = AzureOpenAIChatClient(async_client=client) - - assert azure_chat_client.client is not None - assert isinstance(azure_chat_client.client, AsyncAzureOpenAI) - - -def test_init_base_url(azure_openai_unit_test_env: dict[str, str]) -> None: - # Custom header for testing - default_headers = {"X-Unit-Test": "test-guid"} - - azure_chat_client = AzureOpenAIChatClient( - default_headers=default_headers, - ) - - assert azure_chat_client.client is not None - assert isinstance(azure_chat_client.client, AsyncAzureOpenAI) - assert azure_chat_client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] - assert isinstance(azure_chat_client, SupportsChatGetResponse) - for key, value in default_headers.items(): - assert key in azure_chat_client.client.default_headers - assert azure_chat_client.client.default_headers[key] == value - - -@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_BASE_URL"]], indirect=True) -def test_init_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: - azure_chat_client = AzureOpenAIChatClient() - - assert azure_chat_client.client is not None - assert isinstance(azure_chat_client.client, AsyncAzureOpenAI) - assert azure_chat_client.model == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] - assert isinstance(azure_chat_client, SupportsChatGetResponse) - - -@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]], indirect=True) -def test_init_with_empty_deployment_name( - azure_openai_unit_test_env: dict[str, str], -) -> None: - with pytest.raises(ValueError): - AzureOpenAIChatClient() - - -@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_BASE_URL"]], indirect=True) -def test_init_with_empty_endpoint_and_base_url( - azure_openai_unit_test_env: dict[str, str], -) -> None: - with pytest.raises(ValueError): - AzureOpenAIChatClient() - - -@pytest.mark.parametrize( - "override_env_param_dict", - [{"AZURE_OPENAI_ENDPOINT": "http://test.com"}], - indirect=True, -) -def test_init_with_invalid_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: - # Note: URL scheme validation was previously handled by pydantic's HTTPsUrl type. - # After migrating to load_settings with TypedDict, endpoint is a plain string and no longer - # validated at the settings level. The Azure OpenAI SDK may reject invalid URLs at runtime. - client = AzureOpenAIChatClient() - assert client is not None - - -@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_BASE_URL"]], indirect=True) -def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: - default_headers = {"X-Test": "test"} - - settings = { - "deployment_name": azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - "endpoint": azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"], - "api_key": azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"], - "api_version": azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"], - "default_headers": default_headers, - } - - azure_chat_client = AzureOpenAIChatClient.from_dict(settings) - dumped_settings = azure_chat_client.to_dict() - assert dumped_settings["model"] == settings["deployment_name"] - assert str(settings["endpoint"]) in str(dumped_settings["endpoint"]) - assert str(settings["deployment_name"]) == str(dumped_settings["deployment_name"]) - assert settings["api_version"] == dumped_settings["api_version"] - assert "api_key" not in dumped_settings - - # Assert that the default header we added is present in the dumped_settings default headers - for key, value in default_headers.items(): - assert key in dumped_settings["default_headers"] - assert dumped_settings["default_headers"][key] == value - - # Assert that the 'User-agent' header is not present in the dumped_settings default headers - assert USER_AGENT_KEY not in dumped_settings["default_headers"] - - -# endregion -# region CMC - - -@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 - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_cmc( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], - mock_chat_completion_response: ChatCompletion, -) -> None: - mock_create.return_value = mock_chat_completion_response - chat_history.append(Message(text="hello world", role="user")) - - azure_chat_client = AzureOpenAIChatClient() - await azure_chat_client.get_response( - messages=chat_history, - ) - mock_create.assert_awaited_once_with( - model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - stream=False, - messages=azure_chat_client._prepare_messages_for_openai(chat_history), # type: ignore - ) - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_cmc_with_logit_bias( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], - mock_chat_completion_response: ChatCompletion, -) -> None: - mock_create.return_value = mock_chat_completion_response - prompt = "hello world" - chat_history.append(Message(text=prompt, role="user")) - - token_bias: dict[str | int, float] = {"1": -100} - - azure_chat_client = AzureOpenAIChatClient() - - await azure_chat_client.get_response(messages=chat_history, options={"logit_bias": token_bias}) - - mock_create.assert_awaited_once_with( - model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - messages=azure_chat_client._prepare_messages_for_openai(chat_history), # type: ignore - stream=False, - logit_bias=token_bias, - ) - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_cmc_with_stop( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], - mock_chat_completion_response: ChatCompletion, -) -> None: - mock_create.return_value = mock_chat_completion_response - prompt = "hello world" - chat_history.append(Message(text=prompt, role="user")) - - stop = ["!"] - - azure_chat_client = AzureOpenAIChatClient() - - await azure_chat_client.get_response(messages=chat_history, options={"stop": stop}) - - mock_create.assert_awaited_once_with( - model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - messages=azure_chat_client._prepare_messages_for_openai(chat_history), # type: ignore - stream=False, - stop=stop, - ) - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_azure_on_your_data( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], - mock_chat_completion_response: ChatCompletion, -) -> None: - mock_chat_completion_response.choices = [ - Choice( - index=0, - message=ChatCompletionMessage( - content="test", - role="assistant", - context={ # type: ignore - "citations": [ - { - "content": "test content", - "title": "test title", - "url": "test url", - "filepath": "test filepath", - "chunk_id": "test chunk_id", - } - ], - "intent": "query used", - }, - ), - finish_reason="stop", - ) - ] - mock_create.return_value = mock_chat_completion_response - prompt = "hello world" - messages_in = chat_history - chat_history.append(Message(text=prompt, role="user")) - messages_out: list[Message] = [] - messages_out.append(Message(text=prompt, role="user")) - - expected_data_settings = { - "data_sources": [ - { - "type": "AzureCognitiveSearch", - "parameters": { - "indexName": "test_index", - "endpoint": "https://test-endpoint-search.com", - "key": "test_key", - }, - } - ] - } - - azure_chat_client = AzureOpenAIChatClient() - - content = await azure_chat_client.get_response( - messages=messages_in, - options={"extra_body": expected_data_settings}, - ) - assert len(content.messages) == 1 - assert len(content.messages[0].contents) == 1 - assert content.messages[0].contents[0].type == "text" - assert len(content.messages[0].contents[0].annotations) == 1 - assert content.messages[0].contents[0].annotations[0]["title"] == "test title" - assert content.messages[0].contents[0].text == "test" - - mock_create.assert_awaited_once_with( - model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - messages=azure_chat_client._prepare_messages_for_openai(messages_out), # type: ignore - stream=False, - extra_body=expected_data_settings, - ) - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_azure_on_your_data_string( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], - mock_chat_completion_response: ChatCompletion, -) -> None: - mock_chat_completion_response.choices = [ - Choice( - index=0, - message=ChatCompletionMessage( - content="test", - role="assistant", - context=json.dumps({ # type: ignore - "citations": [ - { - "content": "test content", - "title": "test title", - "url": "test url", - "filepath": "test filepath", - "chunk_id": "test chunk_id", - } - ], - "intent": "query used", - }), - ), - finish_reason="stop", - ) - ] - mock_create.return_value = mock_chat_completion_response - prompt = "hello world" - messages_in = chat_history - messages_in.append(Message(text=prompt, role="user")) - messages_out: list[Message] = [] - messages_out.append(Message(text=prompt, role="user")) - - expected_data_settings = { - "data_sources": [ - { - "type": "AzureCognitiveSearch", - "parameters": { - "indexName": "test_index", - "endpoint": "https://test-endpoint-search.com", - "key": "test_key", - }, - } - ] - } - - azure_chat_client = AzureOpenAIChatClient() - - content = await azure_chat_client.get_response( - messages=messages_in, - options={"extra_body": expected_data_settings}, - ) - assert len(content.messages) == 1 - assert len(content.messages[0].contents) == 1 - assert content.messages[0].contents[0].type == "text" - assert len(content.messages[0].contents[0].annotations) == 1 - assert content.messages[0].contents[0].annotations[0]["title"] == "test title" - assert content.messages[0].contents[0].text == "test" - - mock_create.assert_awaited_once_with( - model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - messages=azure_chat_client._prepare_messages_for_openai(messages_out), # type: ignore - stream=False, - extra_body=expected_data_settings, - ) - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_azure_on_your_data_fail( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], - mock_chat_completion_response: ChatCompletion, -) -> None: - mock_chat_completion_response.choices = [ - Choice( - index=0, - message=ChatCompletionMessage( - content="test", - role="assistant", - context="not a dictionary", # type: ignore - ), - finish_reason="stop", - ) - ] - mock_create.return_value = mock_chat_completion_response - prompt = "hello world" - messages_in = chat_history - messages_in.append(Message(text=prompt, role="user")) - messages_out: list[Message] = [] - messages_out.append(Message(text=prompt, role="user")) - - expected_data_settings = { - "data_sources": [ - { - "type": "AzureCognitiveSearch", - "parameters": { - "indexName": "test_index", - "endpoint": "https://test-endpoint-search.com", - "key": "test_key", - }, - } - ] - } - - azure_chat_client = AzureOpenAIChatClient() - - content = await azure_chat_client.get_response( - messages=messages_in, - options={"extra_body": expected_data_settings}, - ) - assert len(content.messages) == 1 - assert len(content.messages[0].contents) == 1 - assert content.messages[0].contents[0].type == "text" - assert content.messages[0].contents[0].text == "test" - - mock_create.assert_awaited_once_with( - model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - messages=azure_chat_client._prepare_messages_for_openai(messages_out), # type: ignore - stream=False, - extra_body=expected_data_settings, - ) - - -CONTENT_FILTERED_ERROR_MESSAGE = ( - "The response was filtered due to the prompt triggering Azure OpenAI's content management policy. Please " - "modify your prompt and retry. To learn more about our content filtering policies please read our " - "documentation: https://go.microsoft.com/fwlink/?linkid=2198766" -) -CONTENT_FILTERED_ERROR_FULL_MESSAGE = ( - "Error code: 400 - {'error': {'message': \"%s\", 'type': null, 'param': 'prompt', 'code': 'content_filter', " - "'status': 400, 'innererror': {'code': 'ResponsibleAIPolicyViolation', 'content_filter_result': {'hate': " - "{'filtered': True, 'severity': 'high'}, 'self_harm': {'filtered': False, 'severity': 'safe'}, 'sexual': " - "{'filtered': False, 'severity': 'safe'}, 'violence': {'filtered': False, 'severity': 'safe'}}}}}" -) % CONTENT_FILTERED_ERROR_MESSAGE - - -@patch.object(AsyncChatCompletions, "create") -async def test_content_filtering_raises_correct_exception( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], -) -> None: - prompt = "some prompt that would trigger the content filtering" - chat_history.append(Message(text=prompt, role="user")) - - test_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT") - assert test_endpoint is not None - mock_create.side_effect = openai.BadRequestError( - CONTENT_FILTERED_ERROR_FULL_MESSAGE, - response=Response(400, request=Request("POST", test_endpoint)), - body={ - "message": CONTENT_FILTERED_ERROR_MESSAGE, - "type": None, - "param": "prompt", - "code": "content_filter", - "status": 400, - "innererror": { - "code": "ResponsibleAIPolicyViolation", - "content_filter_result": { - "hate": {"filtered": True, "severity": "high"}, - "self_harm": {"filtered": False, "severity": "safe"}, - "sexual": {"filtered": False, "severity": "safe"}, - "violence": {"filtered": False, "severity": "safe"}, - }, - }, - }, - ) - - azure_chat_client = AzureOpenAIChatClient() - - with pytest.raises(OpenAIContentFilterException, match="service encountered a content error") as exc_info: - await azure_chat_client.get_response( - messages=chat_history, - ) - - content_filter_exc = exc_info.value - assert content_filter_exc.param == "prompt" - assert content_filter_exc.content_filter_result["hate"].filtered - assert content_filter_exc.content_filter_result["hate"].severity == ContentFilterResultSeverity.HIGH - - -@patch.object(AsyncChatCompletions, "create") -async def test_content_filtering_without_response_code_raises_with_default_code( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], -) -> None: - prompt = "some prompt that would trigger the content filtering" - chat_history.append(Message(text=prompt, role="user")) - - test_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT") - assert test_endpoint is not None - mock_create.side_effect = openai.BadRequestError( - CONTENT_FILTERED_ERROR_FULL_MESSAGE, - response=Response(400, request=Request("POST", test_endpoint)), - body={ - "message": CONTENT_FILTERED_ERROR_MESSAGE, - "type": None, - "param": "prompt", - "code": "content_filter", - "status": 400, - "innererror": { - "content_filter_result": { - "hate": {"filtered": True, "severity": "high"}, - "self_harm": {"filtered": False, "severity": "safe"}, - "sexual": {"filtered": False, "severity": "safe"}, - "violence": {"filtered": False, "severity": "safe"}, - }, - }, - }, - ) - - azure_chat_client = AzureOpenAIChatClient() - - with pytest.raises(OpenAIContentFilterException, match="service encountered a content error"): - await azure_chat_client.get_response( - messages=chat_history, - ) - - -@patch.object(AsyncChatCompletions, "create") -async def test_bad_request_non_content_filter( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], -) -> None: - prompt = "some prompt that would trigger the content filtering" - chat_history.append(Message(text=prompt, role="user")) - - test_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT") - assert test_endpoint is not None - mock_create.side_effect = openai.BadRequestError( - "The request was bad.", - response=Response(400, request=Request("POST", test_endpoint)), - body={}, - ) - - azure_chat_client = AzureOpenAIChatClient() - - with pytest.raises(ChatClientException, match="service failed to complete the prompt"): - await azure_chat_client.get_response( - messages=chat_history, - ) - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_get_streaming( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], - mock_streaming_chat_completion_response: AsyncStream[ChatCompletionChunk], -) -> None: - mock_create.return_value = mock_streaming_chat_completion_response - chat_history.append(Message(text="hello world", role="user")) - - azure_chat_client = AzureOpenAIChatClient() - async for msg in azure_chat_client.get_response( - messages=chat_history, - stream=True, - ): - assert msg is not None - assert msg.message_id is not None - assert msg.response_id is not None - mock_create.assert_awaited_once_with( - model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], - stream=True, - messages=azure_chat_client._prepare_messages_for_openai(chat_history), # type: ignore - # NOTE: The `stream_options={"include_usage": True}` is explicitly enforced in - # `OpenAIChatCompletionBase.get_response(..., stream=True)`. - # To ensure consistency, we align the arguments here accordingly. - stream_options={"include_usage": True}, - ) - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_streaming_with_none_delta( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], -) -> None: - """Test streaming handles None delta from async content filtering.""" - # First chunk has None delta (simulates async filtering) - chunk_choice_with_none = ChunkChoice.model_construct(index=0, delta=None, finish_reason=None) - chunk_with_none_delta = ChatCompletionChunk.model_construct( - id="test_id", - choices=[chunk_choice_with_none], - created=0, - model="test", - object="chat.completion.chunk", - ) - # Second chunk has actual content - chunk_with_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 = [chunk_with_none_delta, chunk_with_content] - mock_create.return_value = stream - - chat_history.append(Message(text="hello world", role="user")) - azure_chat_client = AzureOpenAIChatClient() - - results: list[ChatResponseUpdate] = [] - async for msg in azure_chat_client.get_response(messages=chat_history, stream=True): - results.append(msg) - - assert len(results) > 0 - assert any(content.type == "text" and content.text == "test" for msg in results for content in msg.contents) - assert any(msg.contents for msg in results) - - -# region _parse_text_from_openai direct unit tests - - -def test_parse_text_from_openai_with_choice_message(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test _parse_text_from_openai correctly reads message from a Choice.""" - client = AzureOpenAIChatClient() - choice = Choice( - index=0, - message=ChatCompletionMessage(content="hello", role="assistant"), - finish_reason="stop", - ) - result = client._parse_text_from_openai(choice) - assert result is not None - assert result.type == "text" - assert result.text == "hello" - - -def test_parse_text_from_openai_with_chunk_choice_delta(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test _parse_text_from_openai correctly reads delta from a ChunkChoice.""" - client = AzureOpenAIChatClient() - choice = ChunkChoice( - index=0, - delta=ChunkChoiceDelta(content="streamed", role="assistant"), - finish_reason=None, - ) - result = client._parse_text_from_openai(choice) - assert result is not None - assert result.type == "text" - assert result.text == "streamed" - - -def test_parse_text_from_openai_refusal_choice(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test _parse_text_from_openai returns refusal text from a Choice.""" - client = AzureOpenAIChatClient() - choice = Choice( - index=0, - message=ChatCompletionMessage(content=None, role="assistant", refusal="I cannot help with that"), - finish_reason="stop", - ) - result = client._parse_text_from_openai(choice) - assert result is not None - assert result.type == "text" - assert result.text == "I cannot help with that" - - -def test_parse_text_from_openai_refusal_chunk_choice(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test _parse_text_from_openai returns refusal text from a ChunkChoice.""" - client = AzureOpenAIChatClient() - choice = ChunkChoice( - index=0, - delta=ChunkChoiceDelta(content=None, role="assistant", refusal="I cannot help with that"), - finish_reason=None, - ) - result = client._parse_text_from_openai(choice) - assert result is not None - assert result.type == "text" - assert result.text == "I cannot help with that" - - -def test_parse_text_from_openai_no_content_no_refusal(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test _parse_text_from_openai returns None when no content or refusal.""" - client = AzureOpenAIChatClient() - choice = Choice( - index=0, - message=ChatCompletionMessage(content=None, role="assistant"), - finish_reason="stop", - ) - result = client._parse_text_from_openai(choice) - assert result is None - - -def test_parse_text_from_openai_none_delta(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test _parse_text_from_openai returns None when delta is None (async content filtering).""" - client = AzureOpenAIChatClient() - choice = ChunkChoice.model_construct(index=0, delta=None, finish_reason=None) - result = client._parse_text_from_openai(choice) - assert result is None - - -# endregion - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_cmc_with_conversation_id( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], - mock_chat_completion_response: ChatCompletion, -) -> None: - """Test that conversation_id is excluded from the completions create call.""" - mock_create.return_value = mock_chat_completion_response - chat_history.append(Message(text="hello world", role="user")) - - azure_chat_client = AzureOpenAIChatClient() - await azure_chat_client.get_response( - messages=chat_history, - options={"conversation_id": "12345"}, - ) - - call_kwargs = mock_create.call_args.kwargs - assert "conversation_id" not in call_kwargs - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_cmc_streaming_with_conversation_id( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - chat_history: list[Message], - mock_streaming_chat_completion_response: AsyncStream[ChatCompletionChunk], -) -> None: - """Test that conversation_id is excluded from the streaming completions create call.""" - mock_create.return_value = mock_streaming_chat_completion_response - chat_history.append(Message(text="hello world", role="user")) - - azure_chat_client = AzureOpenAIChatClient() - async for _ in azure_chat_client.get_response( - messages=chat_history, - options={"conversation_id": "12345"}, - stream=True, - ): - pass - - call_kwargs = mock_create.call_args.kwargs - assert "conversation_id" not in call_kwargs - - -@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) -async def test_cmc_agent_with_service_session_id( - mock_create: AsyncMock, - azure_openai_unit_test_env: dict[str, str], - mock_chat_completion_response: ChatCompletion, -) -> None: - """Test that agent.run() with a session containing service_session_id works correctly.""" - mock_create.return_value = mock_chat_completion_response - - azure_chat_client = AzureOpenAIChatClient() - agent = azure_chat_client.as_agent( - name="TestAgent", - instructions="You are a helpful assistant.", - ) - - session = agent.get_session(service_session_id="12345") - response = await agent.run("hello", session=session) - - assert response is not None - call_kwargs = mock_create.call_args.kwargs - assert "conversation_id" not in call_kwargs - - -@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") -def get_weather(location: str) -> str: - """Get the current weather for a location.""" - return f"The weather in {location} is sunny and 72°F." - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_openai_chat_client_response() -> None: - """Test Azure OpenAI chat completion responses.""" - azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - assert isinstance(azure_chat_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append( - Message( - role="user", - text="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.", - ) - ) - messages.append(Message(role="user", text="who are Emily and David?")) - - # Test that the client can be used to get a response - response = await azure_chat_client.get_response(messages=messages) - - assert response is not None - assert isinstance(response, ChatResponse) - # Check for any relevant keywords that indicate the AI understood the context - assert any( - word in response.text.lower() for word in ["scientists", "research", "antarctica", "glaciology", "climate"] - ) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_openai_chat_client_response_tools() -> None: - """Test AzureOpenAI chat completion responses.""" - azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - assert isinstance(azure_chat_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append(Message(role="user", text="who are Emily and David?")) - - # Test that the client can be used to get a response - response = await azure_chat_client.get_response( - messages=messages, - 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_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_openai_chat_client_streaming() -> None: - """Test Azure OpenAI chat completion responses.""" - azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - assert isinstance(azure_chat_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append( - Message( - role="user", - text="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.", - ) - ) - messages.append(Message(role="user", text="who are Emily and David?")) - - # Test that the client can be used to get a response - response = azure_chat_client.get_response(messages=messages, stream=True) - - full_message: str = "" - async for chunk in response: - assert chunk is not None - 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_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_openai_chat_client_streaming_tools() -> None: - """Test AzureOpenAI chat completion responses.""" - azure_chat_client = AzureOpenAIChatClient(credential=AzureCliCredential()) - assert isinstance(azure_chat_client, SupportsChatGetResponse) - - messages: list[Message] = [] - messages.append(Message(role="user", text="who are Emily and David?")) - - # Test that the client can be used to get a response - response = azure_chat_client.get_response( - messages=messages, - stream=True, - options={"tools": [get_story_text], "tool_choice": "auto"}, - ) - full_message: str = "" - async for chunk in response: - assert chunk is not None - 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_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_openai_chat_client_agent_basic_run(): - """Test Azure OpenAI chat client agent basic run functionality with AzureOpenAIChatClient.""" - async with Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), - ) as agent: - # Test basic run - response = await agent.run("Please respond with exactly: 'This is a response test.'") - - assert isinstance(response, AgentResponse) - assert response.text is not None - assert len(response.text) > 0 - assert "response test" in response.text.lower() - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_openai_chat_client_agent_basic_run_streaming(): - """Test Azure OpenAI chat client agent basic streaming functionality with AzureOpenAIChatClient.""" - async with Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), - ) as agent: - # Test streaming run - 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 len(full_text) > 0 - assert "streaming response test" in full_text.lower() - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_openai_chat_client_agent_session_persistence(): - """Test Azure OpenAI chat client agent session persistence across runs with AzureOpenAIChatClient.""" - async with Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant with good memory.", - ) as agent: - # Create a new session that will be reused - session = agent.create_session() - - # First interaction - response1 = await agent.run("My name is Alice. Remember this.", session=session) - - assert isinstance(response1, AgentResponse) - assert response1.text is not None - - # Second interaction - test memory - response2 = await agent.run("What is my name?", session=session) - - 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_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_openai_chat_client_agent_existing_session(): - """Test Azure OpenAI chat client agent with existing session to continue conversations across agent instances.""" - # First conversation - capture the session - preserved_session = None - - async with Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant with good memory.", - ) as first_agent: - # Start a conversation and capture the session - session = first_agent.create_session() - first_response = await first_agent.run("My name is Alice. Remember this.", session=session) - - assert isinstance(first_response, AgentResponse) - assert first_response.text is not None - - # Preserve the session for reuse - preserved_session = session - - # Second conversation - reuse the session in a new agent instance - if preserved_session: - async with Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant with good memory.", - ) as second_agent: - # Reuse the preserved session - 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_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_chat_client_agent_level_tool_persistence(): - """Test that agent-level tools persist across multiple runs with Azure Chat Client.""" - - async with Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant that uses available tools.", - tools=[get_weather], # Agent-level tool - ) as agent: - # First run - agent-level tool should be available - first_response = await agent.run("What's the weather like in Chicago?") - - assert isinstance(first_response, AgentResponse) - assert first_response.text is not None - # Should use the agent-level weather tool - assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"]) - - # Second run - agent-level tool should still be available (persistence test) - second_response = await agent.run("What's the weather in Miami?") - - assert isinstance(second_response, AgentResponse) - assert second_response.text is not None - # Should use the agent-level weather tool again - assert any(term in second_response.text.lower() for term in ["miami", "sunny", "72"]) diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py deleted file mode 100644 index a172be577f..0000000000 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_embedding_client.py +++ /dev/null @@ -1,219 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import os -from functools import wraps -from typing import Any -from unittest.mock import AsyncMock, MagicMock - -import pytest -from agent_framework.azure import AzureOpenAIEmbeddingClient -from agent_framework.openai import OpenAIEmbeddingOptions -from azure.identity.aio import AzureCliCredential -from openai.types import CreateEmbeddingResponse -from openai.types import Embedding as OpenAIEmbedding -from openai.types.create_embedding_response import Usage - -pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIEmbeddingClient is deprecated\\..*:DeprecationWarning") - - -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), - ) - - -@pytest.fixture -def azure_embedding_unit_test_env(monkeypatch: pytest.MonkeyPatch) -> None: - """Clear ambient Azure OpenAI embedding env vars for deterministic unit tests.""" - for key in ( - "AZURE_OPENAI_ENDPOINT", - "AZURE_OPENAI_API_KEY", - "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", - "AZURE_OPENAI_BASE_URL", - "AZURE_OPENAI_TOKEN_ENDPOINT", - ): - monkeypatch.delenv(key, raising=False) - - -def test_azure_construction_with_deployment_name(azure_embedding_unit_test_env: None) -> None: - client = AzureOpenAIEmbeddingClient( - deployment_name="text-embedding-3-small", - api_key="test-key", - endpoint="https://test.openai.azure.com/", - ) - assert client.model == "text-embedding-3-small" - - -def test_azure_construction_with_existing_client(azure_embedding_unit_test_env: None) -> None: - mock_client = MagicMock() - client = AzureOpenAIEmbeddingClient( - deployment_name="my-deployment", - async_client=mock_client, - ) - assert client.model == "my-deployment" - assert client.client is mock_client - - -def test_azure_construction_missing_deployment_name_raises(azure_embedding_unit_test_env: None) -> None: - with pytest.raises(ValueError, match="deployment name is required"): - AzureOpenAIEmbeddingClient( - api_key="test-key", - endpoint="https://test.openai.azure.com/", - ) - - -def test_azure_construction_missing_credentials_raises(azure_embedding_unit_test_env: None) -> None: - with pytest.raises(ValueError, match="api_key, credential, or a client"): - AzureOpenAIEmbeddingClient( - deployment_name="test", - endpoint="https://test.openai.azure.com/", - ) - - -async def test_azure_get_embeddings(azure_embedding_unit_test_env: None) -> None: - mock_response = _make_openai_response( - embeddings=[[0.1, 0.2]], - ) - mock_async_client = MagicMock() - mock_async_client.embeddings = MagicMock() - mock_async_client.embeddings.create = AsyncMock(return_value=mock_response) - - client = AzureOpenAIEmbeddingClient( - deployment_name="text-embedding-3-small", - async_client=mock_async_client, - ) - - result = await client.get_embeddings(["hello"]) - - assert len(result) == 1 - assert result[0].vector == [0.1, 0.2] - - -def test_azure_otel_provider_name(azure_embedding_unit_test_env: None) -> None: - mock_client = MagicMock() - client = AzureOpenAIEmbeddingClient( - deployment_name="test", - async_client=mock_client, - ) - assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" - - -skip_if_azure_openai_integration_tests_disabled = pytest.mark.skipif( - os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com") - or ( - os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", "") == "" - and os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "") == "" - ), - reason="No Azure OpenAI endpoint or embedding deployment provided; skipping integration tests.", -) - - -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_DEPLOYMENT_NAME") or os.getenv( - "AZURE_OPENAI_DEPLOYMENT_NAME", "" - ) - api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") - endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") - debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" - if hasattr(exc, "add_note"): - 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_DEPLOYMENT_NAME") or os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"] - - -def _create_azure_openai_embedding_client( - *, - api_key: str | None = None, - credential: AzureCliCredential | None = None, -) -> AzureOpenAIEmbeddingClient: - resolved_api_key = ( - api_key if api_key is not None else None if credential is not None else os.getenv("AZURE_OPENAI_API_KEY") - ) - return AzureOpenAIEmbeddingClient( - deployment_name=_get_azure_embedding_deployment_name(), - api_key=resolved_api_key, - endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], - api_version=os.getenv("AZURE_OPENAI_API_VERSION"), - credential=credential, - ) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_openai_integration_tests_disabled -@_with_azure_openai_debug() -async def test_integration_azure_openai_get_embeddings() -> None: - """End-to-end test of Azure OpenAI embedding generation.""" - async with AzureCliCredential() as credential: - client = _create_azure_openai_embedding_client(credential=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_id is not None - assert result.usage is not None - assert result.usage["input_token_count"] > 0 - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_openai_integration_tests_disabled -@_with_azure_openai_debug() -async def test_integration_azure_openai_get_embeddings_multiple() -> None: - """Test Azure OpenAI embedding generation for multiple inputs.""" - async with AzureCliCredential() as credential: - client = _create_azure_openai_embedding_client(credential=credential) - - 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) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_openai_integration_tests_disabled -@_with_azure_openai_debug() -async def test_integration_azure_openai_get_embeddings_with_dimensions() -> None: - """Test Azure OpenAI embedding generation with custom dimensions.""" - async with AzureCliCredential() as credential: - client = _create_azure_openai_embedding_client(credential=credential) - - options: OpenAIEmbeddingOptions = {"dimensions": 256} - result = await client.get_embeddings(["hello world"], options=options) - - assert len(result) == 1 - assert len(result[0].vector) == 256 diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py deleted file mode 100644 index da2c346d49..0000000000 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client.py +++ /dev/null @@ -1,542 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import json -import logging -import os -from functools import wraps -from pathlib import Path -from typing import Annotated, Any - -import pytest -from agent_framework import ( - Agent, - AgentResponse, - ChatResponse, - Content, - Message, - SupportsChatGetResponse, - tool, -) -from agent_framework.azure import AzureOpenAIResponsesClient -from azure.identity import AzureCliCredential -from pydantic import BaseModel -from pytest import param - -pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIResponsesClient is deprecated\\..*:DeprecationWarning") - -skip_if_azure_integration_tests_disabled = pytest.mark.skipif( - os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com"), - reason="No real AZURE_OPENAI_ENDPOINT provided; skipping integration tests.", -) - - -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_RESPONSES_DEPLOYMENT_NAME") or os.getenv( - "AZURE_OPENAI_DEPLOYMENT_NAME", "" - ) - api_version = os.getenv("AZURE_OPENAI_API_VERSION", "") - endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "") - debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}" - if hasattr(exc, "add_note"): - 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 - - -logger = logging.getLogger(__name__) - - -class OutputStruct(BaseModel): - """A structured output for testing purposes.""" - - location: str - weather: str - - -@tool(approval_mode="never_require") -async def get_weather(location: Annotated[str, "The location as a city name"]) -> str: - """Get the current weather in a given location.""" - # Implementation of the tool to get weather - return f"The weather in {location} is sunny and 72°F." - - -async def create_vector_store( - client: AzureOpenAIResponsesClient, -) -> 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) - if result.last_error is not None: - raise Exception(f"Vector store file processing failed with status: {result.last_error.message}") - - return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id) - - -async def delete_vector_store(client: AzureOpenAIResponsesClient, 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) - - -def test_init(azure_openai_unit_test_env: dict[str, str]) -> None: - # Test successful initialization - azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) - - assert azure_responses_client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - -def test_init_validation_fail() -> None: - # Test successful initialization - with pytest.raises(ValueError): - AzureOpenAIResponsesClient(api_key="34523", deployment_name={"test": "dict"}) # type: ignore - - -def test_init_model_id_constructor(azure_openai_unit_test_env: dict[str, str]) -> None: - # Test successful initialization - model_id = "test_model_id" - azure_responses_client = AzureOpenAIResponsesClient(deployment_name=model_id) - - assert azure_responses_client.model == model_id - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - -def test_init_model_id_kwarg(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test that model_id kwarg correctly sets the deployment name (issue #4299).""" - azure_responses_client = AzureOpenAIResponsesClient(model_id="gpt-4o") - - assert azure_responses_client.model == "gpt-4o" - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - -def test_init_model_id_kwarg_does_not_override_deployment_name( - azure_openai_unit_test_env: dict[str, str], -) -> None: - """Test that deployment_name takes precedence over model_id kwarg (issue #4299).""" - azure_responses_client = AzureOpenAIResponsesClient(deployment_name="my-deployment", model_id="gpt-4o") - - assert azure_responses_client.model == "my-deployment" - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - -def test_init_model_id_kwarg_none(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test that model_id=None does not override the env-var deployment name.""" - azure_responses_client = AzureOpenAIResponsesClient(model_id=None) - - assert azure_responses_client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] - - -def test_init_with_default_header(azure_openai_unit_test_env: dict[str, str]) -> None: - default_headers = {"X-Unit-Test": "test-guid"} - - # Test successful initialization - azure_responses_client = AzureOpenAIResponsesClient( - default_headers=default_headers, - ) - - assert azure_responses_client.model == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - # Assert that the default header we added is present in the client's default headers - for key, value in default_headers.items(): - assert key in azure_responses_client.client.default_headers - assert azure_responses_client.client.default_headers[key] == value - - -@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]], indirect=True) -def test_init_with_empty_model_id(azure_openai_unit_test_env: dict[str, str]) -> None: - with pytest.raises(ValueError): - AzureOpenAIResponsesClient() - - -def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: - default_headers = {"X-Unit-Test": "test-guid"} - - settings = { - "deployment_name": azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], - "api_key": azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"], - "default_headers": default_headers, - } - - azure_responses_client = AzureOpenAIResponsesClient.from_dict(settings) - dumped_settings = azure_responses_client.to_dict() - assert dumped_settings["deployment_name"] == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] - assert "api_key" not in dumped_settings - # Assert that the default header we added is present in the dumped_settings default headers - for key, value in default_headers.items(): - assert key in dumped_settings["default_headers"] - assert dumped_settings["default_headers"][key] == value - # Assert that the 'User-Agent' header is not present in the dumped_settings default headers - assert "User-Agent" not in dumped_settings["default_headers"] - - -# region Integration Tests - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@pytest.mark.parametrize( - "option_name,option_value,needs_validation", - [ - # Simple ChatOptions - just verify they don't fail - 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"), - # OpenAIResponsesOptions - just verify they don't fail - 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"), - # Complex options requiring output validation - 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: - """Parametrized test covering all ChatOptions and OpenAIResponsesOptions. - - Tests both streaming and non-streaming modes for each option to ensure - they don't cause failures. Options marked with needs_validation also - check that the feature actually works correctly. - """ - client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) - # Need at least 2 iterations for tool_choice tests: one to get function call, one to get final response - client.function_invocation_configuration["max_iterations"] = 2 - - # Prepare test message - if option_name == "tools" or option_name == "tool_choice": - # Use weather-related prompt for tool tests - messages = [Message(role="user", text="What is the weather in Seattle?")] - elif option_name == "response_format": - # Use prompt that works well with structured output - messages = [ - Message(role="user", text="The weather in Seattle is sunny"), - Message(role="user", text="What is the weather in Seattle?"), - ] - else: - # Generic prompt for simple options - messages = [Message(role="user", text="Say 'Hello World' briefly.")] - - # Build options dict - options: dict[str, Any] = {option_name: option_value} - - # Add tools if testing tool_choice to avoid errors - if option_name == "tool_choice": - options["tools"] = [get_weather] - - # Test streaming mode - response = await client.get_response(messages=messages, stream=True, options=options).get_final_response() - - assert response is not None - assert isinstance(response, ChatResponse) - assert response.text is not None, f"No text in response for option '{option_name}'" - assert len(response.text) > 0, f"Empty response for option '{option_name}'" - - # Validate based on option type - if needs_validation: - if option_name == "tools" or option_name == "tool_choice": - # Should have called the weather function - text = response.text.lower() - assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}" - elif option_name == "response_format": - if option_value == OutputStruct: - # Should have structured output - assert response.value is not None, "No structured output" - assert isinstance(response.value, OutputStruct) - assert "seattle" in response.value.location.lower() - else: - # Runtime JSON schema - assert response.value is None, "No structured output, can't parse any json." - response_value = json.loads(response.text) - 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_integration_tests_disabled -@_with_azure_openai_debug() -async def test_integration_web_search() -> None: - client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) - response = await client.get_response( - messages=[ - Message( - role="user", - text="What is the current weather? Do not ask for my current location.", - ) - ], - options={ - "tools": [ - AzureOpenAIResponsesClient.get_web_search_tool(user_location={"country": "US", "city": "Seattle"}) - ] - }, - stream=True, - ).get_final_response() - - assert response.text is not None - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_integration_client_file_search() -> None: - """Test Azure responses client with file search tool.""" - azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) - file_id, vector_store = await create_vector_store(azure_responses_client) - try: - # Test that the client will use the file search tool - response = await azure_responses_client.get_response( - messages=[ - Message( - role="user", - text="What is the weather today? Do a file search to find the answer.", - ) - ], - options={ - "tools": [ - AzureOpenAIResponsesClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id]) - ], - "tool_choice": "auto", - }, - ) - - assert "sunny" in response.text.lower() - assert "75" in response.text - finally: - await delete_vector_store(azure_responses_client, file_id, vector_store.vector_store_id) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_integration_client_file_search_streaming() -> None: - """Test Azure responses client with file search tool and streaming.""" - azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) - file_id, vector_store = await create_vector_store(azure_responses_client) - # Test that the client will use the file search tool - try: - response_stream = azure_responses_client.get_response( - messages=[ - Message( - role="user", - text="What is the weather today? Do a file search to find the answer.", - ) - ], - stream=True, - options={ - "tools": [ - AzureOpenAIResponsesClient.get_file_search_tool(vector_store_ids=[vector_store.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(azure_responses_client, file_id, vector_store.vector_store_id) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_integration_client_agent_hosted_mcp_tool() -> None: - """Integration test for MCP tool with Azure Response Agent using Microsoft Learn MCP.""" - client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) - response = await client.get_response( - messages=[Message(role="user", text="How to create an Azure storage account using az cli?")], - options={ - # this needs to be high enough to handle the full MCP tool response. - "max_tokens": 5000, - "tools": AzureOpenAIResponsesClient.get_mcp_tool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - ), - }, - ) - assert isinstance(response, ChatResponse) - # MCP server may return empty response intermittently - skip test rather than fail - if not response.text: - pytest.skip("MCP server returned empty response - service-side issue") - # Should contain Azure-related content since it's asking about Azure CLI - assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"]) - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_azure_integration_tests_disabled -@_with_azure_openai_debug() -async def test_integration_client_agent_hosted_code_interpreter_tool(): - """Test Azure Responses Client agent with code interpreter tool.""" - client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) - - response = await client.get_response( - messages=[ - Message( - role="user", - text="Calculate the sum of numbers from 1 to 10 using Python code.", - ) - ], - options={ - "tools": [AzureOpenAIResponsesClient.get_code_interpreter_tool()], - }, - ) - # Should contain calculation result (sum of 1-10 = 55) or code execution content - 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_integration_tests_disabled -@_with_azure_openai_debug() -async def test_integration_client_agent_existing_session(): - """Test Azure Responses Client agent with existing session to continue conversations across agent instances.""" - # First conversation - capture the session - preserved_session = None - - async with Agent( - client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant with good memory.", - ) as first_agent: - # Start a conversation and capture the session - 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) - assert first_response.text is not None - - # Preserve the session for reuse - preserved_session = session - - # Second conversation - reuse the session in a new agent instance - if preserved_session: - async with Agent( - client=AzureOpenAIResponsesClient(credential=AzureCliCredential()), - instructions="You are a helpful assistant with good memory.", - ) as second_agent: - # Reuse the preserved session - 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_integration_tests_disabled -@_with_azure_openai_debug() -async def test_azure_openai_responses_client_tool_rich_content_image() -> None: - """Test that Azure OpenAI Responses client can handle tool results containing images.""" - 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") - - client = AzureOpenAIResponsesClient(credential=AzureCliCredential()) - client.function_invocation_configuration["max_iterations"] = 2 - - for streaming in [False, True]: - messages = [ - Message( - role="user", - text="Call the get_test_image tool and describe what you see.", - ) - ] - options: dict[str, Any] = {"tools": [get_test_image], "tool_choice": "auto"} - - if streaming: - response = await client.get_response(messages=messages, stream=True, options=options).get_final_response() - else: - response = await client.get_response(messages=messages, options=options) - - assert response is not None - assert isinstance(response, ChatResponse) - assert response.text is not None - assert len(response.text) > 0 - # sample_image.jpg contains a photo of a house; the model should mention it. - assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}" diff --git a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py b/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py deleted file mode 100644 index bbf10e7b88..0000000000 --- a/python/packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py +++ /dev/null @@ -1,131 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import warnings -from unittest.mock import MagicMock - -import pytest -from agent_framework import SupportsChatGetResponse - -warnings.filterwarnings( - "ignore", - message=r"RawAzureAIClient is deprecated\..*", - category=DeprecationWarning, -) - -from agent_framework.azure import AzureOpenAIResponsesClient # noqa: E402 -from azure.identity import AzureCliCredential # noqa: E402 - -pytestmark = pytest.mark.filterwarnings("ignore:AzureOpenAIResponsesClient is deprecated\\..*:DeprecationWarning") - - -def test_init_with_project_client(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test initialization with an existing AIProjectClient.""" - from unittest.mock import patch - - from openai import AsyncOpenAI - - # Create a mock AIProjectClient that returns a mock AsyncOpenAI client - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_openai_client.default_headers = {} - - mock_project_client = MagicMock() - mock_project_client.get_openai_client.return_value = mock_openai_client - - with patch( - "agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project", - return_value=mock_openai_client, - ): - azure_responses_client = AzureOpenAIResponsesClient( - project_client=mock_project_client, - deployment_name="gpt-4o", - ) - - assert azure_responses_client.model == "gpt-4o" - assert azure_responses_client.client is mock_openai_client - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - -def test_init_with_project_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: - """Test initialization with a project endpoint and credential.""" - from unittest.mock import patch - - from openai import AsyncOpenAI - - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_openai_client.default_headers = {} - - with patch( - "agent_framework_azure_ai._deprecated_azure_openai.AzureOpenAIResponsesClient._create_client_from_project", - return_value=mock_openai_client, - ): - azure_responses_client = AzureOpenAIResponsesClient( - project_endpoint="https://test-project.services.ai.azure.com", - deployment_name="gpt-4o", - credential=AzureCliCredential(), - ) - - assert azure_responses_client.model == "gpt-4o" - assert azure_responses_client.client is mock_openai_client - assert isinstance(azure_responses_client, SupportsChatGetResponse) - - -def test_create_client_from_project_with_project_client() -> None: - """Test _create_client_from_project with an existing project client.""" - from openai import AsyncOpenAI - - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_project_client = MagicMock() - mock_project_client.get_openai_client.return_value = mock_openai_client - - result = AzureOpenAIResponsesClient._create_client_from_project( - project_client=mock_project_client, - project_endpoint=None, - credential=None, - ) - - assert result is mock_openai_client - mock_project_client.get_openai_client.assert_called_once() - - -def test_create_client_from_project_with_endpoint() -> None: - """Test _create_client_from_project with a project endpoint.""" - from unittest.mock import patch - - from openai import AsyncOpenAI - - mock_openai_client = MagicMock(spec=AsyncOpenAI) - mock_credential = MagicMock() - - with patch("agent_framework_azure_ai._deprecated_azure_openai.AIProjectClient") as MockAIProjectClient: - mock_instance = MockAIProjectClient.return_value - mock_instance.get_openai_client.return_value = mock_openai_client - - result = AzureOpenAIResponsesClient._create_client_from_project( - project_client=None, - project_endpoint="https://test-project.services.ai.azure.com", - credential=mock_credential, - ) - - assert result is mock_openai_client - MockAIProjectClient.assert_called_once() - mock_instance.get_openai_client.assert_called_once() - - -def test_create_client_from_project_missing_endpoint() -> None: - """Test _create_client_from_project raises error when endpoint is missing.""" - with pytest.raises(ValueError, match="project endpoint is required"): - AzureOpenAIResponsesClient._create_client_from_project( - project_client=None, - project_endpoint=None, - credential=MagicMock(), - ) - - -def test_create_client_from_project_missing_credential() -> None: - """Test _create_client_from_project raises error when credential is missing.""" - with pytest.raises(ValueError, match="credential is required"): - AzureOpenAIResponsesClient._create_client_from_project( - project_client=None, - project_endpoint="https://test-project.services.ai.azure.com", - credential=None, - ) diff --git a/python/packages/azure-ai/tests/test_agent_provider.py b/python/packages/azure-ai/tests/test_agent_provider.py deleted file mode 100644 index 025f882eb6..0000000000 --- a/python/packages/azure-ai/tests/test_agent_provider.py +++ /dev/null @@ -1,773 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import os -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from agent_framework import ( - Agent, - tool, -) -from azure.ai.agents.models import ( - Agent as AzureAgent, -) -from azure.ai.agents.models import ( - CodeInterpreterToolDefinition, -) -from pydantic import BaseModel - -from agent_framework_azure_ai import ( - AzureAIAgentClient, - AzureAIAgentsProvider, - AzureAISettings, -) -from agent_framework_azure_ai._shared import ( - from_azure_ai_agent_tools, - to_azure_ai_agent_tools, -) - -skip_if_azure_ai_integration_tests_disabled = pytest.mark.skipif( - os.getenv("AZURE_AI_PROJECT_ENDPOINT", "") in ("", "https://test-project.cognitiveservices.azure.com/"), - reason="No real AZURE_AI_PROJECT_ENDPOINT provided; skipping integration tests.", -) - -# region Provider Initialization Tests - - -def test_provider_init_with_agents_client(mock_agents_client: MagicMock) -> None: - """Test AzureAIAgentsProvider initialization with existing AgentsClient.""" - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - assert provider._agents_client is mock_agents_client # type: ignore - assert provider._should_close_client is False # type: ignore - - -def test_provider_init_with_credential( - azure_ai_unit_test_env: dict[str, str], - mock_azure_credential: MagicMock, -) -> None: - """Test AzureAIAgentsProvider initialization with credential.""" - with patch("agent_framework_azure_ai._agent_provider.AgentsClient") as mock_client_class: - mock_client_instance = MagicMock() - mock_client_class.return_value = mock_client_instance - - provider = AzureAIAgentsProvider(credential=mock_azure_credential) - - mock_client_class.assert_called_once() - assert provider._agents_client is mock_client_instance # type: ignore - assert provider._should_close_client is True # type: ignore - - -def test_provider_init_with_explicit_endpoint(mock_azure_credential: MagicMock) -> None: - """Test AzureAIAgentsProvider initialization with explicit endpoint.""" - with patch("agent_framework_azure_ai._agent_provider.AgentsClient") as mock_client_class: - mock_client_instance = MagicMock() - mock_client_class.return_value = mock_client_instance - - provider = AzureAIAgentsProvider( - project_endpoint="https://custom-endpoint.com/", - credential=mock_azure_credential, - ) - - mock_client_class.assert_called_once() - call_kwargs = mock_client_class.call_args.kwargs - assert call_kwargs["endpoint"] == "https://custom-endpoint.com/" - assert provider._should_close_client is True # type: ignore - - -def test_provider_init_missing_endpoint_raises( - mock_azure_credential: MagicMock, -) -> None: - """Test AzureAIAgentsProvider raises error when endpoint is missing.""" - # Mock load_settings to return a dict with None for project_endpoint - with patch("agent_framework_azure_ai._agent_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"} - - with pytest.raises(ValueError) as exc_info: - AzureAIAgentsProvider(credential=mock_azure_credential) - - assert "project endpoint is required" in str(exc_info.value).lower() - - -def test_provider_init_missing_credential_raises(azure_ai_unit_test_env: dict[str, str]) -> None: - """Test AzureAIAgentsProvider raises error when credential is missing.""" - with pytest.raises(ValueError) as exc_info: - AzureAIAgentsProvider() - - assert "credential is required" in str(exc_info.value).lower() - - -# endregion - -# region Context Manager Tests - - -async def test_provider_context_manager_closes_client(mock_agents_client: MagicMock) -> None: - """Test that context manager closes client when it was created by provider.""" - with patch("agent_framework_azure_ai._agent_provider.AgentsClient") as mock_client_class: - mock_client_instance = AsyncMock() - mock_client_class.return_value = mock_client_instance - - with patch.object(AzureAIAgentsProvider, "__init__", lambda self: None): # type: ignore - provider = AzureAIAgentsProvider.__new__(AzureAIAgentsProvider) - provider._agents_client = mock_client_instance # type: ignore - provider._should_close_client = True # type: ignore - provider._settings = AzureAISettings(project_endpoint="https://test.com") # type: ignore - - async with provider: - pass - - mock_client_instance.close.assert_called_once() - - -async def test_provider_context_manager_does_not_close_external_client(mock_agents_client: MagicMock) -> None: - """Test that context manager does not close externally provided client.""" - mock_agents_client.close = AsyncMock() - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - async with provider: - pass - - mock_agents_client.close.assert_not_called() - - -# endregion - -# region create_agent Tests - - -async def test_create_agent_basic( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test creating a basic agent.""" - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "test-agent-id" - mock_agent.name = "TestAgent" - mock_agent.description = "A test agent" - mock_agent.instructions = "Be helpful" - mock_agent.model = "gpt-4" - mock_agent.temperature = 0.7 - mock_agent.top_p = 0.9 - mock_agent.tools = [] - mock_agents_client.create_agent = AsyncMock(return_value=mock_agent) - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - agent = await provider.create_agent( - name="TestAgent", - instructions="Be helpful", - description="A test agent", - ) - - assert isinstance(agent, Agent) - assert agent.name == "TestAgent" - assert agent.id == "test-agent-id" - mock_agents_client.create_agent.assert_called_once() - - -async def test_create_agent_with_model( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test creating an agent with explicit model.""" - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "test-agent-id" - mock_agent.name = "TestAgent" - mock_agent.description = None - mock_agent.instructions = None - mock_agent.model = "custom-model" - mock_agent.temperature = None - mock_agent.top_p = None - mock_agent.tools = [] - mock_agents_client.create_agent = AsyncMock(return_value=mock_agent) - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - await provider.create_agent(name="TestAgent", model="custom-model") - - call_kwargs = mock_agents_client.create_agent.call_args.kwargs - assert call_kwargs["model"] == "custom-model" - - -async def test_create_agent_with_tools( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test creating an agent with tools.""" - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "test-agent-id" - mock_agent.name = "TestAgent" - mock_agent.description = None - mock_agent.instructions = None - mock_agent.model = "gpt-4" - mock_agent.temperature = None - mock_agent.top_p = None - mock_agent.tools = [] - mock_agents_client.create_agent = AsyncMock(return_value=mock_agent) - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - @tool(approval_mode="never_require") - def get_weather(city: str) -> str: - """Get weather for a city.""" - return f"Weather in {city}" - - await provider.create_agent(name="TestAgent", tools=get_weather) - - call_kwargs = mock_agents_client.create_agent.call_args.kwargs - assert "tools" in call_kwargs - assert len(call_kwargs["tools"]) > 0 - - -async def test_create_agent_with_response_format( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test creating an agent with structured response format via default_options.""" - - class WeatherResponse(BaseModel): - temperature: float - description: str - - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "test-agent-id" - mock_agent.name = "TestAgent" - mock_agent.description = None - mock_agent.instructions = None - mock_agent.model = "gpt-4" - mock_agent.temperature = None - mock_agent.top_p = None - mock_agent.tools = [] - mock_agents_client.create_agent = AsyncMock(return_value=mock_agent) - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - await provider.create_agent( - name="TestAgent", - default_options={"response_format": WeatherResponse}, - ) - - call_kwargs = mock_agents_client.create_agent.call_args.kwargs - assert "response_format" in call_kwargs - - -async def test_create_agent_missing_model_raises( - mock_agents_client: MagicMock, -) -> None: - """Test that create_agent raises error when model is not specified.""" - # Create provider with mocked settings that has no model - with patch("agent_framework_azure_ai._agent_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = {"project_endpoint": "https://test.com", "model_deployment_name": None} - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - with pytest.raises(ValueError) as exc_info: - await provider.create_agent(name="TestAgent") - - assert "model deployment name is required" in str(exc_info.value).lower() - - -# endregion - -# region get_agent Tests - - -async def test_get_agent_by_id( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test getting an agent by ID.""" - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "existing-agent-id" - mock_agent.name = "ExistingAgent" - mock_agent.description = "An existing agent" - mock_agent.instructions = "Be helpful" - mock_agent.model = "gpt-4" - mock_agent.temperature = 0.7 - mock_agent.top_p = 0.9 - mock_agent.tools = [] - mock_agents_client.get_agent = AsyncMock(return_value=mock_agent) - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - agent = await provider.get_agent("existing-agent-id") - - assert isinstance(agent, Agent) - assert agent.id == "existing-agent-id" - mock_agents_client.get_agent.assert_called_once_with("existing-agent-id") - - -async def test_get_agent_with_function_tools( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test getting an agent that has function tools requires tool implementations.""" - mock_function_tool = MagicMock() - mock_function_tool.type = "function" - mock_function_tool.function = MagicMock() - mock_function_tool.function.name = "get_weather" - - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "agent-with-tools" - mock_agent.name = "AgentWithTools" - mock_agent.description = None - mock_agent.instructions = None - mock_agent.model = "gpt-4" - mock_agent.temperature = None - mock_agent.top_p = None - mock_agent.tools = [mock_function_tool] - mock_agents_client.get_agent = AsyncMock(return_value=mock_agent) - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - with pytest.raises(ValueError) as exc_info: - await provider.get_agent("agent-with-tools") - - assert "get_weather" in str(exc_info.value) - - -async def test_get_agent_with_provided_function_tools( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test getting an agent with function tools when implementations are provided.""" - mock_function_tool = MagicMock() - mock_function_tool.type = "function" - mock_function_tool.function = MagicMock() - mock_function_tool.function.name = "get_weather" - - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "agent-with-tools" - mock_agent.name = "AgentWithTools" - mock_agent.description = None - mock_agent.instructions = None - mock_agent.model = "gpt-4" - mock_agent.temperature = None - mock_agent.top_p = None - mock_agent.tools = [mock_function_tool] - mock_agents_client.get_agent = AsyncMock(return_value=mock_agent) - - @tool(approval_mode="never_require") - def get_weather(city: str) -> str: - """Get weather for a city.""" - return f"Weather in {city}" - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - agent = await provider.get_agent("agent-with-tools", tools=get_weather) - - assert isinstance(agent, Agent) - assert agent.id == "agent-with-tools" - - -# endregion - -# region as_agent Tests - - -def test_as_agent_wraps_without_http( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test as_agent wraps Agent object without making HTTP calls.""" - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "wrap-agent-id" - mock_agent.name = "WrapAgent" - mock_agent.description = "Wrapped agent" - mock_agent.instructions = "Be helpful" - mock_agent.model = "gpt-4" - mock_agent.temperature = 0.5 - mock_agent.top_p = 0.8 - mock_agent.tools = [] - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - agent = provider.as_agent(mock_agent) - - assert isinstance(agent, Agent) - assert agent.id == "wrap-agent-id" - assert agent.name == "WrapAgent" - # Ensure no HTTP calls were made - mock_agents_client.get_agent.assert_not_called() - mock_agents_client.create_agent.assert_not_called() - - -def test_as_agent_with_function_tools_validates( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test as_agent validates that function tool implementations are provided.""" - mock_function_tool = MagicMock() - mock_function_tool.type = "function" - mock_function_tool.function = MagicMock() - mock_function_tool.function.name = "my_function" - - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "agent-id" - mock_agent.name = "Agent" - mock_agent.description = None - mock_agent.instructions = None - mock_agent.model = "gpt-4" - mock_agent.temperature = None - mock_agent.top_p = None - mock_agent.tools = [mock_function_tool] - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - with pytest.raises(ValueError) as exc_info: - provider.as_agent(mock_agent) - - assert "my_function" in str(exc_info.value) - - -def test_as_agent_with_hosted_tools( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test as_agent excludes hosted tools from local tools (they stay on the server agent).""" - mock_code_interpreter = MagicMock() - mock_code_interpreter.type = "code_interpreter" - - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "agent-id" - mock_agent.name = "Agent" - mock_agent.description = None - mock_agent.instructions = None - mock_agent.model = "gpt-4" - mock_agent.temperature = None - mock_agent.top_p = None - mock_agent.tools = [mock_code_interpreter] - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - agent = provider.as_agent(mock_agent) - - assert isinstance(agent, Agent) - # Hosted tools (code_interpreter, file_search, etc.) are already on the server agent - # and should NOT be in local tools to avoid re-sending them at run time - tools = agent.default_options.get("tools") or [] - assert not any(isinstance(t, dict) and t.get("type") == "code_interpreter" for t in tools) - - -def test_as_agent_with_dict_function_tools_validates( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test as_agent validates dict-format function tools require implementations.""" - # Dict-based function tool (as returned by some Azure AI SDK operations) - dict_function_tool = { # type: ignore - "type": "function", - "function": { - "name": "dict_based_function", - "description": "A function defined as dict", - "parameters": {"type": "object", "properties": {}}, - }, - } - - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "agent-id" - mock_agent.name = "Agent" - mock_agent.description = None - mock_agent.instructions = None - mock_agent.model = "gpt-4" - mock_agent.temperature = None - mock_agent.top_p = None - mock_agent.tools = [dict_function_tool] - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - with pytest.raises(ValueError) as exc_info: - provider.as_agent(mock_agent) - - assert "dict_based_function" in str(exc_info.value) - - -def test_as_agent_with_dict_function_tools_provided( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test as_agent succeeds when dict-format function tools have implementations provided.""" - dict_function_tool = { # type: ignore - "type": "function", - "function": { - "name": "dict_based_function", - "description": "A function defined as dict", - "parameters": {"type": "object", "properties": {}}, - }, - } - - mock_agent = MagicMock(spec=AzureAgent) - mock_agent.id = "agent-id" - mock_agent.name = "Agent" - mock_agent.description = None - mock_agent.instructions = None - mock_agent.model = "gpt-4" - mock_agent.temperature = None - mock_agent.top_p = None - mock_agent.tools = [dict_function_tool] - - @tool - def dict_based_function() -> str: - """A function implementation.""" - return "result" - - provider = AzureAIAgentsProvider(agents_client=mock_agents_client) - - agent = provider.as_agent(mock_agent, tools=dict_based_function) - - assert isinstance(agent, Agent) - assert agent.id == "agent-id" - - -# endregion - -# region Tool Conversion Tests - to_azure_ai_agent_tools - - -def test_to_azure_ai_agent_tools_empty() -> None: - """Test converting empty tools list.""" - result = to_azure_ai_agent_tools(None) - assert result == [] - - result = to_azure_ai_agent_tools([]) - assert result == [] - - -def test_to_azure_ai_agent_tools_function() -> None: - """Test converting FunctionTool to Azure tool definition.""" - - @tool(approval_mode="never_require") - def get_weather(city: str) -> str: - """Get weather for a city.""" - return f"Weather in {city}" - - result = to_azure_ai_agent_tools([get_weather]) - - assert len(result) == 1 - assert result[0]["type"] == "function" - assert result[0]["function"]["name"] == "get_weather" - - -def test_to_azure_ai_agent_tools_code_interpreter() -> None: - """Test converting code_interpreter dict tool.""" - tool = AzureAIAgentClient.get_code_interpreter_tool() - - result = to_azure_ai_agent_tools([tool]) - - assert len(result) == 1 - assert isinstance(result[0], CodeInterpreterToolDefinition) - - -def test_to_azure_ai_agent_tools_file_search() -> None: - """Test converting file_search dict tool with vector stores.""" - tool = AzureAIAgentClient.get_file_search_tool(vector_store_ids=["vs-123"]) - run_options: dict[str, Any] = {} - - result = to_azure_ai_agent_tools([tool], run_options) - - assert len(result) == 1 - assert "tool_resources" in run_options - - -def test_to_azure_ai_agent_tools_web_search_bing_grounding(monkeypatch: Any) -> None: - """Test converting web_search dict tool for Bing Grounding.""" - # Use a properly formatted connection ID as required by Azure SDK - valid_conn_id = ( - "/subscriptions/test-sub/resourceGroups/test-rg/" - "providers/Microsoft.CognitiveServices/accounts/test-account/" - "projects/test-project/connections/test-connection" - ) - tool = AzureAIAgentClient.get_web_search_tool(bing_connection_id=valid_conn_id) - - result = to_azure_ai_agent_tools([tool]) - - assert len(result) > 0 - - -def test_to_azure_ai_agent_tools_web_search_custom(monkeypatch: Any) -> None: - """Test converting web_search dict tool for Custom Bing Search.""" - tool = AzureAIAgentClient.get_web_search_tool( - bing_custom_connection_id="custom-conn-id", - bing_custom_instance_id="my-instance", - ) - - result = to_azure_ai_agent_tools([tool]) - - assert len(result) > 0 - - -def test_to_azure_ai_agent_tools_web_search_missing_config(monkeypatch: Any) -> None: - """Test converting web_search dict tool without bing config returns empty.""" - monkeypatch.delenv("BING_CONNECTION_ID", raising=False) - monkeypatch.delenv("BING_CUSTOM_CONNECTION_ID", raising=False) - monkeypatch.delenv("BING_CUSTOM_INSTANCE_NAME", raising=False) - tool = {"type": "web_search"} - - result = to_azure_ai_agent_tools([tool]) - - # web_search without bing connection is passed through as dict - assert len(result) == 1 - - -def test_to_azure_ai_agent_tools_mcp() -> None: - """Test converting MCP dict tool.""" - tool = AzureAIAgentClient.get_mcp_tool( - name="my mcp server", - url="https://mcp.example.com", - ) - - result = to_azure_ai_agent_tools([tool]) - - assert len(result) > 0 - - -def test_to_azure_ai_agent_tools_dict_passthrough() -> None: - """Test that dict tools are passed through.""" - tool = {"type": "custom_tool", "config": {"key": "value"}} - - result = to_azure_ai_agent_tools([tool]) - - assert len(result) == 1 - assert result[0] == tool - - -def test_to_azure_ai_agent_tools_unsupported_type() -> None: - """Test that unsupported tool types pass through unchanged.""" - - class UnsupportedTool: - pass - - unsupported = UnsupportedTool() - result = to_azure_ai_agent_tools([unsupported]) # type: ignore - assert len(result) == 1 - assert result[0] is unsupported # Passed through unchanged - - -# endregion - -# region Tool Conversion Tests - from_azure_ai_agent_tools - - -def test_from_azure_ai_agent_tools_empty() -> None: - """Test converting empty tools list.""" - result = from_azure_ai_agent_tools(None) - assert result == [] - - result = from_azure_ai_agent_tools([]) - assert result == [] - - -def test_from_azure_ai_agent_tools_code_interpreter() -> None: - """Test converting CodeInterpreterToolDefinition.""" - tool = CodeInterpreterToolDefinition() - - result = from_azure_ai_agent_tools([tool]) - - assert len(result) == 1 - assert result[0] == {"type": "code_interpreter"} - - -def test_from_azure_ai_agent_tools_code_interpreter_dict() -> None: - """Test converting code_interpreter dict.""" - tool = {"type": "code_interpreter"} - - result = from_azure_ai_agent_tools([tool]) - - assert len(result) == 1 - assert result[0] == {"type": "code_interpreter"} - - -def test_from_azure_ai_agent_tools_file_search_dict() -> None: - """Test converting file_search dict with vector store IDs.""" - tool = { - "type": "file_search", - "file_search": {"vector_store_ids": ["vs-123", "vs-456"]}, - } - - result = from_azure_ai_agent_tools([tool]) - - assert len(result) == 1 - assert result[0]["type"] == "file_search" - assert result[0]["vector_store_ids"] == ["vs-123", "vs-456"] - - -def test_from_azure_ai_agent_tools_bing_grounding_dict() -> None: - """Test converting bing_grounding dict.""" - tool = { - "type": "bing_grounding", - "bing_grounding": {"connection_id": "conn-123"}, - } - - result = from_azure_ai_agent_tools([tool]) - - assert len(result) == 1 - assert result[0]["type"] == "bing_grounding" - assert result[0]["connection_id"] == "conn-123" - - -def test_from_azure_ai_agent_tools_bing_custom_search_dict() -> None: - """Test converting bing_custom_search dict.""" - tool = { - "type": "bing_custom_search", - "bing_custom_search": { - "connection_id": "custom-conn", - "instance_name": "my-instance", - }, - } - - result = from_azure_ai_agent_tools([tool]) - - assert len(result) == 1 - assert result[0]["type"] == "bing_custom_search" - assert result[0]["connection_id"] == "custom-conn" - assert result[0]["instance_name"] == "my-instance" - - -def test_from_azure_ai_agent_tools_mcp_dict() -> None: - """Test that mcp dict is skipped (hosted on Azure, no local handling needed).""" - tool = { - "type": "mcp", - "mcp": { - "server_label": "my_server", - "server_url": "https://mcp.example.com", - "allowed_tools": ["tool1"], - }, - } - - result = from_azure_ai_agent_tools([tool]) - - # MCP tools are hosted on Azure agent, skipped in conversion - assert len(result) == 0 - - -def test_from_azure_ai_agent_tools_function_dict() -> None: - """Test converting function tool dict (returned as-is).""" - tool: dict[str, Any] = { - "type": "function", - "function": { - "name": "get_weather", - "description": "Get weather", - "parameters": {}, - }, - } - - result = from_azure_ai_agent_tools([tool]) - - assert len(result) == 1 - assert result[0] == tool - - -def test_from_azure_ai_agent_tools_unknown_dict() -> None: - """Test converting unknown tool type dict.""" - tool = {"type": "unknown_tool", "config": "value"} - - result = from_azure_ai_agent_tools([tool]) - - assert len(result) == 1 - assert result[0] == tool - - -# endregion diff --git a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py deleted file mode 100644 index f7560a6443..0000000000 --- a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py +++ /dev/null @@ -1,1941 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import json -from typing import Annotated, Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from agent_framework import ( - ChatOptions, - ChatResponse, - ChatResponseUpdate, - Content, - Message, - SupportsChatGetResponse, - tool, -) -from agent_framework._serialization import SerializationMixin -from agent_framework._settings import load_settings -from agent_framework.exceptions import ChatClientInvalidRequestException -from azure.ai.agents.models import ( - AgentsNamedToolChoice, - AgentsNamedToolChoiceType, - AgentsToolChoiceOptionMode, - CodeInterpreterToolDefinition, - MessageDeltaChunk, - MessageDeltaTextContent, - MessageDeltaTextFileCitationAnnotation, - MessageDeltaTextFilePathAnnotation, - MessageDeltaTextUrlCitationAnnotation, - MessageInputTextBlock, - RequiredFunctionToolCall, - RequiredMcpToolCall, - RunStatus, - SubmitToolApprovalAction, - SubmitToolOutputsAction, - ThreadRun, -) -from azure.core.credentials_async import AsyncTokenCredential -from pydantic import BaseModel, Field - -from agent_framework_azure_ai import AzureAIAgentClient, AzureAISettings - - -def create_test_azure_ai_chat_client( - mock_agents_client: MagicMock, - agent_id: str | None = None, - thread_id: str | None = None, - azure_ai_settings: AzureAISettings | None = None, - should_cleanup_agent: bool = True, - agent_name: str | None = None, -) -> AzureAIAgentClient: - """Helper function to create AzureAIAgentClient instances for testing, bypassing normal validation.""" - if azure_ai_settings is None: - azure_ai_settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_") - - # Create client instance directly - client = object.__new__(AzureAIAgentClient) - - # Set attributes directly - client.agents_client = mock_agents_client - client.credential = None - client.agent_id = agent_id - client.agent_name = agent_name - client.agent_description = None - client.model_id = azure_ai_settings.get("model_deployment_name") - client.thread_id = thread_id - client.should_cleanup_agent = should_cleanup_agent - client._agent_created = False - client._should_close_client = False - client._agent_definition = None - client._azure_search_tool_calls = [] # Add the new instance variable - client.additional_properties = {} - client.middleware = None - client.chat_middleware = [] - client.function_middleware = [] - client._cached_chat_middleware_pipeline = None - client._cached_function_middleware_pipeline = None - client.otel_provider_name = "azure.ai" - client.function_invocation_configuration = { - "enabled": True, - "max_iterations": 5, - "max_consecutive_errors_per_request": 0, - "terminate_on_unknown_calls": False, - "additional_tools": [], - "include_detailed_errors": False, - } - - return client - - -def test_init_emits_updated_deprecation_warning(mock_agents_client: MagicMock) -> None: - """Test that construction emits the updated class deprecation warning.""" - with pytest.deprecated_call(match="V1 Agents Service API and has no direct replacement"): - AzureAIAgentClient( - agents_client=mock_agents_client, - agent_id="test-agent", - ) - - -def test_azure_ai_settings_init(azure_ai_unit_test_env: dict[str, str]) -> None: - """Test AzureAISettings initialization.""" - settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_") - - assert settings["project_endpoint"] == azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"] - assert settings["model_deployment_name"] == azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] - - -def test_azure_ai_settings_init_with_explicit_values() -> None: - """Test AzureAISettings initialization with explicit values.""" - settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - project_endpoint="https://custom-endpoint.com/", - model_deployment_name="custom-model", - ) - - assert settings["project_endpoint"] == "https://custom-endpoint.com/" - assert settings["model_deployment_name"] == "custom-model" - - -def test_azure_ai_chat_client_init_with_client(mock_agents_client: MagicMock) -> None: - """Test AzureAIAgentClient initialization with existing agents_client.""" - client = create_test_azure_ai_chat_client( - mock_agents_client, agent_id="existing-agent-id", thread_id="test-thread-id" - ) - - assert client.agents_client is mock_agents_client - assert client.agent_id == "existing-agent-id" - assert client.thread_id == "test-thread-id" - assert isinstance(client, SupportsChatGetResponse) - - -def test_azure_ai_chat_client_init_auto_create_client( - azure_ai_unit_test_env: dict[str, str], - mock_agents_client: MagicMock, -) -> None: - """Test AzureAIAgentClient initialization with auto-created agents_client.""" - azure_ai_settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_", **azure_ai_unit_test_env) # type: ignore - - # Create client instance directly - chat_client = object.__new__(AzureAIAgentClient) - chat_client.agents_client = mock_agents_client - chat_client.agent_id = None - chat_client.thread_id = None - chat_client._should_close_client = False # type: ignore - chat_client.credential = None - chat_client.model_id = azure_ai_settings.get("model_deployment_name") - chat_client.agent_name = None - chat_client.additional_properties = {} - chat_client.middleware = None - chat_client.chat_middleware = [] - chat_client.function_middleware = [] - chat_client._cached_chat_middleware_pipeline = None - chat_client._cached_function_middleware_pipeline = None - - assert chat_client.agents_client is mock_agents_client - assert chat_client.agent_id is None - - -def test_azure_ai_chat_client_init_missing_project_endpoint() -> None: - """Test AzureAIAgentClient initialization when project_endpoint is missing and no agents_client provided.""" - # Mock AzureAISettings to return settings with None project_endpoint - with patch("agent_framework_azure_ai._chat_client.load_settings") as mock_load_settings: - mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"} - - with pytest.raises(ValueError, match="project endpoint is required"): - AzureAIAgentClient( - agents_client=None, - agent_id=None, - project_endpoint=None, # Missing endpoint - model_deployment_name="test-model", - credential=AsyncMock(spec=AsyncTokenCredential), - ) - - -def test_azure_ai_chat_client_init_missing_model_deployment_for_agent_creation() -> None: - """Test AzureAIAgentClient initialization when model deployment is missing for agent creation.""" - # Mock AzureAISettings to return settings with None model_deployment_name - with patch("agent_framework_azure_ai._chat_client.load_settings") as mock_load_settings: - mock_load_settings.return_value = {"project_endpoint": "https://test.com", "model_deployment_name": None} - - with pytest.raises(ValueError, match="model deployment name is required"): - AzureAIAgentClient( - agents_client=None, - agent_id=None, # No existing agent - project_endpoint="https://test.com", - model_deployment_name=None, # Missing for agent creation - credential=AsyncMock(spec=AsyncTokenCredential), - ) - - -def test_azure_ai_chat_client_init_missing_credential(azure_ai_unit_test_env: dict[str, str]) -> None: - """Test AzureAIAgentClient.__init__ when credential is missing and no agents_client provided.""" - with pytest.raises(ValueError, match="Azure credential is required when agents_client is not provided"): - AzureAIAgentClient( - agents_client=None, - agent_id="existing-agent", - project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=None, # Missing credential - ) - - -def test_azure_ai_chat_client_from_dict() -> None: - """Test from_settings class method.""" - mock_agents_client = MagicMock() - settings = { - "agents_client": mock_agents_client, - "agent_id": "test-agent", - "thread_id": "test-thread", - "project_endpoint": "https://test.com", - "model_deployment_name": "test-model", - "agent_name": "TestAgent", - } - - client = AzureAIAgentClient.from_dict(settings) - - assert client.agents_client is mock_agents_client - assert client.agent_id == "test-agent" - assert client.thread_id == "test-thread" - assert client.agent_name == "TestAgent" - - -async def test_azure_ai_chat_client_get_agent_id_or_create_with_temperature_and_top_p( - mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str] -) -> None: - """Test _get_agent_id_or_create with temperature and top_p in run_options.""" - azure_ai_settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - ) - client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings) - - run_options = { - "model": azure_ai_settings.get("model_deployment_name"), - "temperature": 0.7, - "top_p": 0.9, - } - - agent_id = await client._get_agent_id_or_create(run_options) # type: ignore - - assert agent_id == "test-agent-id" - # Verify create_agent was called with temperature and top_p parameters - mock_agents_client.create_agent.assert_called_once() - call_kwargs = mock_agents_client.create_agent.call_args[1] - assert call_kwargs["temperature"] == 0.7 - assert call_kwargs["top_p"] == 0.9 - - -async def test_azure_ai_chat_client_get_agent_id_or_create_existing_agent( - mock_agents_client: MagicMock, -) -> None: - """Test _get_agent_id_or_create when agent_id is already provided.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="existing-agent-id") - - agent_id = await client._get_agent_id_or_create() # type: ignore - - assert agent_id == "existing-agent-id" - assert not client._agent_created - - -async def test_azure_ai_chat_client_get_agent_id_or_create_create_new( - mock_agents_client: MagicMock, - azure_ai_unit_test_env: dict[str, str], -) -> None: - """Test _get_agent_id_or_create when creating a new agent.""" - azure_ai_settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - ) - chat_client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings) - - agent_id = await chat_client._get_agent_id_or_create( - run_options={"model": azure_ai_settings.get("model_deployment_name")} - ) # type: ignore - - assert agent_id == "test-agent-id" - assert chat_client._agent_created - - -async def test_azure_ai_chat_client_thread_management_through_public_api(mock_agents_client: MagicMock) -> None: - """Test thread creation and management through public API.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock get_agent to avoid the async error - mock_agents_client.get_agent = AsyncMock(return_value=None) - - mock_thread = MagicMock() - mock_thread.id = "new-thread-456" - mock_agents_client.threads.create = AsyncMock(return_value=mock_thread) - - mock_stream = AsyncMock() - mock_agents_client.runs.stream = AsyncMock(return_value=mock_stream) - - # Create an async iterator that yields nothing (empty stream) - async def empty_async_iter(): - return - yield # Make this a generator (unreachable) - - mock_stream.__aenter__ = AsyncMock(return_value=empty_async_iter()) - mock_stream.__aexit__ = AsyncMock(return_value=None) - - messages = [Message(role="user", text="Hello")] - - # Call without existing thread - should create new one - response = client.get_response(messages, stream=True) - # Consume the generator to trigger the method execution - async for _ in response: - pass - - # Verify thread creation was called - mock_agents_client.threads.create.assert_called_once() - - -@pytest.mark.parametrize("exclude_list", [["AZURE_AI_MODEL_DEPLOYMENT_NAME"]], indirect=True) -async def test_azure_ai_chat_client_get_agent_id_or_create_missing_model( - mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str] -) -> None: - """Test _get_agent_id_or_create when model_deployment_name is missing.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - with pytest.raises(ValueError, match="Model deployment name is required"): - await client._get_agent_id_or_create() # type: ignore - - -async def test_azure_ai_chat_client_prepare_options_basic(mock_agents_client: MagicMock) -> None: - """Test _prepare_options with basic ChatOptions.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - messages = [Message(role="user", text="Hello")] - chat_options: ChatOptions = {"max_tokens": 100, "temperature": 0.7} - - run_options, tool_results = await client._prepare_options(messages, chat_options) # type: ignore - - assert run_options is not None - assert tool_results is None - - -async def test_azure_ai_chat_client_prepare_options_no_chat_options(mock_agents_client: MagicMock) -> None: - """Test _prepare_options with default ChatOptions.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - messages = [Message(role="user", text="Hello")] - - run_options, tool_results = await client._prepare_options(messages, {}) # type: ignore - - assert run_options is not None - assert tool_results is None - - -async def test_azure_ai_chat_client_prepare_options_with_image_content(mock_agents_client: MagicMock) -> None: - """Test _prepare_options with image content.""" - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock get_agent - mock_agents_client.get_agent = AsyncMock(return_value=None) - - image_content = Content.from_uri(uri="https://example.com/image.jpg", media_type="image/jpeg") - messages = [Message(role="user", contents=[image_content])] - - run_options, _ = await client._prepare_options(messages, {}) # type: ignore - - assert "additional_messages" in run_options - assert len(run_options["additional_messages"]) == 1 - # Verify image was converted to MessageInputImageUrlBlock - message = run_options["additional_messages"][0] - assert len(message.content) == 1 - - -def test_azure_ai_chat_client_prepare_tool_outputs_for_azure_ai_none(mock_agents_client: MagicMock) -> None: - """Test _prepare_tool_outputs_for_azure_ai with None input.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai(None) # type: ignore - - assert run_id is None - assert tool_outputs is None - assert tool_approvals is None - - -async def test_azure_ai_chat_client_close_client_when_should_close_true(mock_agents_client: MagicMock) -> None: - """Test _close_client_if_needed closes agents_client when should_close_client is True.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - client._should_close_client = True # type: ignore - - mock_agents_client.close = AsyncMock() - - await client._close_client_if_needed() # type: ignore - - mock_agents_client.close.assert_called_once() - - -async def test_azure_ai_chat_client_close_client_when_should_close_false(mock_agents_client: MagicMock) -> None: - """Test _close_client_if_needed does not close agents_client when should_close_client is False.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - client._should_close_client = False # type: ignore - - await client._close_client_if_needed() # type: ignore - - mock_agents_client.close.assert_not_called() - - -def test_azure_ai_chat_client_update_agent_name_and_description_when_current_is_none( - mock_agents_client: MagicMock, -) -> None: - """Test _update_agent_name_and_description updates name when current agent_name is None.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - client.agent_name = None # type: ignore - - client._update_agent_name_and_description("NewAgentName", "description") # type: ignore - - assert client.agent_name == "NewAgentName" - assert client.agent_description == "description" - - -def test_azure_ai_chat_client_update_agent_name_and_description_when_current_exists( - mock_agents_client: MagicMock, -) -> None: - """Test _update_agent_name_and_description does not update when current agent_name exists.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - client.agent_name = "ExistingName" # type: ignore - client.agent_description = "ExistingDescription" # type: ignore - - client._update_agent_name_and_description("NewAgentName", "description") # type: ignore - - assert client.agent_name == "ExistingName" - assert client.agent_description == "ExistingDescription" - - -def test_azure_ai_chat_client_update_agent_name_and_description_with_none_input(mock_agents_client: MagicMock) -> None: - """Test _update_agent_name_and_description with None input.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - client.agent_name = None # type: ignore - client.agent_description = None # type: ignore - - client._update_agent_name_and_description(None, None) # type: ignore - - assert client.agent_name is None - assert client.agent_description is None - - -async def test_azure_ai_chat_client_prepare_options_with_messages(mock_agents_client: MagicMock) -> None: - """Test _prepare_options with different message types.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Test with system message (becomes instruction) - messages = [ - Message(role="system", text="You are a helpful assistant"), - Message(role="user", text="Hello"), - ] - - run_options, _ = await client._prepare_options(messages, {}) # type: ignore - - assert "instructions" in run_options - assert "You are a helpful assistant" in run_options["instructions"] - assert "additional_messages" in run_options - assert len(run_options["additional_messages"]) == 1 # Only user message - - -async def test_azure_ai_chat_client_prepare_options_with_instructions_from_options( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_options includes instructions passed via options. - - This verifies that agent instructions set via as_agent(instructions=...) - are properly included in the API call. - """ - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - mock_agents_client.get_agent = AsyncMock(return_value=None) - - messages = [Message(role="user", text="Hello")] - chat_options: ChatOptions = { - "instructions": "You are a thoughtful reviewer. Give brief feedback.", - } - - run_options, _ = await client._prepare_options(messages, chat_options) # type: ignore - - assert "instructions" in run_options - assert "reviewer" in run_options["instructions"].lower() - - -async def test_azure_ai_chat_client_prepare_options_merges_instructions_from_messages_and_options( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_options merges instructions from both system messages and options. - - When instructions come from both system/developer messages AND from options, - both should be included in the final instructions. - """ - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - mock_agents_client.get_agent = AsyncMock(return_value=None) - - messages = [ - Message(role="system", text="Context: You are reviewing marketing copy."), - Message(role="user", text="Review this tagline"), - ] - chat_options: ChatOptions = { - "instructions": "Be concise and constructive in your feedback.", - } - - run_options, _ = await client._prepare_options(messages, chat_options) # type: ignore - - assert "instructions" in run_options - instructions_text = run_options["instructions"] - # Both instruction sources should be present - assert "marketing" in instructions_text.lower() - assert "concise" in instructions_text.lower() - - -def test_as_agent_uses_client_agent_name_as_default(mock_agents_client: MagicMock) -> None: - """Test that as_agent() defaults Agent.name to client.agent_name when name is not provided.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_name="my_agent") - client.agent_description = "my description" - - agent = client.as_agent(instructions="You are helpful.") - - assert agent.name == "my_agent" - assert agent.description == "my description" - - -def test_as_agent_explicit_name_overrides_client_agent_name(mock_agents_client: MagicMock) -> None: - """Test that an explicit name passed to as_agent() takes precedence over client.agent_name.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_name="client_name") - client.agent_description = "client description" - - agent = client.as_agent(name="explicit_name", description="explicit description", instructions="You are helpful.") - - assert agent.name == "explicit_name" - assert agent.description == "explicit description" - - -def test_as_agent_no_name_anywhere(mock_agents_client: MagicMock) -> None: - """Test that Agent.name is None when neither as_agent name nor client.agent_name is provided.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - agent = client.as_agent(instructions="You are helpful.") - - assert agent.name is None - - -def test_as_agent_empty_string_preserves_explicit_value(mock_agents_client: MagicMock) -> None: - """Test that empty-string name/description are preserved and do not fall back to client defaults.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_name="client_name") - client.agent_description = "client description" - - agent = client.as_agent(name="", description="", instructions="You are helpful.") - - assert agent.name == "" - assert agent.description == "" - - -async def test_azure_ai_chat_client_inner_get_response(mock_agents_client: MagicMock) -> None: - """Test _inner_get_response method.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - async def mock_streaming_response(): - yield ChatResponseUpdate(role="assistant", contents=[Content.from_text("Hello back")]) - - with ( - patch.object(client, "_inner_get_response", return_value=mock_streaming_response()), - patch("agent_framework.ChatResponse.from_update_generator") as mock_from_generator, - ): - mock_response = ChatResponse(messages=[Message(role="assistant", text="Hello back")]) - mock_from_generator.return_value = mock_response - - result = await ChatResponse.from_update_generator(mock_streaming_response()) - - assert result is mock_response - mock_from_generator.assert_called_once() - - -async def test_azure_ai_chat_client_get_agent_id_or_create_with_run_options( - mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str] -) -> None: - """Test _get_agent_id_or_create with run_options containing tools and instructions.""" - azure_ai_settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - ) - client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings) - - run_options = { - "tools": [{"type": "function", "function": {"name": "test_tool"}}], - "instructions": "Test instructions", - "response_format": {"type": "json_object"}, - "model": azure_ai_settings.get("model_deployment_name"), - } - - agent_id = await client._get_agent_id_or_create(run_options) # type: ignore - - assert agent_id == "test-agent-id" - # Verify create_agent was called with run_options parameters - mock_agents_client.create_agent.assert_called_once() - call_args = mock_agents_client.create_agent.call_args[1] - assert "tools" in call_args - assert "instructions" in call_args - assert "response_format" in call_args - - -async def test_azure_ai_chat_client_prepare_thread_cancels_active_run(mock_agents_client: MagicMock) -> None: - """Test _prepare_thread cancels active thread run when provided.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - mock_thread_run = MagicMock() - mock_thread_run.id = "run_123" - mock_thread_run.thread_id = "test-thread" - - run_options = {"additional_messages": []} # type: ignore - - result = await client._prepare_thread("test-thread", mock_thread_run, run_options) # type: ignore - - assert result == "test-thread" - mock_agents_client.runs.cancel.assert_called_once_with("test-thread", "run_123") - - -def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_basic(mock_agents_client: MagicMock) -> None: - """Test _parse_function_calls_from_azure_ai with basic function call.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - mock_tool_call = MagicMock(spec=RequiredFunctionToolCall) - mock_tool_call.id = "call_123" - mock_tool_call.function.name = "get_weather" - mock_tool_call.function.arguments = '{"location": "Seattle"}' - - mock_submit_action = MagicMock(spec=SubmitToolOutputsAction) - mock_submit_action.submit_tool_outputs.tool_calls = [mock_tool_call] - - mock_event_data = MagicMock(spec=ThreadRun) - mock_event_data.required_action = mock_submit_action - - result = client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore - - assert len(result) == 1 - assert result[0].type == "function_call" - assert result[0].name == "get_weather" - assert result[0].call_id == '["response_123", "call_123"]' - - -def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_no_submit_action( - mock_agents_client: MagicMock, -) -> None: - """Test _parse_function_calls_from_azure_ai when required_action is not SubmitToolOutputsAction.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - mock_event_data = MagicMock(spec=ThreadRun) - mock_event_data.required_action = MagicMock() - - result = client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore - - assert result == [] - - -def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_non_function_tool_call( - mock_agents_client: MagicMock, -) -> None: - """Test _parse_function_calls_from_azure_ai with non-function tool call.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - mock_tool_call = MagicMock() - - mock_submit_action = MagicMock(spec=SubmitToolOutputsAction) - mock_submit_action.submit_tool_outputs.tool_calls = [mock_tool_call] - - mock_event_data = MagicMock(spec=ThreadRun) - mock_event_data.required_action = mock_submit_action - - result = client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore - - assert result == [] - - -async def test_azure_ai_chat_client_prepare_options_with_none_tool_choice( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_options with tool_choice set to 'none'.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - chat_options: ChatOptions = {"tool_choice": "none"} - - run_options, _ = await client._prepare_options([], chat_options) # type: ignore - - assert run_options["tool_choice"] == AgentsToolChoiceOptionMode.NONE - - -async def test_azure_ai_chat_client_prepare_options_with_auto_tool_choice( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_options with tool_choice set to 'auto'.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - chat_options = {"tool_choice": "auto"} - - run_options, _ = await client._prepare_options([], chat_options) # type: ignore - - assert run_options["tool_choice"] == AgentsToolChoiceOptionMode.AUTO - - -async def test_azure_ai_chat_client_prepare_options_tool_choice_required_specific_function( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_options with required tool_choice specifying a specific function name.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - required_tool_mode = {"mode": "required", "required_function_name": "specific_function_name"} - - dict_tool = {"type": "function", "function": {"name": "test_function"}} - - chat_options = {"tools": [dict_tool], "tool_choice": required_tool_mode} - messages = [Message(role="user", text="Hello")] - - run_options, _ = await client._prepare_options(messages, chat_options) # type: ignore - - # Verify tool_choice is set to the specific named function - assert "tool_choice" in run_options - tool_choice = run_options["tool_choice"] - assert isinstance(tool_choice, AgentsNamedToolChoice) - assert tool_choice.type == AgentsNamedToolChoiceType.FUNCTION - assert tool_choice.function.name == "specific_function_name" # type: ignore - - -async def test_azure_ai_chat_client_prepare_options_with_response_format( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_options with response_format configured.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - class TestResponseModel(BaseModel): - name: str = Field(description="Test name") - - chat_options: ChatOptions = {"response_format": TestResponseModel} - - run_options, _ = await client._prepare_options([], chat_options) # type: ignore - - assert "response_format" in run_options - response_format = run_options["response_format"] - assert response_format.json_schema.name == "TestResponseModel" - - -def test_azure_ai_chat_client_service_url_method(mock_agents_client: MagicMock) -> None: - """Test service_url method returns endpoint.""" - mock_agents_client._config.endpoint = "https://test-endpoint.com/" - client = create_test_azure_ai_chat_client(mock_agents_client) - - url = client.service_url() - assert url == "https://test-endpoint.com/" - - -async def test_azure_ai_chat_client_prepare_options_mcp_never_require(mock_agents_client: MagicMock) -> None: - """Test _prepare_options with MCP dict tool having never_require approval mode.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Create MCP tool with approval_mode parameter - mcp_tool = AzureAIAgentClient.get_mcp_tool( - name="Test MCP Tool", url="https://example.com/mcp", approval_mode="never_require" - ) - - messages = [Message(role="user", text="Hello")] - chat_options: ChatOptions = {"tools": [mcp_tool], "tool_choice": "auto"} - - run_options, _ = await client._prepare_options(messages, chat_options) # type: ignore - - # Verify tool_resources is created with correct MCP approval structure - assert "tool_resources" in run_options, f"Expected 'tool_resources' in run_options keys: {list(run_options.keys())}" - assert "mcp" in run_options["tool_resources"] - assert len(run_options["tool_resources"]["mcp"]) == 1 - - mcp_resource = run_options["tool_resources"]["mcp"][0] - assert mcp_resource["server_label"] == "Test_MCP_Tool" - assert mcp_resource["require_approval"] == "never" - - -async def test_azure_ai_chat_client_prepare_options_mcp_with_headers(mock_agents_client: MagicMock) -> None: - """Test _prepare_options with MCP dict tool having headers.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Test with headers - create MCP tool with all options - headers = {"Authorization": "Bearer DUMMY_TOKEN", "X-API-Key": "DUMMY_KEY"} - mcp_tool = AzureAIAgentClient.get_mcp_tool( - name="Test MCP Tool", - url="https://example.com/mcp", - headers=headers, - approval_mode="never_require", - ) - - messages = [Message(role="user", text="Hello")] - chat_options: ChatOptions = {"tools": [mcp_tool], "tool_choice": "auto"} - - run_options, _ = await client._prepare_options(messages, chat_options) # type: ignore - - # Verify tool_resources is created with headers - assert "tool_resources" in run_options - assert "mcp" in run_options["tool_resources"] - assert len(run_options["tool_resources"]["mcp"]) == 1 - - mcp_resource = run_options["tool_resources"]["mcp"][0] - assert mcp_resource["server_label"] == "Test_MCP_Tool" - assert mcp_resource["require_approval"] == "never" - assert mcp_resource["headers"] == headers - - -async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_web_search_bing_grounding( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tools_for_azure_ai with BingGroundingTool from get_web_search_tool().""" - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock BingGroundingTool to avoid SDK validation of connection ID - with patch("agent_framework_azure_ai._chat_client.BingGroundingTool") as mock_bing_grounding: - mock_bing_tool = MagicMock() - mock_bing_tool.definitions = [{"type": "bing_grounding"}] - mock_bing_grounding.return_value = mock_bing_tool - - # get_web_search_tool now returns a BingGroundingTool directly - web_search_tool = client.get_web_search_tool(bing_connection_id="test-connection-id") - - # Verify the factory method created the tool with correct args - mock_bing_grounding.assert_called_once_with(connection_id="test-connection-id") - - result = await client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore - - # BingGroundingTool.definitions should be extended into result - assert len(result) == 1 - assert result[0] == {"type": "bing_grounding"} - - -async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_web_search_bing_grounding_with_connection_id( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tools_for_azure_ai with BingGroundingTool using explicit connection_id.""" - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock BingGroundingTool to avoid SDK validation of connection ID - with patch("agent_framework_azure_ai._chat_client.BingGroundingTool") as mock_bing_grounding: - mock_bing_tool = MagicMock() - mock_bing_tool.definitions = [{"type": "bing_grounding"}] - mock_bing_grounding.return_value = mock_bing_tool - - web_search_tool = client.get_web_search_tool(bing_connection_id="direct-connection-id") - - mock_bing_grounding.assert_called_once_with(connection_id="direct-connection-id") - - result = await client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore - - assert len(result) == 1 - assert result[0] == {"type": "bing_grounding"} - - -async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_web_search_custom_bing( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tools_for_azure_ai with BingCustomSearchTool from get_web_search_tool().""" - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock BingCustomSearchTool to avoid SDK validation - with patch("agent_framework_azure_ai._chat_client.BingCustomSearchTool") as mock_custom_bing: - mock_custom_tool = MagicMock() - mock_custom_tool.definitions = [{"type": "bing_custom_search"}] - mock_custom_bing.return_value = mock_custom_tool - - web_search_tool = client.get_web_search_tool( - bing_custom_connection_id="custom-connection-id", - bing_custom_instance_id="custom-instance", - ) - - mock_custom_bing.assert_called_once_with( - connection_id="custom-connection-id", - instance_name="custom-instance", - ) - - result = await client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore - - assert len(result) == 1 - assert result[0] == {"type": "bing_custom_search"} - - -async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_file_search_with_vector_stores( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tools_for_azure_ai with FileSearchTool from get_file_search_tool().""" - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # get_file_search_tool() now returns a FileSearchTool instance directly - file_search_tool = client.get_file_search_tool(vector_store_ids=["vs-123"]) - - run_options: dict[str, Any] = {} - result = await client._prepare_tools_for_azure_ai([file_search_tool], run_options) # type: ignore - - assert len(result) == 1 - assert result[0] == {"type": "file_search"} - assert run_options["tool_resources"] == {"file_search": {"vector_store_ids": ["vs-123"]}} - - -async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_code_interpreter_with_file_ids( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tools_for_azure_ai with CodeInterpreterTool with file_ids from get_code_interpreter_tool().""" - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - code_interpreter_tool = client.get_code_interpreter_tool(file_ids=["file-123", "file-456"]) - - run_options: dict[str, Any] = {} - result = await client._prepare_tools_for_azure_ai([code_interpreter_tool], run_options) # type: ignore - - assert len(result) == 1 - assert result[0] == {"type": "code_interpreter"} - assert "tool_resources" in run_options - assert "code_interpreter" in run_options["tool_resources"] - assert sorted(run_options["tool_resources"]["code_interpreter"]["file_ids"]) == ["file-123", "file-456"] - - -async def test_azure_ai_chat_client_get_code_interpreter_tool_basic() -> None: - """Test get_code_interpreter_tool returns CodeInterpreterTool without files.""" - from azure.ai.agents.models import CodeInterpreterTool - - tool = AzureAIAgentClient.get_code_interpreter_tool() - assert isinstance(tool, CodeInterpreterTool) - assert len(tool.file_ids) == 0 - - -async def test_azure_ai_chat_client_get_code_interpreter_tool_with_file_ids() -> None: - """Test get_code_interpreter_tool forwards file_ids to the SDK.""" - from azure.ai.agents.models import CodeInterpreterTool - - tool = AzureAIAgentClient.get_code_interpreter_tool(file_ids=["file-abc", "file-def"]) - assert isinstance(tool, CodeInterpreterTool) - assert "file-abc" in tool.file_ids - assert "file-def" in tool.file_ids - - -async def test_azure_ai_chat_client_get_code_interpreter_tool_with_data_sources() -> None: - """Test get_code_interpreter_tool forwards data_sources to the SDK.""" - from azure.ai.agents.models import CodeInterpreterTool, VectorStoreDataSource - - ds = VectorStoreDataSource(asset_identifier="test-asset-id", asset_type="id_asset") - tool = AzureAIAgentClient.get_code_interpreter_tool(data_sources=[ds]) - assert isinstance(tool, CodeInterpreterTool) - assert "test-asset-id" in tool.data_sources - - -async def test_azure_ai_chat_client_get_code_interpreter_tool_mutually_exclusive() -> None: - """Test get_code_interpreter_tool raises ValueError when both file_ids and data_sources are provided.""" - from azure.ai.agents.models import VectorStoreDataSource - - ds = VectorStoreDataSource(asset_identifier="test-asset-id", asset_type="id_asset") - with pytest.raises(ValueError, match="mutually exclusive"): - AzureAIAgentClient.get_code_interpreter_tool(file_ids=["file-abc"], data_sources=[ds]) - - -async def test_azure_ai_chat_client_get_code_interpreter_tool_with_content() -> None: - """Test get_code_interpreter_tool accepts Content.from_hosted_file in file_ids.""" - from agent_framework import Content - from azure.ai.agents.models import CodeInterpreterTool - - content = Content.from_hosted_file("file-content-123") - tool = AzureAIAgentClient.get_code_interpreter_tool(file_ids=[content]) - assert isinstance(tool, CodeInterpreterTool) - assert "file-content-123" in tool.file_ids - - -async def test_azure_ai_chat_client_get_code_interpreter_tool_with_mixed_file_ids() -> None: - """Test get_code_interpreter_tool accepts a mix of strings and Content objects.""" - from agent_framework import Content - from azure.ai.agents.models import CodeInterpreterTool - - content = Content.from_hosted_file("file-from-content") - tool = AzureAIAgentClient.get_code_interpreter_tool(file_ids=["file-plain", content]) - assert isinstance(tool, CodeInterpreterTool) - assert "file-plain" in tool.file_ids - assert "file-from-content" in tool.file_ids - - -async def test_azure_ai_chat_client_get_code_interpreter_tool_content_unsupported_type() -> None: - """Test get_code_interpreter_tool raises ValueError for unsupported Content types.""" - from agent_framework import Content - - content = Content.from_hosted_vector_store("vs-123") - with pytest.raises(ValueError, match="Unsupported Content type"): - AzureAIAgentClient.get_code_interpreter_tool(file_ids=[content]) - - -async def test_azure_ai_chat_client_get_code_interpreter_tool_content_missing_file_id() -> None: - """Test get_code_interpreter_tool raises ValueError when Content.file_id is None.""" - from agent_framework import Content - - content = Content(type="hosted_file") - with pytest.raises(ValueError, match="missing a file_id"): - AzureAIAgentClient.get_code_interpreter_tool(file_ids=[content]) - - -async def test_azure_ai_chat_client_get_code_interpreter_tool_empty_string_file_id() -> None: - """Test get_code_interpreter_tool raises ValueError for empty string file_ids.""" - with pytest.raises(ValueError, match="must not contain empty strings"): - AzureAIAgentClient.get_code_interpreter_tool(file_ids=[""]) - - -async def test_azure_ai_chat_client_create_agent_stream_submit_tool_approvals( - mock_agents_client: MagicMock, -) -> None: - """Test _create_agent_stream with tool approvals submission path.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock active thread run that matches the tool run ID - mock_thread_run = MagicMock() - mock_thread_run.thread_id = "test-thread" - mock_thread_run.id = "test-run-id" - client._get_active_thread_run = AsyncMock(return_value=mock_thread_run) # type: ignore - - # Mock required action results with approval response that matches run ID - approval_response = Content.from_function_approval_response( - id='["test-run-id", "test-call-id"]', - function_call=Content.from_function_call( - call_id='["test-run-id", "test-call-id"]', name="test_function", arguments="{}" - ), - approved=True, - ) - - # Mock submit_tool_outputs_stream - mock_handler = MagicMock() - mock_agents_client.runs.submit_tool_outputs_stream = AsyncMock() - - with patch("azure.ai.agents.models.AsyncAgentEventHandler", return_value=mock_handler): - stream, final_thread_id = await client._create_agent_stream( # type: ignore - "test-agent", {"thread_id": "test-thread"}, [approval_response] - ) - - # Verify the approvals path was taken - assert final_thread_id == "test-thread" - - # Verify submit_tool_outputs_stream was called with approvals - mock_agents_client.runs.submit_tool_outputs_stream.assert_called_once() - call_args = mock_agents_client.runs.submit_tool_outputs_stream.call_args[1] - assert "tool_approvals" in call_args - assert call_args["tool_approvals"][0].tool_call_id == "test-call-id" - assert call_args["tool_approvals"][0].approve is True - - -async def test_azure_ai_chat_client_get_active_thread_run_with_active_run(mock_agents_client: MagicMock) -> None: - """Test _get_active_thread_run when there's an active run.""" - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock an active run - mock_run = MagicMock() - mock_run.status = RunStatus.IN_PROGRESS - - async def mock_list_runs(*args, **kwargs): # type: ignore - yield mock_run - - mock_agents_client.runs.list = mock_list_runs - - result = await client._get_active_thread_run("thread-123") # type: ignore - - assert result == mock_run - - -async def test_azure_ai_chat_client_get_active_thread_run_no_active_run(mock_agents_client: MagicMock) -> None: - """Test _get_active_thread_run when there's no active run.""" - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock a completed run (not active) - mock_run = MagicMock() - mock_run.status = RunStatus.COMPLETED - - async def mock_list_runs(*args, **kwargs): # type: ignore - yield mock_run - - mock_agents_client.runs.list = mock_list_runs - - result = await client._get_active_thread_run("thread-123") # type: ignore - - assert result is None - - -async def test_azure_ai_chat_client_get_active_thread_run_no_thread(mock_agents_client: MagicMock) -> None: - """Test _get_active_thread_run with None thread_id.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - result = await client._get_active_thread_run(None) # type: ignore - - assert result is None - # Should not call list since thread_id is None - mock_agents_client.runs.list.assert_not_called() - - -async def test_azure_ai_chat_client_service_url(mock_agents_client: MagicMock) -> None: - """Test service_url method.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock the config endpoint - mock_config = MagicMock() - mock_config.endpoint = "https://test-endpoint.com/" - mock_agents_client._config = mock_config - - result = client.service_url() - - assert result == "https://test-endpoint.com/" - - -async def test_azure_ai_chat_client_prepare_tool_outputs_for_azure_tool_result( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tool_outputs_for_azure_ai with FunctionResultContent.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Test with simple result - function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result="Simple result") - - run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore - - assert run_id == "run_123" - assert tool_approvals is None - assert tool_outputs is not None - assert len(tool_outputs) == 1 - assert tool_outputs[0].tool_call_id == "call_456" - assert tool_outputs[0].output == "Simple result" - - -async def test_azure_ai_chat_client_convert_required_action_invalid_call_id(mock_agents_client: MagicMock) -> None: - """Test _prepare_tool_outputs_for_azure_ai with invalid call_id format.""" - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Invalid call_id format - should raise JSONDecodeError - function_result = Content.from_function_result(call_id="invalid_json", result="result") - - with pytest.raises(json.JSONDecodeError): - client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore - - -async def test_azure_ai_chat_client_convert_required_action_invalid_structure( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tool_outputs_for_azure_ai with invalid call_id structure.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Valid JSON but invalid structure (missing second element) - function_result = Content.from_function_result(call_id='["run_123"]', result="result") - - run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore - - # Should return None values when structure is invalid - assert run_id is None - assert tool_outputs is None - assert tool_approvals is None - - -async def test_azure_ai_chat_client_convert_required_action_serde_model_results( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tool_outputs_for_azure_ai with BaseModel results.""" - - class MockResult(SerializationMixin): - def __init__(self, name: str, value: int): - self.name = name - self.value = value - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Test with BaseModel result (pre-parsed as it would be from FunctionTool.invoke) - mock_result = MockResult(name="test", value=42) - expected_json = mock_result.to_json() - function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=expected_json) - - run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore - - assert run_id == "run_123" - assert tool_approvals is None - assert tool_outputs is not None - assert len(tool_outputs) == 1 - assert tool_outputs[0].tool_call_id == "call_456" - # Should use pre-parsed result string directly - assert tool_outputs[0].output == expected_json - - -async def test_azure_ai_chat_client_convert_required_action_multiple_results( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tool_outputs_for_azure_ai with multiple results.""" - - class MockResult(SerializationMixin): - def __init__(self, data: str): - self.data = data - - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Test with multiple results - pre-parsed as FunctionTool.invoke would produce - mock_basemodel = MockResult(data="model_data") - results_list = [mock_basemodel, {"key": "value"}, "string_result"] - # FunctionTool.parse_result would serialize this to a JSON string - from agent_framework import FunctionTool - - pre_parsed = FunctionTool.parse_result(results_list) - function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=pre_parsed) - - run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore - - assert run_id == "run_123" - assert tool_outputs is not None - assert len(tool_outputs) == 1 - assert tool_outputs[0].tool_call_id == "call_456" - - # Result is the text content extracted from items - assert tool_outputs[0].output == function_result.result - - -async def test_azure_ai_chat_client_convert_required_action_approval_response( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tool_outputs_for_azure_ai with FunctionApprovalResponseContent.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Test with approval response - need to provide required fields - approval_response = Content.from_function_approval_response( - id='["run_123", "call_456"]', - function_call=Content.from_function_call( - call_id='["run_123", "call_456"]', name="test_function", arguments="{}" - ), - approved=True, - ) - - run_id, tool_outputs, tool_approvals = client._prepare_tool_outputs_for_azure_ai([approval_response]) # type: ignore - - assert run_id == "run_123" - assert tool_outputs is None - assert tool_approvals is not None - assert len(tool_approvals) == 1 - assert tool_approvals[0].tool_call_id == "call_456" - assert tool_approvals[0].approve is True - - -async def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_approval_request( - mock_agents_client: MagicMock, -) -> None: - """Test _parse_function_calls_from_azure_ai with approval action.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock SubmitToolApprovalAction with RequiredMcpToolCall - mock_tool_call = MagicMock(spec=RequiredMcpToolCall) - mock_tool_call.id = "approval_call_123" - mock_tool_call.name = "approve_action" - mock_tool_call.arguments = '{"action": "approve"}' - - mock_approval_action = MagicMock(spec=SubmitToolApprovalAction) - mock_approval_action.submit_tool_approval.tool_calls = [mock_tool_call] - - mock_event_data = MagicMock(spec=ThreadRun) - mock_event_data.required_action = mock_approval_action - - result = client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore - - assert len(result) == 1 - assert result[0].type == "function_approval_request" - assert result[0].id == '["response_123", "approval_call_123"]' - assert result[0].function_call.name == "approve_action" - assert result[0].function_call.call_id == '["response_123", "approval_call_123"]' - - -async def test_azure_ai_chat_client_get_agent_id_or_create_with_agent_name( - mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str] -) -> None: - """Test _get_agent_id_or_create uses default name when no agent_name set.""" - azure_ai_settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - ) - client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings) - - # Ensure agent_name is None to test the default - client.agent_name = None # type: ignore - - agent_id = await client._get_agent_id_or_create( - run_options={"model": azure_ai_settings.get("model_deployment_name")} - ) # type: ignore - - assert agent_id == "test-agent-id" - # Verify create_agent was called with default "UnnamedAgent" - mock_agents_client.create_agent.assert_called_once() - call_kwargs = mock_agents_client.create_agent.call_args[1] - assert call_kwargs["name"] == "UnnamedAgent" - - -async def test_azure_ai_chat_client_get_agent_id_or_create_with_response_format( - mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str] -) -> None: - """Test _get_agent_id_or_create with response_format in run_options.""" - azure_ai_settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - ) - client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings) - - # Test with response_format in run_options - run_options = {"response_format": {"type": "json_object"}, "model": azure_ai_settings.get("model_deployment_name")} - - agent_id = await client._get_agent_id_or_create(run_options) # type: ignore - - assert agent_id == "test-agent-id" - # Verify create_agent was called with response_format - mock_agents_client.create_agent.assert_called_once() - call_kwargs = mock_agents_client.create_agent.call_args[1] - assert call_kwargs["response_format"] == {"type": "json_object"} - - -async def test_azure_ai_chat_client_get_agent_id_or_create_with_tool_resources( - mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str] -) -> None: - """Test _get_agent_id_or_create with tool_resources in run_options.""" - azure_ai_settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - ) - client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings) - - # Test with tool_resources in run_options - run_options = { - "tool_resources": {"vector_store_ids": ["vs-123"]}, - "model": azure_ai_settings.get("model_deployment_name"), - } - - agent_id = await client._get_agent_id_or_create(run_options) # type: ignore - - assert agent_id == "test-agent-id" - # Verify create_agent was called with tool_resources - mock_agents_client.create_agent.assert_called_once() - call_kwargs = mock_agents_client.create_agent.call_args[1] - assert call_kwargs["tool_resources"] == {"vector_store_ids": ["vs-123"]} - - -async def test_azure_ai_chat_client_create_agent_stream_submit_tool_outputs( - mock_agents_client: MagicMock, -) -> None: - """Test _create_agent_stream with tool outputs submission path.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Mock active thread run that matches the tool run ID - mock_thread_run = MagicMock() - mock_thread_run.thread_id = "test-thread" - mock_thread_run.id = "test-run-id" - client._get_active_thread_run = AsyncMock(return_value=mock_thread_run) # type: ignore - - # Mock required action results with matching run ID - function_result = Content.from_function_result(call_id='["test-run-id", "test-call-id"]', result="test result") - - # Mock submit_tool_outputs_stream - mock_handler = MagicMock() - mock_agents_client.runs.submit_tool_outputs_stream = AsyncMock() - - with patch("azure.ai.agents.models.AsyncAgentEventHandler", return_value=mock_handler): - stream, final_thread_id = await client._create_agent_stream( # type: ignore - agent_id="test-agent", run_options={"thread_id": "test-thread"}, required_action_results=[function_result] - ) - - # Should call submit_tool_outputs_stream since we have matching run ID - mock_agents_client.runs.submit_tool_outputs_stream.assert_called_once() - assert final_thread_id == "test-thread" - - -def test_azure_ai_chat_client_extract_url_citations_with_citations(mock_agents_client: MagicMock) -> None: - """Test _extract_url_citations with MessageDeltaChunk containing URL citations.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Create mock URL citation annotation - mock_url_citation = MagicMock() - mock_url_citation.url = "https://example.com/test" - mock_url_citation.title = "Test Title" - - mock_annotation = MagicMock(spec=MessageDeltaTextUrlCitationAnnotation) - mock_annotation.url_citation = mock_url_citation - mock_annotation.start_index = 10 - mock_annotation.end_index = 20 - - # Create mock text content with annotations - mock_text = MagicMock() - mock_text.annotations = [mock_annotation] - - mock_text_content = MagicMock(spec=MessageDeltaTextContent) - mock_text_content.text = mock_text - - # Create mock delta - mock_delta = MagicMock() - mock_delta.content = [mock_text_content] - - # Create mock MessageDeltaChunk - mock_chunk = MagicMock(spec=MessageDeltaChunk) - mock_chunk.delta = mock_delta - - # Call the method with empty azure_search_tool_calls - citations = client._extract_url_citations(mock_chunk, []) # type: ignore - - # Verify results - assert len(citations) == 1 - citation = citations[0] - assert citation["url"] == "https://example.com/test" - assert citation["title"] == "Test Title" - assert citation["snippet"] is None - assert citation["annotated_regions"] is not None - assert len(citation["annotated_regions"]) == 1 - assert citation["annotated_regions"][0]["start_index"] == 10 - assert citation["annotated_regions"][0]["end_index"] == 20 - - -def test_azure_ai_chat_client_extract_file_path_contents_with_file_path_annotation( - mock_agents_client: MagicMock, -) -> None: - """Test _extract_file_path_contents with MessageDeltaChunk containing file path annotation.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Create mock file_path annotation - mock_file_path = MagicMock() - mock_file_path.file_id = "assistant-test-file-123" - - mock_annotation = MagicMock(spec=MessageDeltaTextFilePathAnnotation) - mock_annotation.file_path = mock_file_path - - # Create mock text content with annotations - mock_text = MagicMock() - mock_text.annotations = [mock_annotation] - - mock_text_content = MagicMock(spec=MessageDeltaTextContent) - mock_text_content.text = mock_text - - # Create mock delta - mock_delta = MagicMock() - mock_delta.content = [mock_text_content] - - # Create mock MessageDeltaChunk - mock_chunk = MagicMock(spec=MessageDeltaChunk) - mock_chunk.delta = mock_delta - - # Call the method - file_contents = client._extract_file_path_contents(mock_chunk) - - # Verify results - assert len(file_contents) == 1 - assert file_contents[0].type == "hosted_file" - assert file_contents[0].file_id == "assistant-test-file-123" - - -def test_azure_ai_chat_client_extract_file_path_contents_with_file_citation_annotation( - mock_agents_client: MagicMock, -) -> None: - """Test _extract_file_path_contents with MessageDeltaChunk containing file citation annotation.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Create mock file_citation annotation - mock_file_citation = MagicMock() - mock_file_citation.file_id = "cfile_test-citation-456" - - mock_annotation = MagicMock(spec=MessageDeltaTextFileCitationAnnotation) - mock_annotation.file_citation = mock_file_citation - - # Create mock text content with annotations - mock_text = MagicMock() - mock_text.annotations = [mock_annotation] - - mock_text_content = MagicMock(spec=MessageDeltaTextContent) - mock_text_content.text = mock_text - - # Create mock delta - mock_delta = MagicMock() - mock_delta.content = [mock_text_content] - - # Create mock MessageDeltaChunk - mock_chunk = MagicMock(spec=MessageDeltaChunk) - mock_chunk.delta = mock_delta - - # Call the method - file_contents = client._extract_file_path_contents(mock_chunk) - - # Verify results - assert len(file_contents) == 1 - assert file_contents[0].type == "hosted_file" - assert file_contents[0].file_id == "cfile_test-citation-456" - - -def test_azure_ai_chat_client_extract_file_path_contents_empty_annotations( - mock_agents_client: MagicMock, -) -> None: - """Test _extract_file_path_contents with no annotations returns empty list.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Create mock text content with no annotations - mock_text = MagicMock() - mock_text.annotations = [] - - mock_text_content = MagicMock(spec=MessageDeltaTextContent) - mock_text_content.text = mock_text - - # Create mock delta - mock_delta = MagicMock() - mock_delta.content = [mock_text_content] - - # Create mock MessageDeltaChunk - mock_chunk = MagicMock(spec=MessageDeltaChunk) - mock_chunk.delta = mock_delta - - # Call the method - file_contents = client._extract_file_path_contents(mock_chunk) - - # Verify results - assert len(file_contents) == 0 - - -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - return f"The weather in {location} is sunny with a high of 25°C." - - -async def test_azure_ai_chat_client_cleanup_agent_when_enabled_and_created( - mock_agents_client: MagicMock, -) -> None: - """Test that agent is cleaned up when should_cleanup_agent=True and agent was created by client.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id=None, should_cleanup_agent=True) - - # Simulate agent creation - client.agent_id = "created-agent-id" - client._agent_created = True # type: ignore - - await client._cleanup_agent_if_needed() # type: ignore - - # Verify agent was deleted - mock_agents_client.delete_agent.assert_called_once_with("created-agent-id") - assert client.agent_id is None - assert client._agent_created is False # type: ignore - - -async def test_azure_ai_chat_client_no_cleanup_when_disabled( - mock_agents_client: MagicMock, -) -> None: - """Test that agent is not cleaned up when should_cleanup_agent=False.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id=None, should_cleanup_agent=False) - - # Simulate agent creation - client.agent_id = "created-agent-id" - client._agent_created = True - - await client._cleanup_agent_if_needed() # type: ignore - - # Verify agent was NOT deleted - mock_agents_client.delete_agent.assert_not_called() - assert client.agent_id == "created-agent-id" - assert client._agent_created is True - - -async def test_azure_ai_chat_client_no_cleanup_when_agent_not_created_by_client( - mock_agents_client: MagicMock, -) -> None: - """Test that agent is not cleaned up when it was not created by this client instance.""" - client = create_test_azure_ai_chat_client( - mock_agents_client, agent_id="existing-agent-id", should_cleanup_agent=True - ) - - # Agent exists but was not created by this client (_agent_created = False) - assert client._agent_created is False # type: ignore - - await client._cleanup_agent_if_needed() # type: ignore - - # Verify agent was NOT deleted - mock_agents_client.delete_agent.assert_not_called() - assert client.agent_id == "existing-agent-id" - - -def test_azure_ai_chat_client_capture_azure_search_tool_calls(mock_agents_client: MagicMock) -> None: - """Test _capture_azure_search_tool_calls method.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Mock Azure AI Search tool call - mock_tool_call = MagicMock() - mock_tool_call.type = "azure_ai_search" - mock_tool_call.id = "call_123" - mock_tool_call.azure_ai_search = {"input": "test query", "output": "test output"} - - # Mock step data - mock_step_data = MagicMock() - mock_step_data.step_details.tool_calls = [mock_tool_call] - - # Call the method with a list to capture tool calls - azure_search_tool_calls: list[dict[str, Any]] = [] - client._capture_azure_search_tool_calls(mock_step_data, azure_search_tool_calls) # type: ignore - - # Verify tool call was captured - assert len(azure_search_tool_calls) == 1 - captured_tool_call = azure_search_tool_calls[0] - assert captured_tool_call["type"] == "azure_ai_search" - assert captured_tool_call["id"] == "call_123" - assert captured_tool_call["azure_ai_search"] == {"input": "test query", "output": "test output"} - - -def test_azure_ai_chat_client_get_real_url_from_citation_reference_no_tool_calls( - mock_agents_client: MagicMock, -) -> None: - """Test _get_real_url_from_citation_reference with no tool calls.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # No tool calls - pass empty list - result = client._get_real_url_from_citation_reference("doc_1", []) # type: ignore - assert result == "doc_1" - - -def test_azure_ai_chat_client_get_real_url_from_citation_reference_invalid_output( - mock_agents_client: MagicMock, -) -> None: - """Test _get_real_url_from_citation_reference with invalid output format.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Tool call with invalid output format - azure_search_tool_calls = [ - {"id": "call_123", "type": "azure_ai_search", "azure_ai_search": {"output": "invalid_json_format"}} - ] - - result = client._get_real_url_from_citation_reference("doc_1", azure_search_tool_calls) # type: ignore - assert result == "doc_1" - - -async def test_azure_ai_chat_client_context_manager(mock_agents_client: MagicMock) -> None: - """Test AzureAIAgentClient as async context manager.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Mock close method to avoid actual cleanup - client.close = AsyncMock() - - async with client as client: - assert client is client - - # Verify close was called on exit - client.close.assert_called_once() - - -async def test_azure_ai_chat_client_close_method(mock_agents_client: MagicMock) -> None: - """Test AzureAIAgentClient close method.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Mock cleanup methods - client._cleanup_agent_if_needed = AsyncMock() - client._close_client_if_needed = AsyncMock() - - await client.close() - - # Verify cleanup methods were called - client._cleanup_agent_if_needed.assert_called_once() - client._close_client_if_needed.assert_called_once() - - -def test_azure_ai_chat_client_extract_url_citations_with_azure_search_enhanced_url( - mock_agents_client: MagicMock, -) -> None: - """Test _extract_url_citations with Azure AI Search URL enhancement.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Add Azure Search tool calls for URL enhancement - azure_search_tool_calls = [ - { - "id": "call_123", - "type": "azure_ai_search", - "azure_ai_search": { - "output": str({ - "metadata": {"get_urls": ["https://real-example.com/doc1", "https://real-example.com/doc2"]} - }) - }, - } - ] - - # Create mock URL citation with doc reference - mock_url_citation = MagicMock() - mock_url_citation.url = "doc_1" - mock_url_citation.title = "Test Title" - - mock_annotation = MagicMock(spec=MessageDeltaTextUrlCitationAnnotation) - mock_annotation.url_citation = mock_url_citation - mock_annotation.start_index = 10 - mock_annotation.end_index = 20 - - mock_text = MagicMock() - mock_text.annotations = [mock_annotation] - - mock_text_content = MagicMock(spec=MessageDeltaTextContent) - mock_text_content.text = mock_text - - mock_delta = MagicMock() - mock_delta.content = [mock_text_content] - - mock_chunk = MagicMock(spec=MessageDeltaChunk) - mock_chunk.delta = mock_delta - - citations = client._extract_url_citations(mock_chunk, azure_search_tool_calls) # type: ignore - - # Verify real URL was used - assert len(citations) == 1 - citation = citations[0] - assert citation["url"] == "https://real-example.com/doc2" # doc_1 maps to index 1 - - -def test_azure_ai_chat_client_init_with_auto_created_agents_client( - azure_ai_unit_test_env: dict[str, str], mock_azure_credential: MagicMock -) -> None: - """Test AzureAIAgentClient initialization when it creates its own AgentsClient.""" - - # Mock the AgentsClient constructor - with patch("agent_framework_azure_ai._chat_client.AgentsClient") as mock_agents_client_class: - mock_agents_client_instance = MagicMock() - mock_agents_client_class.return_value = mock_agents_client_instance - - # Create client without providing agents_client - should create its own - client = AzureAIAgentClient( - agents_client=None, # This will trigger creation of AgentsClient - agent_id="test-agent", - project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=mock_azure_credential, - ) - - # Verify AgentsClient was created with correct parameters - mock_agents_client_class.assert_called_once_with( - endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - credential=mock_azure_credential, - user_agent="agent-framework-python/0.0.0", - ) - - # Verify client properties are set correctly - assert client.agents_client is mock_agents_client_instance - assert client.agent_id == "test-agent" - assert client.credential is mock_azure_credential - assert client._should_close_client is True # Should close since we created it # type: ignore[attr-defined] - - -async def test_azure_ai_chat_client_prepare_options_with_mapping_response_format( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_options with Mapping-based response_format (runtime JSON schema).""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Runtime JSON schema dict - response_format_dict = { - "type": "json_schema", - "json_schema": { - "name": "TestSchema", - "schema": {"type": "object", "properties": {"name": {"type": "string"}}}, - }, - } - - chat_options: ChatOptions = {"response_format": response_format_dict} # type: ignore[typeddict-item] - - run_options, _ = await client._prepare_options([], chat_options) # type: ignore - - assert "response_format" in run_options - # Should pass through as-is for Mapping types - assert run_options["response_format"] == response_format_dict - - -async def test_azure_ai_chat_client_prepare_options_with_invalid_response_format( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_options with invalid response_format raises error.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Invalid response_format (not BaseModel or Mapping) - chat_options: ChatOptions = {"response_format": "invalid_format"} # type: ignore[typeddict-item] - - with pytest.raises(ChatClientInvalidRequestException, match="response_format must be a Pydantic BaseModel"): - await client._prepare_options([], chat_options) # type: ignore - - -async def test_azure_ai_chat_client_prepare_tool_definitions_with_agent_tool_resources( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tool_definitions_and_resources copies tool_resources from agent definition.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Create mock agent definition with tool_resources - mock_agent_definition = MagicMock() - mock_agent_definition.tools = [] - mock_agent_definition.tool_resources = {"code_interpreter": {"file_ids": ["file-123"]}} - - run_options: dict[str, Any] = {} - options: dict[str, Any] = {} - - await client._prepare_tool_definitions_and_resources(options, mock_agent_definition, run_options) # type: ignore - - # Verify tool_resources was copied to run_options - assert "tool_resources" in run_options - assert run_options["tool_resources"] == {"code_interpreter": {"file_ids": ["file-123"]}} - - -def test_azure_ai_chat_client_prepare_mcp_resources_with_dict_approval_mode( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_mcp_resources with dict-based approval mode (always_require_approval).""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # MCP tool with dict-based approval mode - use approval_mode parameter - mcp_tool = AzureAIAgentClient.get_mcp_tool( - name="Test MCP", - url="https://example.com/mcp", - approval_mode={"always_require_approval": ["tool1", "tool2"]}, - ) - - result = client._prepare_mcp_resources([mcp_tool]) # type: ignore - - assert len(result) == 1 - assert result[0]["server_label"] == "Test_MCP" - assert "require_approval" in result[0] - - -def test_azure_ai_chat_client_prepare_mcp_resources_with_never_require_dict( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_mcp_resources with dict-based approval mode (never_require_approval).""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # MCP tool with never require approval - use approval_mode parameter - mcp_tool = AzureAIAgentClient.get_mcp_tool( - name="Test MCP", - url="https://example.com/mcp", - approval_mode={"never_require_approval": ["safe_tool"]}, - ) - - result = client._prepare_mcp_resources([mcp_tool]) # type: ignore - - assert len(result) == 1 - assert "require_approval" in result[0] - - -def test_azure_ai_chat_client_prepare_messages_with_function_result( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_messages extracts function_result content.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result="test result") - messages = [Message(role="user", contents=[function_result])] - - additional_messages, instructions, required_action_results = client._prepare_messages(messages) # type: ignore - - # function_result should be extracted, not added to additional_messages - assert additional_messages is None - assert required_action_results is not None - assert len(required_action_results) == 1 - assert required_action_results[0].type == "function_result" - - -def test_azure_ai_chat_client_prepare_messages_with_raw_content_block( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_messages handles raw MessageInputContentBlock in content.""" - client = create_test_azure_ai_chat_client(mock_agents_client) - - # Create content with raw_representation that is a MessageInputContentBlock - raw_block = MessageInputTextBlock(text="Raw block text") - custom_content = Content(type="custom", raw_representation=raw_block) - messages = [Message(role="user", contents=[custom_content])] - - additional_messages, instructions, required_action_results = client._prepare_messages(messages) # type: ignore - - assert additional_messages is not None - assert len(additional_messages) == 1 - assert len(additional_messages[0].content) == 1 - assert additional_messages[0].content[0] == raw_block - - -async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_mcp_tool( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tools_for_azure_ai with MCP dict tool.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - mcp_tool = AzureAIAgentClient.get_mcp_tool( - name="Test MCP Server", - url="https://example.com/mcp", - ) - - tool_definitions = await client._prepare_tools_for_azure_ai([mcp_tool]) # type: ignore - - assert len(tool_definitions) >= 1 - # The McpTool.definitions property returns the tool definitions - # Verify the MCP tool was converted correctly by checking the definition type - mcp_def = tool_definitions[0] - assert mcp_def.get("type") == "mcp" - - -async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_tool_definition( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tools_for_azure_ai with ToolDefinition passthrough.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Pass a ToolDefinition directly - should be passed through as-is - tool_def = CodeInterpreterToolDefinition() - - tool_definitions = await client._prepare_tools_for_azure_ai([tool_def]) # type: ignore - - assert len(tool_definitions) == 1 - assert tool_definitions[0] is tool_def - - -async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_dict_passthrough( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tools_for_azure_ai with dict passthrough.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Pass a dict tool definition - should be passed through as-is - dict_tool = {"type": "function", "function": {"name": "test_func", "parameters": {}}} - - tool_definitions = await client._prepare_tools_for_azure_ai([dict_tool]) # type: ignore - - assert len(tool_definitions) == 1 - assert tool_definitions[0] is dict_tool - - -async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_unsupported_type( - mock_agents_client: MagicMock, -) -> None: - """Test _prepare_tools_for_azure_ai passes through unsupported tool types.""" - client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent") - - # Pass an unsupported tool type - it should be passed through unchanged - class UnsupportedTool: - pass - - unsupported_tool = UnsupportedTool() - - # Unsupported tools are now passed through unchanged (server will reject if invalid) - tool_definitions = await client._prepare_tools_for_azure_ai([unsupported_tool]) # type: ignore - assert len(tool_definitions) == 1 - assert tool_definitions[0] is unsupported_tool diff --git a/python/packages/azure-ai/tests/test_azure_ai_client.py b/python/packages/azure-ai/tests/test_azure_ai_client.py deleted file mode 100644 index 2a74f6f74d..0000000000 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ /dev/null @@ -1,1965 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import json -import os -import sys -import warnings -from collections.abc import AsyncGenerator, AsyncIterator -from contextlib import asynccontextmanager -from typing import Annotated, Any -from unittest.mock import AsyncMock, MagicMock, patch -from uuid import uuid4 - -import pytest -from agent_framework import ( - Annotation, - ChatOptions, - ChatResponse, - ChatResponseUpdate, - Content, - Message, - ResponseStream, - SupportsChatGetResponse, - tool, -) -from agent_framework._settings import load_settings -from agent_framework_openai._chat_client import RawOpenAIChatClient -from azure.ai.projects.aio import AIProjectClient -from azure.ai.projects.models import ( - ApproximateLocation, - AutoCodeInterpreterToolParam, - CodeInterpreterTool, - FileSearchTool, - ImageGenTool, - MCPTool, - TextResponseFormatJsonSchema, - WebSearchPreviewTool, -) -from azure.core.exceptions import ResourceNotFoundError -from azure.identity.aio import AzureCliCredential -from openai.types.responses.parsed_response import ParsedResponse -from openai.types.responses.response import Response as OpenAIResponse -from pydantic import BaseModel, ConfigDict, Field -from pytest import fixture - -from agent_framework_azure_ai import AzureAIClient, AzureAISettings # noqa: E402 -from agent_framework_azure_ai._shared import from_azure_ai_tools # noqa: E402 - -warnings.filterwarnings( - "ignore", - message=r"RawAzureAIClient is deprecated\..*", - category=DeprecationWarning, -) -warnings.filterwarnings( - "ignore", - message=r"AzureAIClient is deprecated\..*", - category=DeprecationWarning, -) - -pytestmark = pytest.mark.filterwarnings("ignore:AzureAIClient is deprecated\\..*:DeprecationWarning") - - -@pytest.fixture -def mock_project_client() -> MagicMock: - """Fixture that provides a mock AIProjectClient.""" - mock_client = MagicMock() - - # Mock agents property - mock_client.agents = MagicMock() - mock_client.agents.create_version = AsyncMock() - - # Mock conversations property - mock_client.conversations = MagicMock() - mock_client.conversations.create = AsyncMock() - - # Mock telemetry property - mock_client.telemetry = MagicMock() - mock_client.telemetry.get_application_insights_connection_string = AsyncMock() - - # AIProjectClient.get_openai_client() is a sync accessor, even on the aio client. - mock_client.get_openai_client = MagicMock(return_value=MagicMock()) - - # Mock close method - mock_client.close = AsyncMock() - - return mock_client - - -@asynccontextmanager -async def temporary_chat_client(agent_name: str) -> AsyncIterator[AzureAIClient]: - """Async context manager that creates an Azure AI agent and yields an `AzureAIClient`. - - The underlying agent version is cleaned up automatically after use. - Tests can construct their own `Agent` instances from the yielded client. - """ - endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] - async with ( - AzureCliCredential() as credential, - AIProjectClient(endpoint=endpoint, credential=credential) as project_client, - ): - client = AzureAIClient( - project_client=project_client, - agent_name=agent_name, - ) - try: - yield client - finally: - await project_client.agents.delete(agent_name=agent_name) - - -def create_test_azure_ai_client( - mock_project_client: MagicMock, - agent_name: str | None = None, - agent_version: str | None = None, - conversation_id: str | None = None, - azure_ai_settings: AzureAISettings | None = None, - should_close_client: bool = False, - use_latest_version: bool | None = None, -) -> AzureAIClient: - """Helper function to create AzureAIClient instances for testing, bypassing normal validation.""" - if azure_ai_settings is None: - azure_ai_settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_") - - # Create client instance directly - client = object.__new__(AzureAIClient) - - # Set attributes directly - client.project_client = mock_project_client - client.credential = None - client.agent_name = agent_name - client.agent_version = agent_version - client.agent_description = None - client.use_latest_version = use_latest_version - client.model_id = azure_ai_settings.get("model_deployment_name") - client.conversation_id = conversation_id - client._is_application_endpoint = False # type: ignore - client._should_close_client = should_close_client # type: ignore - client.warn_runtime_tools_and_structure_changed = False # type: ignore - client._created_agent_tool_names = set() # type: ignore - client._created_agent_structured_output_signature = None # type: ignore - client.additional_properties = {} - client.chat_middleware = [] - - # Mock the OpenAI client attribute - mock_openai_client = MagicMock() - mock_openai_client.conversations = MagicMock() - mock_openai_client.conversations.create = AsyncMock() - client.client = mock_openai_client - - return client - - -def test_azure_ai_settings_init(azure_ai_unit_test_env: dict[str, str]) -> None: - """Test AzureAISettings initialization.""" - settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_") - - assert settings["project_endpoint"] == azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"] - assert settings["model_deployment_name"] == azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] - - -def test_azure_ai_settings_init_with_explicit_values() -> None: - """Test AzureAISettings initialization with explicit values.""" - settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - project_endpoint="https://custom-endpoint.com/", - model_deployment_name="custom-model", - ) - - assert settings["project_endpoint"] == "https://custom-endpoint.com/" - assert settings["model_deployment_name"] == "custom-model" - - -def test_init_with_project_client(mock_project_client: MagicMock) -> None: - """Test AzureAIClient initialization with existing project_client.""" - with patch("agent_framework_azure_ai._client.load_settings") as mock_load_settings: - mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"} - - client = AzureAIClient( - project_client=mock_project_client, - agent_name="test-agent", - agent_version="1.0", - ) - - assert client.project_client is mock_project_client - assert client.agent_name == "test-agent" - assert client.agent_version == "1.0" - assert not client._should_close_client # type: ignore - assert isinstance(client, SupportsChatGetResponse) - - -def test_init_auto_create_client( - azure_ai_unit_test_env: dict[str, str], - mock_azure_credential: MagicMock, -) -> None: - """Test AzureAIClient initialization with auto-created project_client.""" - with patch("agent_framework_azure_ai._client.AIProjectClient") as mock_ai_project_client: - mock_project_client = MagicMock() - mock_ai_project_client.return_value = mock_project_client - - client = AzureAIClient( - project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=mock_azure_credential, - agent_name="test-agent", - ) - - assert client.project_client is mock_project_client - assert client.agent_name == "test-agent" - assert client._should_close_client # type: ignore - - # Verify AIProjectClient was called with correct parameters - mock_ai_project_client.assert_called_once() - - -def test_init_missing_project_endpoint() -> None: - """Test AzureAIClient initialization when project_endpoint is missing and no project_client provided.""" - with patch("agent_framework_azure_ai._client.load_settings") as mock_load_settings: - mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"} - - with pytest.raises(ValueError, match="Azure AI project endpoint is required"): - AzureAIClient(credential=MagicMock()) - - -def test_init_missing_credential(azure_ai_unit_test_env: dict[str, str]) -> None: - """Test AzureAIClient.__init__ when credential is missing and no project_client provided.""" - with pytest.raises(ValueError, match="Azure credential is required when project_client is not provided"): - AzureAIClient( - project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - ) - - -async def test_get_agent_reference_or_create_existing_version( - mock_project_client: MagicMock, -) -> None: - """Test _get_agent_reference_or_create when agent_version is already provided.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="existing-agent", agent_version="1.0") - - agent_ref = await client._get_agent_reference_or_create({}, None) # type: ignore - - assert agent_ref == {"name": "existing-agent", "version": "1.0", "type": "agent_reference"} - - -async def test_get_agent_reference_or_create_missing_agent_name( - mock_project_client: MagicMock, -) -> None: - """Test _get_agent_reference_or_create raises when agent_name is missing.""" - client = create_test_azure_ai_client(mock_project_client, agent_name=None) - - with pytest.raises(ValueError, match="Agent name is required"): - await client._get_agent_reference_or_create({}, None) # type: ignore - - -async def test_get_agent_reference_or_create_new_agent( - mock_project_client: MagicMock, - azure_ai_unit_test_env: dict[str, str], -) -> None: - """Test _get_agent_reference_or_create when creating a new agent.""" - azure_ai_settings = load_settings( - AzureAISettings, - env_prefix="AZURE_AI_", - model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - ) - client = create_test_azure_ai_client( - mock_project_client, agent_name="new-agent", azure_ai_settings=azure_ai_settings - ) - - # Mock agent creation response - mock_agent = MagicMock() - mock_agent.name = "new-agent" - mock_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) - - run_options = {"model": azure_ai_settings.get("model_deployment_name")} - agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore - - assert agent_ref == {"name": "new-agent", "version": "1.0", "type": "agent_reference"} - assert client.agent_name == "new-agent" - assert client.agent_version == "1.0" - - -async def test_get_agent_reference_missing_model( - mock_project_client: MagicMock, -) -> None: - """Test _get_agent_reference_or_create when model is missing for agent creation.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") - - with pytest.raises(ValueError, match="Model deployment name is required for agent creation"): - await client._get_agent_reference_or_create({}, None) # type: ignore - - -async def test_prepare_messages_for_azure_ai_with_system_messages( - mock_project_client: MagicMock, -) -> None: - """Test _prepare_messages_for_azure_ai converts system/developer messages to instructions.""" - client = create_test_azure_ai_client(mock_project_client) - - messages = [ - Message(role="system", contents=[Content.from_text(text="You are a helpful assistant.")]), - Message(role="user", contents=[Content.from_text(text="Hello")]), - Message(role="assistant", contents=[Content.from_text(text="System response")]), - ] - - result_messages, instructions = client._prepare_messages_for_azure_ai(messages) # type: ignore - - assert len(result_messages) == 2 - assert result_messages[0].role == "user" - assert result_messages[1].role == "assistant" - assert instructions == "You are a helpful assistant." - - -async def test_prepare_messages_for_azure_ai_no_system_messages( - mock_project_client: MagicMock, -) -> None: - """Test _prepare_messages_for_azure_ai with no system/developer messages.""" - client = create_test_azure_ai_client(mock_project_client) - - messages = [ - Message(role="user", contents=[Content.from_text(text="Hello")]), - Message(role="assistant", contents=[Content.from_text(text="Hi there!")]), - ] - - result_messages, instructions = client._prepare_messages_for_azure_ai(messages) # type: ignore - - assert len(result_messages) == 2 - assert instructions is None - - -def test_transform_input_for_azure_ai(mock_project_client: MagicMock) -> None: - """Test _transform_input_for_azure_ai adds required fields for Azure AI schema. - - WORKAROUND TEST: Azure AI Projects API requires 'type' at item level and - 'annotations' in output_text content items, which OpenAI's Responses API does not require. - See: https://github.com/Azure/azure-sdk-for-python/issues/44493 - See: https://github.com/microsoft/agent-framework/issues/2926 - """ - client = create_test_azure_ai_client(mock_project_client) - - # Input in OpenAI Responses API format (what agent-framework generates) - openai_format_input = [ - { - "role": "user", - "content": [ - {"type": "input_text", "text": "Hello"}, - ], - }, - { - "role": "assistant", - "content": [ - {"type": "output_text", "text": "Hi there!"}, - ], - }, - ] - - result = client._transform_input_for_azure_ai(openai_format_input) # type: ignore - - # Verify 'type': 'message' added at item level - assert result[0]["type"] == "message" - assert result[1]["type"] == "message" - - # Verify 'annotations' added ONLY to output_text (assistant) content, NOT input_text (user) - assert result[0]["content"][0]["type"] == "input_text" # user content type preserved - assert "annotations" not in result[0]["content"][0] # user message - no annotations - assert result[1]["content"][0]["type"] == "output_text" # assistant content type preserved - assert result[1]["content"][0]["annotations"] == [] # assistant message - has annotations - - # Verify original fields preserved - assert result[0]["role"] == "user" - assert result[0]["content"][0]["text"] == "Hello" - assert result[1]["role"] == "assistant" - assert result[1]["content"][0]["text"] == "Hi there!" - - -def test_transform_input_preserves_existing_fields(mock_project_client: MagicMock) -> None: - """Test _transform_input_for_azure_ai preserves existing type and annotations.""" - client = create_test_azure_ai_client(mock_project_client) - - # Input that already has the fields (shouldn't duplicate) - input_with_fields = [ - { - "type": "message", - "role": "assistant", - "content": [ - {"type": "output_text", "text": "Hello", "annotations": [{"some": "annotation"}]}, - ], - }, - ] - - result = client._transform_input_for_azure_ai(input_with_fields) # type: ignore - - # Should preserve existing values, not overwrite - assert result[0]["type"] == "message" - assert result[0]["content"][0]["annotations"] == [{"some": "annotation"}] - - -def test_transform_input_handles_non_dict_content(mock_project_client: MagicMock) -> None: - """Test _transform_input_for_azure_ai handles non-dict content items.""" - client = create_test_azure_ai_client(mock_project_client) - - # Input with string content (edge case) - input_with_string_content = [ - { - "role": "user", - "content": ["plain string content"], - }, - ] - - result = client._transform_input_for_azure_ai(input_with_string_content) # type: ignore - - # Should add 'type': 'message' at item level even with non-dict content - assert result[0]["type"] == "message" - # Non-dict content items should be preserved without modification - assert result[0]["content"] == ["plain string content"] - - -async def test_prepare_options_basic(mock_project_client: MagicMock) -> None: - """Test prepare_options basic functionality.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") - - messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - - with ( - patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={"model": "test-model"}, - ), - patch.object( - client, - "_get_agent_reference_or_create", - return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, - ), - ): - run_options = await client._prepare_options(messages, {}) - - assert "extra_body" in run_options - assert run_options["extra_body"]["agent_reference"]["name"] == "test-agent" - - -@pytest.mark.parametrize( - "endpoint,expects_agent", - [ - ("https://example.com/api/projects/my-project/applications/my-application/protocols", False), - ("https://example.com/api/projects/my-project", True), - ], -) -async def test_prepare_options_with_application_endpoint( - mock_azure_credential: MagicMock, endpoint: str, expects_agent: bool -) -> None: - client = AzureAIClient( - project_endpoint=endpoint, - model_deployment_name="test-model", - credential=mock_azure_credential, - agent_name="test-agent", - agent_version="1", - ) - - messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - - with ( - patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={"model": "test-model"}, - ), - patch.object( - client, - "_get_agent_reference_or_create", - return_value={"name": "test-agent", "version": "1", "type": "agent_reference"}, - ), - ): - run_options = await client._prepare_options(messages, {}) - - if expects_agent: - assert "extra_body" in run_options - assert run_options["extra_body"]["agent_reference"]["name"] == "test-agent" - else: - assert "extra_body" not in run_options - - -@pytest.mark.parametrize( - "endpoint,expects_agent", - [ - ("https://example.com/api/projects/my-project/applications/my-application/protocols", False), - ("https://example.com/api/projects/my-project", True), - ], -) -async def test_prepare_options_with_application_project_client( - mock_project_client: MagicMock, endpoint: str, expects_agent: bool -) -> None: - mock_project_client._config = MagicMock() - mock_project_client._config.endpoint = endpoint - - client = AzureAIClient( - project_client=mock_project_client, - model_deployment_name="test-model", - agent_name="test-agent", - agent_version="1", - ) - - messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - - with ( - patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={"model": "test-model"}, - ), - patch.object( - client, - "_get_agent_reference_or_create", - return_value={"name": "test-agent", "version": "1", "type": "agent_reference"}, - ), - ): - run_options = await client._prepare_options(messages, {}) - - if expects_agent: - assert "extra_body" in run_options - assert run_options["extra_body"]["agent_reference"]["name"] == "test-agent" - else: - assert "extra_body" not in run_options - - -def test_update_agent_name_and_description(mock_project_client: MagicMock) -> None: - """Test _update_agent_name_and_description method.""" - client = create_test_azure_ai_client(mock_project_client) - - # Test updating agent name when current is None - with patch.object(client, "_update_agent_name_and_description") as mock_update: - mock_update.return_value = None - client._update_agent_name_and_description("new-agent") # type: ignore - mock_update.assert_called_once_with("new-agent") - - # Test behavior when agent name is updated - assert client.agent_name is None # Should remain None since we didn't actually update - client.agent_name = "test-agent" # Manually set for the test - - # Test with None input - with patch.object(client, "_update_agent_name_and_description") as mock_update: - mock_update.return_value = None - client._update_agent_name_and_description(None) # type: ignore - mock_update.assert_called_once_with(None) - - -def test_as_agent_uses_client_agent_name_as_default(mock_project_client: MagicMock) -> None: - """Test that as_agent() defaults Agent.name to client.agent_name when name is not provided.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="my_agent") - client.agent_description = "my description" - - agent = client.as_agent(instructions="You are helpful.") - - assert agent.name == "my_agent" - assert agent.description == "my description" - - -def test_as_agent_explicit_name_overrides_client_agent_name(mock_project_client: MagicMock) -> None: - """Test that an explicit name passed to as_agent() takes precedence over client.agent_name.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="client_name") - client.agent_description = "client description" - - agent = client.as_agent(name="explicit_name", description="explicit description", instructions="You are helpful.") - - assert agent.name == "explicit_name" - assert agent.description == "explicit description" - - -def test_as_agent_no_name_anywhere(mock_project_client: MagicMock) -> None: - """Test that Agent.name is None when neither as_agent name nor client.agent_name is provided.""" - client = create_test_azure_ai_client(mock_project_client) - - agent = client.as_agent(instructions="You are helpful.") - - assert agent.name is None - - -def test_as_agent_empty_string_preserves_explicit_value(mock_project_client: MagicMock) -> None: - """Test that empty-string name/description are preserved and do not fall back to client defaults.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="client_name") - client.agent_description = "client description" - - agent = client.as_agent(name="", description="", instructions="You are helpful.") - - assert agent.name == "" - assert agent.description == "" - - -async def test_async_context_manager(mock_project_client: MagicMock) -> None: - """Test async context manager functionality.""" - client = create_test_azure_ai_client(mock_project_client, should_close_client=True) - - mock_project_client.close = AsyncMock() - - async with client as ctx_client: - assert ctx_client is client - - # Should call close after exiting context - mock_project_client.close.assert_called_once() - - -async def test_close_method(mock_project_client: MagicMock) -> None: - """Test close method.""" - client = create_test_azure_ai_client(mock_project_client, should_close_client=True) - - mock_project_client.close = AsyncMock() - - await client.close() - - mock_project_client.close.assert_called_once() - - -async def test_close_client_when_should_close_false(mock_project_client: MagicMock) -> None: - """Test _close_client_if_needed when should_close_client is False.""" - client = create_test_azure_ai_client(mock_project_client, should_close_client=False) - - mock_project_client.close = AsyncMock() - - await client._close_client_if_needed() # type: ignore - - # Should not call close when should_close_client is False - mock_project_client.close.assert_not_called() - - -async def test_configure_azure_monitor_success(mock_project_client: MagicMock) -> None: - """Test configure_azure_monitor successfully configures Azure Monitor.""" - client = create_test_azure_ai_client(mock_project_client) - - # Mock the telemetry connection string retrieval - mock_project_client.telemetry.get_application_insights_connection_string = AsyncMock( - return_value="InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint" - ) - - mock_configure = MagicMock() - mock_views = MagicMock(return_value=[]) - mock_resource = MagicMock() - mock_enable = MagicMock() - - with ( - patch.dict( - "sys.modules", - {"azure.monitor.opentelemetry": MagicMock(configure_azure_monitor=mock_configure)}, - ), - patch("agent_framework.observability.create_metric_views", mock_views), - patch("agent_framework.observability.create_resource", return_value=mock_resource), - patch("agent_framework.observability.enable_instrumentation", mock_enable), - ): - await client.configure_azure_monitor(enable_sensitive_data=True) - - # Verify connection string was retrieved - mock_project_client.telemetry.get_application_insights_connection_string.assert_called_once() - - # Verify Azure Monitor was configured - mock_configure.assert_called_once() - call_kwargs = mock_configure.call_args[1] - assert call_kwargs["connection_string"] == "InstrumentationKey=test-key;IngestionEndpoint=https://test.endpoint" - - # Verify instrumentation was enabled with sensitive data flag - mock_enable.assert_called_once_with(enable_sensitive_data=True) - - -async def test_configure_azure_monitor_resource_not_found(mock_project_client: MagicMock) -> None: - """Test configure_azure_monitor handles ResourceNotFoundError gracefully.""" - client = create_test_azure_ai_client(mock_project_client) - - # Mock the telemetry to raise ResourceNotFoundError - mock_project_client.telemetry.get_application_insights_connection_string = AsyncMock( - side_effect=ResourceNotFoundError("No Application Insights found") - ) - - # Should not raise, just log warning and return - await client.configure_azure_monitor() - - # Verify connection string retrieval was attempted - mock_project_client.telemetry.get_application_insights_connection_string.assert_called_once() - - -async def test_configure_azure_monitor_import_error(mock_project_client: MagicMock) -> None: - """Test configure_azure_monitor raises ImportError when azure-monitor-opentelemetry is not installed.""" - client = create_test_azure_ai_client(mock_project_client) - - # Mock the telemetry connection string retrieval - mock_project_client.telemetry.get_application_insights_connection_string = AsyncMock( - return_value="InstrumentationKey=test-key" - ) - - # Mock the import to fail - with ( - patch.dict(sys.modules, {"azure.monitor.opentelemetry": None}), - patch("builtins.__import__", side_effect=ImportError("No module named 'azure.monitor.opentelemetry'")), - pytest.raises(ImportError, match="azure-monitor-opentelemetry is required"), - ): - await client.configure_azure_monitor() - - -async def test_configure_azure_monitor_with_custom_resource(mock_project_client: MagicMock) -> None: - """Test configure_azure_monitor uses custom resource when provided.""" - client = create_test_azure_ai_client(mock_project_client) - - mock_project_client.telemetry.get_application_insights_connection_string = AsyncMock( - return_value="InstrumentationKey=test-key" - ) - - custom_resource = MagicMock() - mock_configure = MagicMock() - - with ( - patch.dict( - "sys.modules", - {"azure.monitor.opentelemetry": MagicMock(configure_azure_monitor=mock_configure)}, - ), - patch("agent_framework.observability.create_metric_views") as mock_views, - patch("agent_framework.observability.create_resource") as mock_create_resource, - patch("agent_framework.observability.enable_instrumentation"), - ): - mock_views.return_value = [] - - await client.configure_azure_monitor(resource=custom_resource) - - # Verify custom resource was used, not create_resource - mock_create_resource.assert_not_called() - call_kwargs = mock_configure.call_args[1] - assert call_kwargs["resource"] is custom_resource - - -async def test_agent_creation_with_instructions( - mock_project_client: MagicMock, -) -> None: - """Test agent creation with combined instructions.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") - - # Mock agent creation response - mock_agent = MagicMock() - mock_agent.name = "test-agent" - mock_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) - - run_options = {"model": "test-model"} - chat_options = {"instructions": "Option instructions. "} - messages_instructions = "Message instructions. " - - await client._get_agent_reference_or_create(run_options, messages_instructions, chat_options) # type: ignore - - # Verify agent was created with combined instructions - call_args = mock_project_client.agents.create_version.call_args - assert call_args[1]["definition"].instructions == "Message instructions. Option instructions. " - - -async def test_agent_creation_with_instructions_from_chat_options( - mock_project_client: MagicMock, -) -> None: - """Test agent creation with instructions passed only via chat_options.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") - - mock_agent = MagicMock() - mock_agent.name = "test-agent" - mock_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) - - run_options = {"model": "test-model"} - chat_options = {"instructions": "Chat options instructions."} - - await client._get_agent_reference_or_create(run_options, None, chat_options) # type: ignore - - call_args = mock_project_client.agents.create_version.call_args - assert call_args[1]["definition"].instructions == "Chat options instructions." - - -async def test_agent_creation_with_additional_args( - mock_project_client: MagicMock, -) -> None: - """Test agent creation with additional arguments.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") - - # Mock agent creation response - mock_agent = MagicMock() - mock_agent.name = "test-agent" - mock_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) - - run_options = {"model": "test-model", "temperature": 0.9, "top_p": 0.8} - messages_instructions = "Message instructions. " - - await client._get_agent_reference_or_create(run_options, messages_instructions) # type: ignore - - # Verify agent was created with provided arguments - call_args = mock_project_client.agents.create_version.call_args - definition = call_args[1]["definition"] - assert definition.temperature == 0.9 - assert definition.top_p == 0.8 - - -async def test_agent_creation_with_tools( - mock_project_client: MagicMock, -) -> None: - """Test agent creation with tools.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") - - # Mock agent creation response - mock_agent = MagicMock() - mock_agent.name = "test-agent" - mock_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) - - test_tools = [{"type": "function", "function": {"name": "test_tool"}}] - run_options = {"model": "test-model", "tools": test_tools} - - await client._get_agent_reference_or_create(run_options, None) # type: ignore - - # Verify agent was created with tools - call_args = mock_project_client.agents.create_version.call_args - assert call_args[1]["definition"].tools == test_tools - - -async def test_runtime_tools_override_logs_warning( - mock_project_client: MagicMock, -) -> None: - """Test warning is logged when runtime tools differ from creation-time tools.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") - - mock_agent = MagicMock() - mock_agent.name = "test-agent" - mock_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) - messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - - with patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_one"}]}, - ): - await client._prepare_options(messages, {}) - - with ( - patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_two"}]}, - ), - patch("agent_framework_azure_ai._client.logger.warning") as mock_warning, - ): - await client._prepare_options(messages, {}) - mock_warning.assert_called_once() - assert "Use AzureOpenAIResponsesClient instead." in mock_warning.call_args[0][0] - - -async def test_prepare_options_logs_warning_for_tools_with_existing_agent_version( - mock_project_client: MagicMock, -) -> None: - """Test warning is logged when tools are supplied against an existing agent version.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") - messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - - with ( - patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_one"}]}, - ), - patch("agent_framework_azure_ai._client.logger.warning") as mock_warning, - ): - run_options = await client._prepare_options(messages, {}) - - mock_warning.assert_called_once() - assert "Use AzureOpenAIResponsesClient instead." in mock_warning.call_args[0][0] - assert "tools" not in run_options - - -async def test_prepare_options_logs_warning_for_tools_on_application_endpoint( - mock_project_client: MagicMock, -) -> None: - """Test warning is logged when runtime tools are removed for application endpoints.""" - client = create_test_azure_ai_client(mock_project_client) - client._is_application_endpoint = True # type: ignore - messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - - with ( - patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_one"}]}, - ), - patch.object(client, "_get_agent_reference_or_create", new_callable=AsyncMock) as mock_get_agent_reference, - patch("agent_framework_azure_ai._client.logger.warning") as mock_warning, - ): - run_options = await client._prepare_options(messages, {}) - - mock_get_agent_reference.assert_not_called() - mock_warning.assert_called_once() - assert "Use AzureOpenAIResponsesClient instead." in mock_warning.call_args[0][0] - assert "tools" not in run_options - assert "extra_body" not in run_options - - -async def test_use_latest_version_existing_agent( - mock_project_client: MagicMock, -) -> None: - """Test _get_agent_reference_or_create when use_latest_version=True and agent exists.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="existing-agent", use_latest_version=True) - - # Mock existing agent response - mock_existing_agent = MagicMock() - mock_existing_agent.name = "existing-agent" - mock_existing_agent.versions.latest.version = "2.5" - mock_project_client.agents.get = AsyncMock(return_value=mock_existing_agent) - - run_options = {"model": "test-model"} - agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore - - # Verify existing agent was retrieved and used - mock_project_client.agents.get.assert_called_once_with("existing-agent") - mock_project_client.agents.create_version.assert_not_called() - - assert agent_ref == {"name": "existing-agent", "version": "2.5", "type": "agent_reference"} - assert client.agent_name == "existing-agent" - assert client.agent_version == "2.5" - - -async def test_use_latest_version_agent_not_found( - mock_project_client: MagicMock, -) -> None: - """Test _get_agent_reference_or_create when use_latest_version=True but agent doesn't exist.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="non-existing-agent", use_latest_version=True) - - # Mock ResourceNotFoundError when trying to retrieve agent - mock_project_client.agents.get = AsyncMock(side_effect=ResourceNotFoundError("Agent not found")) - - # Mock agent creation response for fallback - mock_created_agent = MagicMock() - mock_created_agent.name = "non-existing-agent" - mock_created_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_created_agent) - - run_options = {"model": "test-model"} - agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore - - # Verify retrieval was attempted and creation was used as fallback - mock_project_client.agents.get.assert_called_once_with("non-existing-agent") - mock_project_client.agents.create_version.assert_called_once() - - assert agent_ref == {"name": "non-existing-agent", "version": "1.0", "type": "agent_reference"} - assert client.agent_name == "non-existing-agent" - assert client.agent_version == "1.0" - - -async def test_use_latest_version_false( - mock_project_client: MagicMock, -) -> None: - """Test _get_agent_reference_or_create when use_latest_version=False (default behavior).""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", use_latest_version=False) - - # Mock agent creation response - mock_created_agent = MagicMock() - mock_created_agent.name = "test-agent" - mock_created_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_created_agent) - - run_options = {"model": "test-model"} - agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore - - # Verify retrieval was not attempted and creation was used directly - mock_project_client.agents.get.assert_not_called() - mock_project_client.agents.create_version.assert_called_once() - - assert agent_ref == {"name": "test-agent", "version": "1.0", "type": "agent_reference"} - - -async def test_use_latest_version_with_existing_agent_version( - mock_project_client: MagicMock, -) -> None: - """Test that use_latest_version is ignored when agent_version is already provided.""" - client = create_test_azure_ai_client( - mock_project_client, agent_name="test-agent", agent_version="3.0", use_latest_version=True - ) - - agent_ref = await client._get_agent_reference_or_create({}, None) # type: ignore - - # Verify neither retrieval nor creation was attempted since version is already set - mock_project_client.agents.get.assert_not_called() - mock_project_client.agents.create_version.assert_not_called() - - assert agent_ref == {"name": "test-agent", "version": "3.0", "type": "agent_reference"} - - -class ResponseFormatModel(BaseModel): - """Test Pydantic model for response format testing.""" - - name: str - value: int - description: str - model_config = ConfigDict(extra="forbid") - - -class AlternateResponseFormatModel(BaseModel): - """Alternate model for structured output warning checks.""" - - summary: str - confidence: float - - -async def test_agent_creation_with_response_format( - mock_project_client: MagicMock, -) -> None: - """Test agent creation with response_format configuration.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") - - # Mock agent creation response - mock_agent = MagicMock() - mock_agent.name = "test-agent" - mock_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) - - run_options = {"model": "test-model"} - chat_options = {"response_format": ResponseFormatModel} - - await client._get_agent_reference_or_create(run_options, None, chat_options) # type: ignore - - # Verify agent was created with response format configuration - call_args = mock_project_client.agents.create_version.call_args - created_definition = call_args[1]["definition"] - - # Check that text format configuration was set - assert hasattr(created_definition, "text") - assert created_definition.text is not None - - # Check that the format is a TextResponseFormatJsonSchema - assert hasattr(created_definition.text, "format") - format_config = created_definition.text.format - assert isinstance(format_config, TextResponseFormatJsonSchema) - - # Check the schema name matches the model class name - assert format_config.name == "ResponseFormatModel" - - # Check that schema was generated correctly - assert format_config.schema is not None - schema = format_config.schema - assert "properties" in schema - assert "name" in schema["properties"] - assert "value" in schema["properties"] - assert "description" in schema["properties"] - assert "additionalProperties" in schema - - -async def test_agent_creation_with_mapping_response_format( - mock_project_client: MagicMock, -) -> None: - """Test agent creation when response_format is provided as a mapping.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") - - mock_agent = MagicMock() - mock_agent.name = "test-agent" - mock_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) - - runtime_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, - } - - run_options = {"model": "test-model"} - response_format_mapping = { - "type": "json_schema", - "json_schema": { - "name": runtime_schema["title"], - "strict": True, - "schema": runtime_schema, - }, - } - chat_options = {"response_format": response_format_mapping} - - await client._get_agent_reference_or_create(run_options, None, chat_options) - - call_args = mock_project_client.agents.create_version.call_args - created_definition = call_args[1]["definition"] - - assert hasattr(created_definition, "text") - assert created_definition.text is not None - format_config = created_definition.text.format - assert isinstance(format_config, TextResponseFormatJsonSchema) - assert format_config.name == runtime_schema["title"] - assert format_config.schema == runtime_schema - assert format_config.strict is True - - -async def test_runtime_structured_output_override_logs_warning( - mock_project_client: MagicMock, -) -> None: - """Test warning is logged when runtime structured_output differs from creation-time configuration.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent") - - mock_agent = MagicMock() - mock_agent.name = "test-agent" - mock_agent.version = "1.0" - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) - messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - - with patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={"model": "test-model"}, - ): - await client._prepare_options(messages, {"response_format": ResponseFormatModel}) - - with ( - patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={"model": "test-model"}, - ), - patch("agent_framework_azure_ai._client.logger.warning") as mock_warning, - ): - await client._prepare_options(messages, {"response_format": AlternateResponseFormatModel}) - mock_warning.assert_called_once() - assert "Use AzureOpenAIResponsesClient instead." in mock_warning.call_args[0][0] - - -async def test_prepare_options_excludes_response_format( - mock_project_client: MagicMock, -) -> None: - """Test that prepare_options excludes response_format, text, and text_format from final run options.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") - - messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - chat_options: ChatOptions = {} - - with ( - patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={ - "model": "test-model", - "response_format": ResponseFormatModel, - "text": {"format": {"type": "json_schema", "name": "test"}}, - "text_format": ResponseFormatModel, - }, - ), - patch.object( - client, - "_get_agent_reference_or_create", - return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, - ), - ): - run_options = await client._prepare_options(messages, chat_options) - - # response_format, text, and text_format should be excluded from final run options - # because they are configured at agent level, not request level - assert "response_format" not in run_options - assert "text" not in run_options - assert "text_format" not in run_options - # But extra_body should contain agent reference - assert "extra_body" in run_options - assert run_options["extra_body"]["agent_reference"]["name"] == "test-agent" - - -async def test_prepare_options_keeps_values_for_unsupported_option_keys( - mock_project_client: MagicMock, -) -> None: - """Test that run_options removal only applies to known AzureAI agent-level option mappings.""" - client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") - messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - - with ( - patch( - "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", - return_value={ - "model": "test-model", - "tools": [{"type": "function", "name": "weather"}], - "text": {"format": {"type": "json_schema", "name": "schema"}}, - "text_format": ResponseFormatModel, - "custom_option": "keep-me", - }, - ), - patch.object( - client, - "_get_agent_reference_or_create", - return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, - ), - ): - run_options = await client._prepare_options(messages, {}) - - assert "model" not in run_options - assert "tools" not in run_options - assert "text" not in run_options - assert "text_format" not in run_options - assert run_options["custom_option"] == "keep-me" - - -def test_get_conversation_id_with_store_true_and_conversation_id() -> None: - """Test _get_conversation_id returns conversation ID when store is True and conversation exists.""" - client = create_test_azure_ai_client(MagicMock()) - - # Mock OpenAI response with conversation - mock_response = MagicMock(spec=OpenAIResponse) - mock_response.id = "resp_12345" - mock_conversation = MagicMock() - mock_conversation.id = "conv_67890" - mock_response.conversation = mock_conversation - - result = client._get_conversation_id(mock_response, store=True) - - assert result == "conv_67890" - - -def test_get_conversation_id_with_store_true_and_no_conversation() -> None: - """Test _get_conversation_id returns response ID when store is True and no conversation exists.""" - client = create_test_azure_ai_client(MagicMock()) - - # Mock OpenAI response without conversation - mock_response = MagicMock(spec=OpenAIResponse) - mock_response.id = "resp_12345" - mock_response.conversation = None - - result = client._get_conversation_id(mock_response, store=True) - - assert result == "resp_12345" - - -def test_get_conversation_id_with_store_true_and_empty_conversation_id() -> None: - """Test _get_conversation_id returns response ID when store is True and conversation ID is empty.""" - client = create_test_azure_ai_client(MagicMock()) - - # Mock OpenAI response with conversation but empty ID - mock_response = MagicMock(spec=OpenAIResponse) - mock_response.id = "resp_12345" - mock_conversation = MagicMock() - mock_conversation.id = "" - mock_response.conversation = mock_conversation - - result = client._get_conversation_id(mock_response, store=True) - - assert result == "resp_12345" - - -def test_get_conversation_id_with_store_false() -> None: - """Test _get_conversation_id returns None when store is False.""" - client = create_test_azure_ai_client(MagicMock()) - - # Mock OpenAI response with conversation - mock_response = MagicMock(spec=OpenAIResponse) - mock_response.id = "resp_12345" - mock_conversation = MagicMock() - mock_conversation.id = "conv_67890" - mock_response.conversation = mock_conversation - - result = client._get_conversation_id(mock_response, store=False) - - assert result is None - - -def test_get_conversation_id_with_parsed_response_and_store_true() -> None: - """Test _get_conversation_id works with ParsedResponse when store is True.""" - client = create_test_azure_ai_client(MagicMock()) - - # Mock ParsedResponse with conversation - mock_response = MagicMock(spec=ParsedResponse[BaseModel]) - mock_response.id = "resp_parsed_12345" - mock_conversation = MagicMock() - mock_conversation.id = "conv_parsed_67890" - mock_response.conversation = mock_conversation - - result = client._get_conversation_id(mock_response, store=True) - - assert result == "conv_parsed_67890" - - -def test_get_conversation_id_with_parsed_response_no_conversation() -> None: - """Test _get_conversation_id returns response ID with ParsedResponse when no conversation exists.""" - client = create_test_azure_ai_client(MagicMock()) - - # Mock ParsedResponse without conversation - mock_response = MagicMock(spec=ParsedResponse[BaseModel]) - mock_response.id = "resp_parsed_12345" - mock_response.conversation = None - - result = client._get_conversation_id(mock_response, store=True) - - assert result == "resp_parsed_12345" - - -# region MCP Tool Dict Tests -# These tests verify that dict-based MCP tools are processed correctly by from_azure_ai_tools - - -def test_from_azure_ai_tools_mcp() -> None: - """Test from_azure_ai_tools with MCP tool.""" - mcp_tool = MCPTool(server_label="test_server", server_url="http://localhost:8080") - parsed_tools = from_azure_ai_tools([mcp_tool]) - assert len(parsed_tools) == 1 - assert parsed_tools[0]["type"] == "mcp" - assert parsed_tools[0]["server_label"] == "test_server" - assert parsed_tools[0]["server_url"] == "http://localhost:8080" - - -def test_from_azure_ai_tools_code_interpreter() -> None: - """Test from_azure_ai_tools with Code Interpreter tool.""" - ci_tool = CodeInterpreterTool(container=AutoCodeInterpreterToolParam(file_ids=["file-1"])) - parsed_tools = from_azure_ai_tools([ci_tool]) - assert len(parsed_tools) == 1 - assert parsed_tools[0]["type"] == "code_interpreter" - - -def test_from_azure_ai_tools_file_search() -> None: - """Test from_azure_ai_tools with File Search tool.""" - fs_tool = FileSearchTool(vector_store_ids=["vs-1"], max_num_results=5) - parsed_tools = from_azure_ai_tools([fs_tool]) - assert len(parsed_tools) == 1 - assert parsed_tools[0]["type"] == "file_search" - assert parsed_tools[0]["vector_store_ids"] == ["vs-1"] - assert parsed_tools[0]["max_num_results"] == 5 - - -def test_from_azure_ai_tools_web_search() -> None: - """Test from_azure_ai_tools with Web Search tool.""" - ws_tool = WebSearchPreviewTool( - user_location=ApproximateLocation(city="Seattle", country="US", region="WA", timezone="PST") - ) - parsed_tools = from_azure_ai_tools([ws_tool]) - assert len(parsed_tools) == 1 - assert parsed_tools[0]["type"] == "web_search_preview" - assert parsed_tools[0]["user_location"]["city"] == "Seattle" - - -# endregion - -# region Integration Tests - - -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - return f"The weather in {location} is sunny with a high of 25°C." - - -class OutputStruct(BaseModel): - """A structured output for testing purposes.""" - - location: str - weather: str - - -@fixture -async def client() -> AsyncGenerator[AzureAIClient, None]: - """Create a client to test with.""" - agent_name = f"test-agent-{uuid4()}" - endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"] - async with ( - AzureCliCredential() as credential, - AIProjectClient(endpoint=endpoint, credential=credential) as project_client, - ): - client = AzureAIClient( - project_client=project_client, - agent_name=agent_name, - ) - try: - assert client.function_invocation_configuration - # Need at least 2 iterations for tool_choice tests: one to get function call, one to get final response - client.function_invocation_configuration["max_iterations"] = 2 - yield client - finally: - await project_client.agents.delete(agent_name=agent_name) - - -# region Factory Method Tests - - -def test_get_code_interpreter_tool_basic() -> None: - """Test get_code_interpreter_tool returns CodeInterpreterTool.""" - tool = AzureAIClient.get_code_interpreter_tool() - assert isinstance(tool, CodeInterpreterTool) - - -def test_get_code_interpreter_tool_with_file_ids() -> None: - """Test get_code_interpreter_tool with file_ids.""" - tool = AzureAIClient.get_code_interpreter_tool(file_ids=["file-123", "file-456"]) - assert isinstance(tool, CodeInterpreterTool) - assert tool["container"]["file_ids"] == ["file-123", "file-456"] - - -def test_get_code_interpreter_tool_with_content() -> None: - """Test get_code_interpreter_tool accepts Content.from_hosted_file in file_ids.""" - from agent_framework import Content - - content = Content.from_hosted_file("file-content-123") - tool = AzureAIClient.get_code_interpreter_tool(file_ids=[content]) - assert isinstance(tool, CodeInterpreterTool) - assert tool["container"]["file_ids"] == ["file-content-123"] - - -def test_get_code_interpreter_tool_with_mixed_file_ids() -> None: - """Test get_code_interpreter_tool accepts a mix of strings and Content objects.""" - from agent_framework import Content - - content = Content.from_hosted_file("file-from-content") - tool = AzureAIClient.get_code_interpreter_tool(file_ids=["file-plain", content]) - assert isinstance(tool, CodeInterpreterTool) - assert sorted(tool["container"]["file_ids"]) == ["file-from-content", "file-plain"] - - -def test_get_code_interpreter_tool_content_unsupported_type() -> None: - """Test get_code_interpreter_tool raises ValueError for unsupported Content types.""" - from agent_framework import Content - - content = Content.from_hosted_vector_store("vs-123") - with pytest.raises(ValueError, match="Unsupported Content type"): - AzureAIClient.get_code_interpreter_tool(file_ids=[content]) - - -def test_get_file_search_tool_basic() -> None: - """Test get_file_search_tool returns FileSearchTool.""" - tool = AzureAIClient.get_file_search_tool(vector_store_ids=["vs-123"]) - assert isinstance(tool, FileSearchTool) - assert tool["vector_store_ids"] == ["vs-123"] - - -def test_get_file_search_tool_with_options() -> None: - """Test get_file_search_tool with max_num_results.""" - tool = AzureAIClient.get_file_search_tool( - vector_store_ids=["vs-123"], - max_num_results=10, - ) - assert isinstance(tool, FileSearchTool) - assert tool["max_num_results"] == 10 - - -def test_get_file_search_tool_requires_vector_store_ids() -> None: - """Test get_file_search_tool raises ValueError when vector_store_ids is empty.""" - with pytest.raises(ValueError, match="vector_store_ids"): - AzureAIClient.get_file_search_tool(vector_store_ids=[]) - - -def test_get_web_search_tool_basic() -> None: - """Test get_web_search_tool returns WebSearchPreviewTool.""" - tool = AzureAIClient.get_web_search_tool() - assert isinstance(tool, WebSearchPreviewTool) - - -def test_get_web_search_tool_with_location() -> None: - """Test get_web_search_tool with user_location.""" - tool = AzureAIClient.get_web_search_tool( - user_location={"city": "Seattle", "country": "US"}, - ) - assert isinstance(tool, WebSearchPreviewTool) - assert tool.user_location is not None - assert tool.user_location.city == "Seattle" - assert tool.user_location.country == "US" - - -def test_get_web_search_tool_with_search_context_size() -> None: - """Test get_web_search_tool with search_context_size.""" - tool = AzureAIClient.get_web_search_tool(search_context_size="high") - assert isinstance(tool, WebSearchPreviewTool) - assert tool.search_context_size == "high" - - -def test_get_mcp_tool_basic() -> None: - """Test get_mcp_tool returns MCPTool.""" - tool = AzureAIClient.get_mcp_tool(name="test_mcp", url="https://example.com") - assert isinstance(tool, MCPTool) - assert tool["server_label"] == "test_mcp" - assert tool["server_url"] == "https://example.com" - - -def test_get_mcp_tool_with_description() -> None: - """Test get_mcp_tool with description.""" - tool = AzureAIClient.get_mcp_tool( - name="test_mcp", - url="https://example.com", - description="Test MCP server", - ) - assert tool["server_description"] == "Test MCP server" - - -def test_get_mcp_tool_with_project_connection_id() -> None: - """Test get_mcp_tool with project_connection_id.""" - tool = AzureAIClient.get_mcp_tool( - name="test_mcp", - project_connection_id="conn-123", - ) - assert tool["project_connection_id"] == "conn-123" - - -def test_get_image_generation_tool_basic() -> None: - """Test get_image_generation_tool returns ImageGenTool.""" - tool = AzureAIClient.get_image_generation_tool() - assert isinstance(tool, ImageGenTool) - - -def test_get_image_generation_tool_with_options() -> None: - """Test get_image_generation_tool with various options.""" - tool = AzureAIClient.get_image_generation_tool( - size="1024x1024", - quality="high", - output_format="png", - ) - assert isinstance(tool, ImageGenTool) - assert tool["size"] == "1024x1024" - assert tool["quality"] == "high" - assert tool["output_format"] == "png" - - -# endregion - - -# region Azure AI Search Citation Enhancement Tests - - -def test_extract_azure_search_urls_with_dict_items(mock_project_client: MagicMock) -> None: - """Test _extract_azure_search_urls with dict-style output (after JSON parsing).""" - client = create_test_azure_ai_client(mock_project_client) - mock_output = { - "documents": [{"id": "1", "url": "https://search.example.com/"}], - "get_urls": [ - "https://search.example.com/indexes/idx/docs/1?api-version=2024-07-01", - "https://search.example.com/indexes/idx/docs/2?api-version=2024-07-01", - ], - } - mock_search_item = MagicMock() - mock_search_item.type = "azure_ai_search_call_output" - mock_search_item.output = mock_output - - mock_call_item = MagicMock() - mock_call_item.type = "azure_ai_search_call" - - mock_msg_item = MagicMock() - mock_msg_item.type = "message" - - urls = client._extract_azure_search_urls([mock_call_item, mock_search_item, mock_msg_item]) - assert len(urls) == 2 - assert urls[0] == "https://search.example.com/indexes/idx/docs/1?api-version=2024-07-01" - assert urls[1] == "https://search.example.com/indexes/idx/docs/2?api-version=2024-07-01" - - -def test_extract_azure_search_urls_with_object_items(mock_project_client: MagicMock) -> None: - """Test _extract_azure_search_urls with object-style output items.""" - client = create_test_azure_ai_client(mock_project_client) - mock_output = MagicMock() - mock_output.get_urls = ["https://example.com/doc/1", "https://example.com/doc/2"] - mock_item = MagicMock() - mock_item.type = "azure_ai_search_call_output" - mock_item.output = mock_output - - urls = client._extract_azure_search_urls([mock_item]) - assert urls == ["https://example.com/doc/1", "https://example.com/doc/2"] - - -def test_extract_azure_search_urls_no_search_items(mock_project_client: MagicMock) -> None: - """Test _extract_azure_search_urls with no search output items.""" - client = create_test_azure_ai_client(mock_project_client) - mock_item = MagicMock() - mock_item.type = "message" - urls = client._extract_azure_search_urls([mock_item]) - assert urls == [] - - -def test_extract_azure_search_urls_with_json_string_output(mock_project_client: MagicMock) -> None: - """Test _extract_azure_search_urls with JSON string output (non-streaming pydantic extra field).""" - client = create_test_azure_ai_client(mock_project_client) - json_output = json.dumps({ - "documents": [{"id": "1"}], - "get_urls": [ - "https://search.example.com/indexes/idx/docs/1?api-version=2024-07-01", - ], - }) - mock_item = MagicMock() - mock_item.type = "azure_ai_search_call_output" - mock_item.output = json_output - - urls = client._extract_azure_search_urls([mock_item]) - assert len(urls) == 1 - assert urls[0] == "https://search.example.com/indexes/idx/docs/1?api-version=2024-07-01" - - -def test_get_search_doc_url_valid(mock_project_client: MagicMock) -> None: - """Test _get_search_doc_url with valid doc_N title.""" - client = create_test_azure_ai_client(mock_project_client) - get_urls = ["https://example.com/doc/0", "https://example.com/doc/1", "https://example.com/doc/2"] - - assert client._get_search_doc_url("doc_0", get_urls) == "https://example.com/doc/0" - assert client._get_search_doc_url("doc_1", get_urls) == "https://example.com/doc/1" - assert client._get_search_doc_url("doc_2", get_urls) == "https://example.com/doc/2" - - -def test_get_search_doc_url_out_of_range(mock_project_client: MagicMock) -> None: - """Test _get_search_doc_url with out-of-range index.""" - client = create_test_azure_ai_client(mock_project_client) - get_urls = ["https://example.com/doc/0"] - assert client._get_search_doc_url("doc_5", get_urls) is None - - -def test_get_search_doc_url_no_match(mock_project_client: MagicMock) -> None: - """Test _get_search_doc_url with non-matching title.""" - client = create_test_azure_ai_client(mock_project_client) - get_urls = ["https://example.com/doc/0"] - assert client._get_search_doc_url("some_title", get_urls) is None - assert client._get_search_doc_url(None, get_urls) is None - assert client._get_search_doc_url("doc_0", []) is None - - -def test_enrich_annotations_with_search_urls(mock_project_client: MagicMock) -> None: - """Test _enrich_annotations_with_search_urls enriches citation annotations.""" - client = create_test_azure_ai_client(mock_project_client) - get_urls = [ - "https://search.example.com/indexes/idx/docs/16?api-version=2024-07-01", - "https://search.example.com/indexes/idx/docs/41?api-version=2024-07-01", - ] - - content = Content.from_text(text="test response") - content.annotations = [ - { - "type": "citation", - "title": "doc_0", - "url": "https://search.example.com/", - }, - { - "type": "citation", - "title": "doc_1", - "url": "https://search.example.com/", - }, - ] - - client._enrich_annotations_with_search_urls([content], get_urls) - - assert content.annotations[0]["additional_properties"]["get_url"] == get_urls[0] - assert content.annotations[1]["additional_properties"]["get_url"] == get_urls[1] - - -def test_enrich_annotations_no_match(mock_project_client: MagicMock) -> None: - """Test _enrich_annotations_with_search_urls with non-matching titles.""" - client = create_test_azure_ai_client(mock_project_client) - get_urls = ["https://search.example.com/indexes/idx/docs/16?api-version=2024-07-01"] - - content = Content.from_text(text="test response") - content.annotations = [ - { - "type": "citation", - "title": "some_title", - "url": "https://search.example.com/", - }, - ] - - client._enrich_annotations_with_search_urls([content], get_urls) - assert "additional_properties" not in content.annotations[0] or "get_url" not in content.annotations[0].get( - "additional_properties", {} - ) - - -def test_enrich_annotations_empty_get_urls(mock_project_client: MagicMock) -> None: - """Test _enrich_annotations_with_search_urls with empty get_urls.""" - client = create_test_azure_ai_client(mock_project_client) - content = Content.from_text(text="test") - content.annotations = [{"type": "citation", "title": "doc_0", "url": "https://example.com/"}] - - # Should not raise or modify - client._enrich_annotations_with_search_urls([content], []) - assert "additional_properties" not in content.annotations[0] - - -async def test_inner_get_response_enriches_non_streaming(mock_project_client: MagicMock) -> None: - """Test _inner_get_response enriches url_citation annotations for non-streaming responses.""" - client = create_test_azure_ai_client(mock_project_client) - - # Build a ChatResponse with citation annotations and a raw_representation carrying search output - content = Content.from_text(text="Here is the result【5:0†source】.") - content.annotations = [ - Annotation(type="citation", title="doc_0", url="https://search.example.com/"), - ] - msg = Message(role="assistant", contents=[content]) - mock_raw = MagicMock() - mock_search_output = MagicMock() - mock_search_output.type = "azure_ai_search_call_output" - mock_search_output_data = MagicMock() - mock_search_output_data.get_urls = [ - "https://search.example.com/indexes/idx/docs/16?api-version=2024-07-01", - ] - mock_search_output.output = mock_search_output_data - mock_raw.output = [mock_search_output] - - base_response = ChatResponse(messages=[msg], raw_representation=mock_raw) - - async def _fake_awaitable() -> ChatResponse: - return base_response - - with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=_fake_awaitable()): - result_awaitable = client._inner_get_response(messages=[], options={}, stream=False) - result = await result_awaitable # type: ignore[misc] - - ann = result.messages[0].contents[0].annotations[0] - assert ann["additional_properties"]["get_url"] == ( - "https://search.example.com/indexes/idx/docs/16?api-version=2024-07-01" - ) - - -async def test_inner_get_response_no_search_output_non_streaming(mock_project_client: MagicMock) -> None: - """Test _inner_get_response passes through when no search output exists.""" - client = create_test_azure_ai_client(mock_project_client) - - content = Content.from_text(text="Hello world") - msg = Message(role="assistant", contents=[content]) - mock_raw = MagicMock() - mock_raw.output = [] - base_response = ChatResponse(messages=[msg], raw_representation=mock_raw) - - async def _fake_awaitable() -> ChatResponse: - return base_response - - with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=_fake_awaitable()): - result_awaitable = client._inner_get_response(messages=[], options={}, stream=False) - result = await result_awaitable # type: ignore[misc] - - assert result.messages[0].contents[0].text == "Hello world" - - -def _create_mock_stream() -> MagicMock: - """Create a mock ResponseStream with working with_transform_hook.""" - mock_stream = MagicMock(spec=ResponseStream) - mock_stream._transform_hooks = [] - mock_stream.with_transform_hook.side_effect = lambda hook: mock_stream._transform_hooks.append(hook) or mock_stream - return mock_stream - - -def test_inner_get_response_streaming_registers_hook(mock_project_client: MagicMock) -> None: - """Test _inner_get_response appends a transform hook to the stream for streaming responses.""" - client = create_test_azure_ai_client(mock_project_client) - - mock_stream = _create_mock_stream() - - with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=mock_stream): - result = client._inner_get_response(messages=[], options={}, stream=True) - - assert result is mock_stream - assert len(mock_stream._transform_hooks) == 1 - - -def test_streaming_hook_captures_search_urls(mock_project_client: MagicMock) -> None: - """Test the streaming transform hook captures get_urls from search output events.""" - client = create_test_azure_ai_client(mock_project_client) - - mock_stream = _create_mock_stream() - - with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=mock_stream): - client._inner_get_response(messages=[], options={}, stream=True) - - hook = mock_stream._transform_hooks[0] - - # Simulate azure_ai_search_call_output event - mock_item = MagicMock() - mock_item.type = "azure_ai_search_call_output" - mock_item.output = MagicMock() - mock_item.output.get_urls = [ - "https://search.example.com/indexes/idx/docs/16?api-version=2024-07-01", - ] - - raw_event = MagicMock() - raw_event.type = "response.output_item.added" - raw_event.item = mock_item - - update = ChatResponseUpdate(raw_representation=raw_event) - result = hook(update) - assert result is update # passes through (no annotations to enrich) - - -def test_streaming_hook_enriches_url_citation(mock_project_client: MagicMock) -> None: - """Test the streaming transform hook enriches url_citation annotations with get_urls.""" - client = create_test_azure_ai_client(mock_project_client) - - mock_stream = _create_mock_stream() - - with patch.object(RawOpenAIChatClient, "_inner_get_response", return_value=mock_stream): - client._inner_get_response(messages=[], options={}, stream=True) - - hook = mock_stream._transform_hooks[0] - - # Step 1: Feed search output event to capture URLs - mock_item = MagicMock() - mock_item.type = "azure_ai_search_call_output" - mock_item.output = MagicMock() - mock_item.output.get_urls = [ - "https://search.example.com/indexes/idx/docs/16?api-version=2024-07-01", - "https://search.example.com/indexes/idx/docs/41?api-version=2024-07-01", - ] - raw_output_event = MagicMock() - raw_output_event.type = "response.output_item.added" - raw_output_event.item = mock_item - hook(ChatResponseUpdate(raw_representation=raw_output_event)) - - # Step 2: Feed url_citation annotation event (annotation is always a dict in streaming) - raw_ann_event = MagicMock() - raw_ann_event.type = "response.output_text.annotation.added" - raw_ann_event.annotation = { - "type": "url_citation", - "title": "doc_0", - "url": "https://search.example.com/", - "start_index": 100, - "end_index": 112, - } - raw_ann_event.annotation_index = 0 - - result = hook(ChatResponseUpdate(raw_representation=raw_ann_event)) - - # Verify the result has enriched annotation - assert result.contents is not None - found = False - for content_item in result.contents: - if hasattr(content_item, "annotations") and content_item.annotations: - for ann in content_item.annotations: - if isinstance(ann, dict) and ann.get("title") == "doc_0": - found = True - assert ann["additional_properties"]["get_url"] == ( - "https://search.example.com/indexes/idx/docs/16?api-version=2024-07-01" - ) - assert found, "Expected url_citation annotation with enriched get_url" - - -def test_build_url_citation_content(mock_project_client: MagicMock) -> None: - """Test _build_url_citation_content creates Content with enriched Annotation.""" - client = create_test_azure_ai_client(mock_project_client) - get_urls = ["https://search.example.com/indexes/idx/docs/16?api-version=2024-07-01"] - - annotation_data = { - "type": "url_citation", - "title": "doc_0", - "url": "https://search.example.com/", - "start_index": 100, - "end_index": 112, - } - - raw_event = MagicMock() - raw_event.annotation_index = 0 - - content = client._build_url_citation_content(annotation_data, get_urls, raw_event) - - assert content.annotations is not None - ann = content.annotations[0] - assert ann["type"] == "citation" - assert ann["title"] == "doc_0" - assert ann["url"] == "https://search.example.com/" - assert ann["additional_properties"]["get_url"] == get_urls[0] - assert ann["annotated_regions"][0]["start_index"] == 100 - assert ann["annotated_regions"][0]["end_index"] == 112 - - -def test_build_url_citation_content_with_dict(mock_project_client: MagicMock) -> None: - """Test _build_url_citation_content handles dict-style annotation data.""" - client = create_test_azure_ai_client(mock_project_client) - get_urls = ["https://search.example.com/indexes/idx/docs/16?api-version=2024-07-01"] - - annotation_data = { - "type": "url_citation", - "title": "doc_1", - "url": "https://search.example.com/", - "start_index": 200, - "end_index": 215, - } - - raw_event = MagicMock() - raw_event.annotation_index = 1 - - content = client._build_url_citation_content(annotation_data, get_urls, raw_event) - - assert content.annotations is not None - ann = content.annotations[0] - assert ann["type"] == "citation" - assert ann["title"] == "doc_1" - # doc_1 is out of range for a 1-element get_urls, so no get_url - assert "get_url" not in ann.get("additional_properties", {}) - - -# region OAuth Consent - - -def test_parse_chunk_with_oauth_consent_request(mock_project_client: MagicMock) -> None: - """Test that a streaming oauth_consent_request output item is parsed into oauth_consent_request content. - - This reproduces the bug from issue #3950 where the event was logged as "Unparsed event" - and silently discarded, causing the agent run to complete with zero content. - """ - client = AzureAIClient(project_client=mock_project_client, agent_name="test") - chat_options: dict[str, Any] = {} - function_call_ids: dict[int, tuple[str, str]] = {} - - mock_item = MagicMock() - mock_item.type = "oauth_consent_request" - mock_item.consent_link = "https://login.microsoftonline.com/common/oauth2/authorize?client_id=abc123" - - mock_event = MagicMock() - mock_event.type = "response.output_item.added" - mock_event.item = mock_item - mock_event.output_index = 0 - - update = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids) - - assert len(update.contents) == 1 - consent_content = update.contents[0] - assert consent_content.type == "oauth_consent_request" - assert consent_content.consent_link == "https://login.microsoftonline.com/common/oauth2/authorize?client_id=abc123" - assert consent_content.user_input_request is True - - -def test_parse_response_with_oauth_consent_output_item(mock_project_client: MagicMock) -> None: - """Test that a non-streaming oauth_consent_request output item is parsed correctly.""" - client = AzureAIClient(project_client=mock_project_client, agent_name="test") - - mock_item = MagicMock() - mock_item.type = "oauth_consent_request" - mock_item.consent_link = "https://login.microsoftonline.com/consent?code=abc" - - mock_response = MagicMock() - mock_response.output = [mock_item] - mock_response.output_parsed = None - mock_response.metadata = {} - mock_response.id = "resp-oauth-1" - mock_response.model = "test-model" - mock_response.created_at = 1000000000 - mock_response.usage = None - mock_response.status = "completed" - - response = client._parse_response_from_openai(mock_response, {}) - - assert len(response.messages) > 0 - consent_contents = [c for c in response.messages[0].contents if c.type == "oauth_consent_request"] - assert len(consent_contents) == 1 - assert consent_contents[0].consent_link == "https://login.microsoftonline.com/consent?code=abc" - - -def test_parse_chunk_oauth_consent_no_link(mock_project_client: MagicMock) -> None: - """Test that a streaming oauth_consent_request with no consent_link produces empty contents.""" - client = AzureAIClient(project_client=mock_project_client, agent_name="test") - - mock_item = MagicMock() - mock_item.type = "oauth_consent_request" - mock_item.consent_link = "" - - mock_event = MagicMock() - mock_event.type = "response.output_item.added" - mock_event.item = mock_item - mock_event.output_index = 0 - - update = client._parse_chunk_from_openai(mock_event, {}, {}) - - assert not any(c.type == "oauth_consent_request" for c in update.contents) - - -def test_parse_response_oauth_consent_no_link(mock_project_client: MagicMock) -> None: - """Test that a non-streaming oauth_consent_request with no consent_link appends no content.""" - client = AzureAIClient(project_client=mock_project_client, agent_name="test") - - mock_item = MagicMock() - mock_item.type = "oauth_consent_request" - mock_item.consent_link = None - - mock_response = MagicMock() - mock_response.output = [mock_item] - mock_response.output_parsed = None - mock_response.metadata = {} - mock_response.id = "resp-oauth-2" - mock_response.model = "test-model" - mock_response.created_at = 1000000000 - mock_response.usage = None - mock_response.status = "completed" - - response = client._parse_response_from_openai(mock_response, {}) - - consent_contents = [c for c in response.messages[0].contents if c.type == "oauth_consent_request"] - assert len(consent_contents) == 0 - - -# endregion diff --git a/python/packages/azure-ai/tests/test_provider.py b/python/packages/azure-ai/tests/test_provider.py deleted file mode 100644 index 09eb8feeee..0000000000 --- a/python/packages/azure-ai/tests/test_provider.py +++ /dev/null @@ -1,682 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from agent_framework import Agent, FunctionTool -from agent_framework._mcp import MCPTool -from azure.ai.projects.models import ( - AgentVersionDetails, - PromptAgentDefinition, -) -from azure.ai.projects.models import ( - FunctionTool as AzureFunctionTool, -) - -from agent_framework_azure_ai import AzureAIProjectAgentProvider - - -@pytest.fixture -def mock_project_client() -> MagicMock: - """Fixture that provides a mock AIProjectClient.""" - mock_client = MagicMock() - - # Mock agents property - mock_client.agents = MagicMock() - mock_client.agents.create_version = AsyncMock() - - # Mock conversations property - mock_client.conversations = MagicMock() - mock_client.conversations.create = AsyncMock() - - # Mock telemetry property - mock_client.telemetry = MagicMock() - mock_client.telemetry.get_application_insights_connection_string = AsyncMock() - - # AIProjectClient.get_openai_client() is a sync accessor, even on the aio client. - mock_client.get_openai_client = MagicMock(return_value=MagicMock()) - - # Mock close method - mock_client.close = AsyncMock() - - return mock_client - - -@pytest.fixture -def mock_azure_credential() -> MagicMock: - """Fixture that provides a mock Azure credential.""" - return MagicMock() - - -@pytest.fixture -def azure_ai_unit_test_env(monkeypatch: pytest.MonkeyPatch) -> dict[str, str]: - """Fixture that sets up Azure AI environment variables for unit testing.""" - env_vars = { - "AZURE_AI_PROJECT_ENDPOINT": "https://test-project.cognitiveservices.azure.com/", - "AZURE_AI_MODEL_DEPLOYMENT_NAME": "test-model-deployment", - } - for key, value in env_vars.items(): - monkeypatch.setenv(key, value) - return env_vars - - -def test_provider_init_with_project_client(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider initialization with existing project_client.""" - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - assert provider._project_client is mock_project_client # type: ignore - assert not provider._should_close_client # type: ignore - - -def test_provider_init_with_credential_and_endpoint( - azure_ai_unit_test_env: dict[str, str], - mock_azure_credential: MagicMock, -) -> None: - """Test AzureAIProjectAgentProvider initialization with credential and endpoint.""" - with patch("agent_framework_azure_ai._project_provider.AIProjectClient") as mock_ai_project_client: - mock_client = MagicMock() - mock_ai_project_client.return_value = mock_client - - provider = AzureAIProjectAgentProvider( - project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - credential=mock_azure_credential, - ) - - assert provider._project_client is mock_client # type: ignore - assert provider._should_close_client # type: ignore - - # Verify AIProjectClient was called with correct parameters - mock_ai_project_client.assert_called_once() - - -def test_provider_init_missing_endpoint() -> None: - """Test AzureAIProjectAgentProvider initialization when endpoint is missing.""" - with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"} - - with pytest.raises(ValueError, match="Azure AI project endpoint is required"): - AzureAIProjectAgentProvider(credential=MagicMock()) - - -def test_provider_init_missing_credential(azure_ai_unit_test_env: dict[str, str]) -> None: - """Test AzureAIProjectAgentProvider initialization when credential is missing.""" - with pytest.raises(ValueError, match="Azure credential is required when project_client is not provided"): - AzureAIProjectAgentProvider( - project_endpoint=azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - ) - - -async def test_provider_create_agent( - mock_project_client: MagicMock, - azure_ai_unit_test_env: dict[str, str], -) -> None: - """Test AzureAIProjectAgentProvider.create_agent method.""" - with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = { - "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - } - - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Mock agent creation response - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = "Test Agent" - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.model = "gpt-4" - mock_agent_version.definition.instructions = "Test instructions" - mock_agent_version.definition.temperature = 0.7 - mock_agent_version.definition.top_p = 0.9 - mock_agent_version.definition.tools = [] - - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version) - - agent = await provider.create_agent( - name="test-agent", - model="gpt-4", - instructions="Test instructions", - description="Test Agent", - ) - - assert isinstance(agent, Agent) - assert agent.name == "test-agent" - mock_project_client.agents.create_version.assert_called_once() - - -async def test_provider_create_agent_with_env_model( - mock_project_client: MagicMock, - azure_ai_unit_test_env: dict[str, str], -) -> None: - """Test AzureAIProjectAgentProvider.create_agent uses model from env var.""" - with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = { - "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - } - - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Mock agent creation response - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = None - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.model = azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] - mock_agent_version.definition.instructions = None - mock_agent_version.definition.temperature = None - mock_agent_version.definition.top_p = None - mock_agent_version.definition.tools = [] - - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version) - - # Call without model parameter - should use env var - agent = await provider.create_agent(name="test-agent") - - assert isinstance(agent, Agent) - # Verify the model from env var was used - call_args = mock_project_client.agents.create_version.call_args - assert call_args[1]["definition"].model == azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] - - -async def test_provider_create_agent_missing_model(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider.create_agent raises when model is missing.""" - with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = {"project_endpoint": "https://test.com", "model_deployment_name": None} - - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - with pytest.raises(ValueError, match="Model deployment name is required"): - await provider.create_agent(name="test-agent") - - -async def test_provider_create_agent_with_rai_config( - mock_project_client: MagicMock, - azure_ai_unit_test_env: dict[str, str], -) -> None: - """Test AzureAIProjectAgentProvider.create_agent passes rai_config from default_options.""" - with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = { - "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - } - - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Mock agent creation response - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = None - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.model = "gpt-4" - mock_agent_version.definition.instructions = None - mock_agent_version.definition.temperature = None - mock_agent_version.definition.top_p = None - mock_agent_version.definition.tools = [] - - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version) - - # Create a mock RaiConfig-like object - mock_rai_config = MagicMock() - mock_rai_config.rai_policy_name = "policy-name" - - # Call create_agent with rai_config in default_options - await provider.create_agent( - name="test-agent", - model="gpt-4", - default_options={"rai_config": mock_rai_config}, - ) - - # Verify rai_config was passed to PromptAgentDefinition - call_args = mock_project_client.agents.create_version.call_args - definition = call_args[1]["definition"] - assert definition.rai_config is mock_rai_config - - -async def test_provider_create_agent_with_reasoning( - mock_project_client: MagicMock, - azure_ai_unit_test_env: dict[str, str], -) -> None: - """Test AzureAIProjectAgentProvider.create_agent passes reasoning from default_options.""" - with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = { - "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - } - - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Mock agent creation response - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = None - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.model = "gpt-5.2" - mock_agent_version.definition.instructions = None - mock_agent_version.definition.temperature = None - mock_agent_version.definition.top_p = None - mock_agent_version.definition.tools = [] - - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version) - - # Create a mock Reasoning-like object - mock_reasoning = MagicMock() - mock_reasoning.effort = "medium" - mock_reasoning.summary = "concise" - - # Call create_agent with reasoning in default_options - await provider.create_agent( - name="test-agent", - model="gpt-5.2", - default_options={"reasoning": mock_reasoning}, - ) - - # Verify reasoning was passed to PromptAgentDefinition - call_args = mock_project_client.agents.create_version.call_args - definition = call_args[1]["definition"] - assert definition.reasoning is mock_reasoning - - -async def test_provider_get_agent_with_name(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider.get_agent with name parameter.""" - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Mock agent response - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = "Test Agent" - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.model = "gpt-4" - mock_agent_version.definition.instructions = "Test instructions" - mock_agent_version.definition.temperature = None - mock_agent_version.definition.top_p = None - mock_agent_version.definition.tools = [] - - mock_agent_object = MagicMock() - mock_agent_object.versions.latest = mock_agent_version - - mock_project_client.agents = AsyncMock() - mock_project_client.agents.get.return_value = mock_agent_object - - agent = await provider.get_agent(name="test-agent") - - assert isinstance(agent, Agent) - assert agent.name == "test-agent" - mock_project_client.agents.get.assert_called_with(agent_name="test-agent") - - -async def test_provider_get_agent_with_reference(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider.get_agent with reference parameter.""" - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Mock agent response - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = "Test Agent" - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.model = "gpt-4" - mock_agent_version.definition.instructions = "Test instructions" - mock_agent_version.definition.temperature = None - mock_agent_version.definition.top_p = None - mock_agent_version.definition.tools = [] - - mock_project_client.agents = AsyncMock() - mock_project_client.agents.get_version.return_value = mock_agent_version - - agent_reference = {"name": "test-agent", "version": "1.0"} - agent = await provider.get_agent(reference=agent_reference) - - assert isinstance(agent, Agent) - assert agent.name == "test-agent" - mock_project_client.agents.get_version.assert_called_with(agent_name="test-agent", agent_version="1.0") - - -async def test_provider_get_agent_missing_parameters(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider.get_agent raises when no identifier provided.""" - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - with pytest.raises(ValueError, match="Either name or reference must be provided"): - await provider.get_agent() - - -async def test_provider_get_agent_missing_function_tools(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider.get_agent raises when required tools are missing.""" - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Mock agent with function tools - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = None - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.tools = [ - AzureFunctionTool(name="test_tool", parameters=[], strict=True, description="Test tool") - ] - - mock_agent_object = MagicMock() - mock_agent_object.versions.latest = mock_agent_version - - mock_project_client.agents = AsyncMock() - mock_project_client.agents.get.return_value = mock_agent_object - - with pytest.raises( - ValueError, match="The following prompt agent definition required tools were not provided: test_tool" - ): - await provider.get_agent(name="test-agent") - - -def test_provider_as_agent(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider.as_agent method.""" - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Create mock agent version - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = "Test Agent" - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.model = "gpt-4" - mock_agent_version.definition.instructions = "Test instructions" - mock_agent_version.definition.temperature = 0.7 - mock_agent_version.definition.top_p = 0.9 - mock_agent_version.definition.tools = [] - - with patch("agent_framework_azure_ai._project_provider.AzureAIClient") as mock_azure_ai_client: - agent = provider.as_agent(mock_agent_version) - - assert isinstance(agent, Agent) - assert agent.name == "test-agent" - assert agent.description == "Test Agent" - - # Verify AzureAIClient was called with correct parameters - mock_azure_ai_client.assert_called_once() - call_kwargs = mock_azure_ai_client.call_args[1] - assert call_kwargs["project_client"] is mock_project_client - assert call_kwargs["agent_name"] == "test-agent" - assert call_kwargs["agent_version"] == "1.0" - assert call_kwargs["agent_description"] == "Test Agent" - assert call_kwargs["model_deployment_name"] == "gpt-4" - - -def test_provider_merge_tools_skips_function_tool_dicts(mock_project_client: MagicMock) -> None: - """Test that _merge_tools skips function tool dicts but keeps other hosted tools.""" - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Create a mock FunctionTool to provide as implementation - mock_ai_function = create_mock_ai_function("my_function", "My function description") - - # Definition tools include a function tool (dict) and an MCP tool - definition_tools = [ - {"type": "function", "name": "my_function", "parameters": {}}, # Should be skipped - {"type": "mcp", "server_label": "my_mcp", "server_url": "http://localhost:8080"}, # Should be converted - ] - - # Call _merge_tools with user-provided function implementation - merged = provider._merge_tools(definition_tools, [mock_ai_function]) # type: ignore - - # Should have 2 items: the converted MCP dict and the user-provided FunctionTool - assert len(merged) == 2 - - # Check that the function tool dict was NOT included (it was skipped) - function_dicts = [t for t in merged if isinstance(t, dict) and t.get("type") == "function"] - assert len(function_dicts) == 0 - - # Check that the MCP tool was converted to dict - mcp_tools = [t for t in merged if isinstance(t, dict) and t.get("type") == "mcp"] - assert len(mcp_tools) == 1 - assert mcp_tools[0]["server_label"] == "my_mcp" - - # Check that the user-provided FunctionTool was included - ai_functions = [t for t in merged if isinstance(t, FunctionTool)] - assert len(ai_functions) == 1 - assert ai_functions[0].name == "my_function" - - -async def test_provider_context_manager(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider async context manager.""" - with patch("agent_framework_azure_ai._project_provider.AIProjectClient") as mock_ai_project_client: - mock_client = MagicMock() - mock_client.close = AsyncMock() - mock_ai_project_client.return_value = mock_client - - with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = { - "project_endpoint": "https://test.com", - "model_deployment_name": "test-model", - } - - async with AzureAIProjectAgentProvider(credential=MagicMock()) as provider: - assert provider._project_client is mock_client # type: ignore - - # Should call close after exiting context - mock_client.close.assert_called_once() - - -async def test_provider_context_manager_with_provided_client(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider context manager doesn't close provided client.""" - mock_project_client.close = AsyncMock() - - async with AzureAIProjectAgentProvider(project_client=mock_project_client) as provider: - assert provider._project_client is mock_project_client # type: ignore - - # Should NOT call close when client was provided - mock_project_client.close.assert_not_called() - - -async def test_provider_close_method(mock_project_client: MagicMock) -> None: - """Test AzureAIProjectAgentProvider.close method.""" - with patch("agent_framework_azure_ai._project_provider.AIProjectClient") as mock_ai_project_client: - mock_client = MagicMock() - mock_client.close = AsyncMock() - mock_ai_project_client.return_value = mock_client - - with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: - mock_load_settings.return_value = { - "project_endpoint": "https://test.com", - "model_deployment_name": "test-model", - } - - provider = AzureAIProjectAgentProvider(credential=MagicMock()) - await provider.close() - - mock_client.close.assert_called_once() - - -def test_create_text_format_config_sets_strict_for_pydantic_models() -> None: - """Test that create_text_format_config sets strict=True for Pydantic models.""" - from pydantic import BaseModel - - from agent_framework_azure_ai._shared import create_text_format_config - - class TestSchema(BaseModel): - subject: str - summary: str - - result = create_text_format_config(TestSchema) - - # Verify strict=True is set - assert result["strict"] is True - assert result["name"] == "TestSchema" - assert "schema" in result - - -class MockMCPTool(MCPTool): # pyright: ignore[reportGeneralTypeIssues] - """A mock MCPTool subclass for testing that passes isinstance checks. - - Note: This intentionally does NOT call super().__init__() because MCPTool's - constructor requires MCP server connection parameters that aren't needed for - unit testing. We only need isinstance(obj, MCPTool) to return True. - """ - - def __init__(self, functions: list[FunctionTool] | None = None) -> None: - self.name = "MockMCPTool" - self.description = "A mock MCP tool for testing" - self.is_connected = False - self._mock_functions = functions or [] - self._connect_called = False - - @property - def functions(self) -> list[FunctionTool]: - return self._mock_functions - - async def connect(self, *, reset: bool = False) -> None: - self._connect_called = True - self.is_connected = True - - -@pytest.fixture -def mock_mcp_tool() -> MockMCPTool: - """Fixture that provides a mock MCPTool.""" - mock_functions = [ - create_mock_ai_function("mcp_function_1", "First MCP function"), - create_mock_ai_function("mcp_function_2", "Second MCP function"), - ] - return MockMCPTool(functions=mock_functions) - - -def create_mock_ai_function(name: str, description: str = "A mock function") -> FunctionTool: - """Create a real FunctionTool for testing.""" - - def mock_func(arg: str) -> str: - return f"Result from {name}: {arg}" - - return FunctionTool(func=mock_func, name=name, description=description, approval_mode="never_require") - - -async def test_provider_create_agent_with_mcp_tool( - mock_project_client: MagicMock, - azure_ai_unit_test_env: dict[str, str], - mock_mcp_tool: "MockMCPTool", -) -> None: - """Test that create_agent connects MCP tools and passes discovered functions to Azure AI.""" - - # Patch normalize_tools to return tools as-is in a list (avoids callable check) - def mock_normalize_tools(tools): - if tools is None: - return [] - if isinstance(tools, list): - return tools - return [tools] - - with ( - patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings, - patch("agent_framework_azure_ai._project_provider.to_azure_ai_tools") as mock_to_azure_tools, - patch("agent_framework_azure_ai._project_provider.normalize_tools", side_effect=mock_normalize_tools), - ): - mock_load_settings.return_value = { - "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - } - mock_to_azure_tools.return_value = [{"type": "function", "name": "mcp_function_1"}] - - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Mock agent creation response - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = "Test Agent" - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.model = "gpt-4" - mock_agent_version.definition.instructions = "Test instructions" - mock_agent_version.definition.tools = [] - - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version) - - # Call create_agent with MCP tool - await provider.create_agent( - name="test-agent", - model="gpt-4", - instructions="Test instructions", - tools=mock_mcp_tool, - ) - - # Verify MCP tool was connected - assert mock_mcp_tool._connect_called is True - assert mock_mcp_tool.is_connected is True - - # Verify to_azure_ai_tools was called with the discovered MCP functions - mock_to_azure_tools.assert_called_once() - tools_passed = mock_to_azure_tools.call_args[0][0] - assert len(tools_passed) == 2 - assert tools_passed[0].name == "mcp_function_1" - assert tools_passed[1].name == "mcp_function_2" - - -async def test_provider_create_agent_with_mcp_and_regular_tools( - mock_project_client: MagicMock, - azure_ai_unit_test_env: dict[str, str], - mock_mcp_tool: "MockMCPTool", -) -> None: - """Test that create_agent handles both MCP tools and regular FunctionTools.""" - # Create a regular FunctionTool - regular_function = create_mock_ai_function("regular_function", "A regular function") - - # Patch normalize_tools to return tools as-is in a list (avoids callable check) - def mock_normalize_tools(tools): - if tools is None: - return [] - if isinstance(tools, list): - return tools - return [tools] - - with ( - patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings, - patch("agent_framework_azure_ai._project_provider.to_azure_ai_tools") as mock_to_azure_tools, - patch("agent_framework_azure_ai._project_provider.normalize_tools", side_effect=mock_normalize_tools), - ): - mock_load_settings.return_value = { - "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], - "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - } - mock_to_azure_tools.return_value = [] - - provider = AzureAIProjectAgentProvider(project_client=mock_project_client) - - # Mock agent creation response - mock_agent_version = MagicMock(spec=AgentVersionDetails) - mock_agent_version.id = "agent-id" - mock_agent_version.name = "test-agent" - mock_agent_version.version = "1.0" - mock_agent_version.description = None - mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) - mock_agent_version.definition.model = "gpt-4" - mock_agent_version.definition.instructions = None - mock_agent_version.definition.tools = [] - - mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version) - - # Pass both MCP tool and regular function - await provider.create_agent( - name="test-agent", - model="gpt-4", - tools=[mock_mcp_tool, regular_function], - ) - - # Verify to_azure_ai_tools was called with: - # - The regular FunctionTool (1) - # - The 2 discovered MCP functions - mock_to_azure_tools.assert_called_once() - tools_passed = mock_to_azure_tools.call_args[0][0] - assert len(tools_passed) == 3 # 1 regular + 2 MCP functions - - # Verify the regular function is in the list - tool_names = [t.name for t in tools_passed] - assert "regular_function" in tool_names - assert "mcp_function_1" in tool_names - assert "mcp_function_2" in tool_names diff --git a/python/packages/azure-ai/tests/test_shared.py b/python/packages/azure-ai/tests/test_shared.py deleted file mode 100644 index 845638ceee..0000000000 --- a/python/packages/azure-ai/tests/test_shared.py +++ /dev/null @@ -1,494 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import os -from unittest.mock import MagicMock, patch - -import pytest -from agent_framework import ( - FunctionTool, -) -from agent_framework.exceptions import IntegrationInvalidRequestException -from azure.ai.agents.models import CodeInterpreterToolDefinition -from pydantic import BaseModel - -from agent_framework_azure_ai import AzureAIAgentClient -from agent_framework_azure_ai._shared import ( - _convert_response_format, # type: ignore - _convert_sdk_tool, # type: ignore - _extract_project_connection_id, # type: ignore - create_text_format_config, - from_azure_ai_agent_tools, - from_azure_ai_tools, - to_azure_ai_agent_tools, - to_azure_ai_tools, -) -from agent_framework_azure_ai._shared import ( - _prepare_mcp_tool_dict_for_azure_ai as _prepare_mcp_tool_for_azure_ai, # type: ignore -) - - -def test_extract_project_connection_id_direct() -> None: - """Test extracting project_connection_id from direct key.""" - result = _extract_project_connection_id({"project_connection_id": "my-connection"}) - assert result == "my-connection" - - -def test_extract_project_connection_id_from_connection_name() -> None: - """Test extracting project_connection_id from connection.name structure.""" - result = _extract_project_connection_id({"connection": {"name": "my-connection"}}) - assert result == "my-connection" - - -def test_extract_project_connection_id_none() -> None: - """Test returns None when no connection info.""" - assert _extract_project_connection_id(None) is None - assert _extract_project_connection_id({}) is None - - -def test_to_azure_ai_agent_tools_empty() -> None: - """Test converting empty/None tools list.""" - assert to_azure_ai_agent_tools(None) == [] - assert to_azure_ai_agent_tools([]) == [] - - -def test_to_azure_ai_agent_tools_function_tool() -> None: - """Test converting FunctionTool to tool definition.""" - - def my_func(arg: str) -> str: - """My function.""" - return arg - - func_tool = FunctionTool(func=my_func, name="my_func", description="My function.") # type: ignore - result = to_azure_ai_agent_tools([func_tool]) # type: ignore - assert len(result) == 1 - assert result[0]["type"] == "function" - assert result[0]["function"]["name"] == "my_func" - - -def test_to_azure_ai_agent_tools_code_interpreter() -> None: - """Test converting code_interpreter dict tool.""" - tool = AzureAIAgentClient.get_code_interpreter_tool() - result = to_azure_ai_agent_tools([tool]) - assert len(result) == 1 - assert isinstance(result[0], CodeInterpreterToolDefinition) - - -def test_to_azure_ai_agent_tools_web_search_missing_connection() -> None: - """Test web search tool raises without connection info.""" - # Clear any environment variables that could provide connection info - with patch.dict( - os.environ, - {"BING_CONNECTION_ID": "", "BING_CUSTOM_CONNECTION_ID": "", "BING_CUSTOM_INSTANCE_NAME": ""}, - clear=False, - ): - # Also need to unset the keys if they exist - env_backup = {} - for key in ["BING_CONNECTION_ID", "BING_CUSTOM_CONNECTION_ID", "BING_CUSTOM_INSTANCE_NAME"]: - env_backup[key] = os.environ.pop(key, None) - try: - # get_web_search_tool now raises ValueError when no connection info is available - with pytest.raises(ValueError, match="Azure AI Agents requires a Bing connection"): - AzureAIAgentClient.get_web_search_tool() - finally: - # Restore environment - for key, value in env_backup.items(): - if value is not None: - os.environ[key] = value - - -def test_to_azure_ai_agent_tools_dict_passthrough() -> None: - """Test dict tools pass through unchanged.""" - tool_dict = {"type": "custom", "config": "value"} - result = to_azure_ai_agent_tools([tool_dict]) - assert result[0] == tool_dict - - -def test_to_azure_ai_agent_tools_unsupported_type() -> None: - """Test unsupported tool type passes through unchanged.""" - - class UnsupportedTool: - pass - - unsupported = UnsupportedTool() - result = to_azure_ai_agent_tools([unsupported]) # type: ignore - assert len(result) == 1 - assert result[0] is unsupported # Passed through unchanged - - -def test_from_azure_ai_agent_tools_empty() -> None: - """Test converting empty/None tools list.""" - assert from_azure_ai_agent_tools(None) == [] - assert from_azure_ai_agent_tools([]) == [] - - -def test_from_azure_ai_agent_tools_code_interpreter() -> None: - """Test converting CodeInterpreterToolDefinition.""" - tool = CodeInterpreterToolDefinition() - result = from_azure_ai_agent_tools([tool]) - assert len(result) == 1 - assert result[0] == {"type": "code_interpreter"} - - -def test_convert_sdk_tool_code_interpreter() -> None: - """Test _convert_sdk_tool with code_interpreter type.""" - tool = MagicMock() - tool.type = "code_interpreter" - result = _convert_sdk_tool(tool) - assert result == {"type": "code_interpreter"} - - -def test_convert_sdk_tool_function_returns_none() -> None: - """Test _convert_sdk_tool with function type returns None.""" - tool = MagicMock() - tool.type = "function" - result = _convert_sdk_tool(tool) - assert result is None - - -def test_convert_sdk_tool_mcp_returns_none() -> None: - """Test _convert_sdk_tool with mcp type returns None.""" - tool = MagicMock() - tool.type = "mcp" - result = _convert_sdk_tool(tool) - assert result is None - - -def test_convert_sdk_tool_file_search() -> None: - """Test _convert_sdk_tool with file_search type.""" - tool = MagicMock() - tool.type = "file_search" - tool.file_search = MagicMock() - tool.file_search.vector_store_ids = ["vs-1", "vs-2"] - result = _convert_sdk_tool(tool) - assert result["type"] == "file_search" - assert result["vector_store_ids"] == ["vs-1", "vs-2"] - - -def test_convert_sdk_tool_bing_grounding() -> None: - """Test _convert_sdk_tool with bing_grounding type.""" - tool = MagicMock() - tool.type = "bing_grounding" - tool.bing_grounding = MagicMock() - tool.bing_grounding.connection_id = "conn-123" - result = _convert_sdk_tool(tool) - assert result["type"] == "bing_grounding" - assert result["connection_id"] == "conn-123" - - -def test_convert_sdk_tool_bing_custom_search() -> None: - """Test _convert_sdk_tool with bing_custom_search type.""" - tool = MagicMock() - tool.type = "bing_custom_search" - tool.bing_custom_search = MagicMock() - tool.bing_custom_search.connection_id = "conn-123" - tool.bing_custom_search.instance_name = "my-instance" - result = _convert_sdk_tool(tool) - assert result["type"] == "bing_custom_search" - assert result["connection_id"] == "conn-123" - assert result["instance_name"] == "my-instance" - - -def test_to_azure_ai_tools_empty() -> None: - """Test converting empty/None tools list.""" - assert to_azure_ai_tools(None) == [] - assert to_azure_ai_tools([]) == [] - - -def test_to_azure_ai_tools_code_interpreter_with_file_ids() -> None: - """Test converting code_interpreter dict tool with file inputs.""" - tool = { - "type": "code_interpreter", - "file_ids": ["file-123"], - } - result = to_azure_ai_tools([tool]) - assert len(result) == 1 - assert result[0]["type"] == "code_interpreter" - - -def test_to_azure_ai_tools_function_tool() -> None: - """Test converting FunctionTool.""" - - def my_func(arg: str) -> str: - """My function.""" - return arg - - func_tool = FunctionTool(func=my_func, name="my_func", description="My function.") # type: ignore - result = to_azure_ai_tools([func_tool]) # type: ignore - assert len(result) == 1 - assert result[0]["type"] == "function" - assert result[0]["name"] == "my_func" - - -def test_to_azure_ai_tools_file_search() -> None: - """Test converting file_search dict tool.""" - tool = { - "type": "file_search", - "vector_store_ids": ["vs-123"], - "max_num_results": 10, - } - result = to_azure_ai_tools([tool]) - assert len(result) == 1 - assert result[0]["type"] == "file_search" - assert result[0]["vector_store_ids"] == ["vs-123"] - assert result[0]["max_num_results"] == 10 - - -def test_to_azure_ai_tools_web_search_with_location() -> None: - """Test converting web_search dict tool with user location.""" - tool = { - "type": "web_search_preview", - "user_location": { - "city": "Seattle", - "country": "US", - "region": "WA", - "timezone": "PST", - }, - } - result = to_azure_ai_tools([tool]) - assert len(result) == 1 - assert result[0]["type"] == "web_search_preview" - - -def test_to_azure_ai_tools_image_generation() -> None: - """Test converting image_generation dict tool.""" - tool = { - "type": "image_generation", - "model": "gpt-image-1", - "size": "1024x1024", - "quality": "high", - } - result = to_azure_ai_tools([tool]) - assert len(result) == 1 - assert result[0]["type"] == "image_generation" - assert result[0]["model"] == "gpt-image-1" - - -def test_prepare_mcp_tool_basic() -> None: - """Test basic MCP tool conversion.""" - tool = {"type": "mcp", "server_label": "my_tool", "server_url": "http://localhost:8080"} - result = _prepare_mcp_tool_for_azure_ai(tool) - assert result["server_label"] == "my_tool" - assert "http://localhost:8080" in result["server_url"] - - -def test_prepare_mcp_tool_with_description() -> None: - """Test MCP tool with description.""" - tool = { - "type": "mcp", - "server_label": "my_tool", - "server_url": "http://localhost:8080", - "server_description": "My MCP server", - } - result = _prepare_mcp_tool_for_azure_ai(tool) - assert result["server_description"] == "My MCP server" - - -def test_prepare_mcp_tool_with_headers() -> None: - """Test MCP tool with headers (no project_connection_id).""" - tool = { - "type": "mcp", - "server_label": "my_tool", - "server_url": "http://localhost:8080", - "headers": {"X-Api-Key": "secret"}, - } - result = _prepare_mcp_tool_for_azure_ai(tool) - assert result["headers"] == {"X-Api-Key": "secret"} - - -def test_prepare_mcp_tool_project_connection_takes_precedence() -> None: - """Test project_connection_id takes precedence over headers.""" - tool = { - "type": "mcp", - "server_label": "my_tool", - "server_url": "http://localhost:8080", - "headers": {"X-Api-Key": "secret"}, - "project_connection_id": "my-conn", - } - result = _prepare_mcp_tool_for_azure_ai(tool) - assert result["project_connection_id"] == "my-conn" - assert "headers" not in result - - -def test_prepare_mcp_tool_approval_mode_always() -> None: - """Test MCP tool with always_require approval mode.""" - tool = { - "type": "mcp", - "server_label": "my_tool", - "server_url": "http://localhost:8080", - "require_approval": "always", - } - result = _prepare_mcp_tool_for_azure_ai(tool) - assert result["require_approval"] == "always" - - -def test_prepare_mcp_tool_approval_mode_never() -> None: - """Test MCP tool with never_require approval mode.""" - tool = { - "type": "mcp", - "server_label": "my_tool", - "server_url": "http://localhost:8080", - "require_approval": "never", - } - result = _prepare_mcp_tool_for_azure_ai(tool) - assert result["require_approval"] == "never" - - -def test_prepare_mcp_tool_approval_mode_dict() -> None: - """Test MCP tool with dict approval mode.""" - tool = { - "type": "mcp", - "server_label": "my_tool", - "server_url": "http://localhost:8080", - "require_approval": {"always": {"tool_names": ["sensitive_tool", "dangerous_tool"]}}, - } - result = _prepare_mcp_tool_for_azure_ai(tool) - # The approval mode is passed through - assert "require_approval" in result - - -def test_create_text_format_config_pydantic_model() -> None: - """Test creating text format config from Pydantic model.""" - - class MySchema(BaseModel): - name: str - value: int - - result = create_text_format_config(MySchema) - assert result["type"] == "json_schema" - assert result["name"] == "MySchema" - assert result["strict"] is True - - -def test_create_text_format_config_json_schema_mapping() -> None: - """Test creating text format config from json_schema mapping.""" - config = { - "type": "json_schema", - "json_schema": { - "name": "MyResponse", - "schema": {"type": "object", "properties": {"name": {"type": "string"}}}, - }, - } - result = create_text_format_config(config) - assert result["type"] == "json_schema" - assert result["name"] == "MyResponse" - - -def test_create_text_format_config_json_object() -> None: - """Test creating text format config for json_object type.""" - result = create_text_format_config({"type": "json_object"}) - assert result["type"] == "json_object" - - -def test_create_text_format_config_text() -> None: - """Test creating text format config for text type.""" - result = create_text_format_config({"type": "text"}) - assert result["type"] == "text" - - -def test_create_text_format_config_invalid_raises() -> None: - """Test invalid response_format raises error.""" - with pytest.raises(IntegrationInvalidRequestException): - create_text_format_config({"type": "invalid"}) - - -def test_convert_response_format_with_format_key() -> None: - """Test _convert_response_format with nested format key.""" - config = {"format": {"type": "json_object"}} - result = _convert_response_format(config) - assert result["type"] == "json_object" - - -def test_convert_response_format_json_schema_missing_schema_raises() -> None: - """Test json_schema without schema raises error.""" - with pytest.raises(IntegrationInvalidRequestException, match="requires a schema"): - _convert_response_format({"type": "json_schema", "json_schema": {}}) - - -def test_convert_response_format_raw_json_schema_with_properties() -> None: - """Test raw JSON schema with properties is wrapped in json_schema envelope.""" - result = _convert_response_format({"type": "object", "properties": {"x": {"type": "string"}}, "title": "MyOutput"}) - - assert result["type"] == "json_schema" - assert result["name"] == "MyOutput" - assert result["strict"] is True - assert result["schema"]["additionalProperties"] is False - assert "title" not in result["schema"] - - -def test_convert_response_format_raw_json_schema_no_title() -> None: - """Test raw JSON schema without title defaults name to 'response'.""" - result = _convert_response_format({"type": "object", "properties": {"x": {"type": "string"}}}) - - assert result["name"] == "response" - - -def test_convert_response_format_raw_json_schema_with_anyof() -> None: - """Test raw JSON schema with anyOf keyword is detected.""" - result = _convert_response_format({"anyOf": [{"type": "string"}, {"type": "number"}]}) - - assert result["type"] == "json_schema" - assert result["strict"] is True - - -def test_from_azure_ai_tools_mcp_approval_mode_always() -> None: - """Test from_azure_ai_tools converts MCP require_approval='always' to dict.""" - tools = [ - { - "type": "mcp", - "server_label": "my_mcp", - "server_url": "http://localhost:8080", - "require_approval": "always", - } - ] - result = from_azure_ai_tools(tools) - assert len(result) == 1 - assert result[0]["type"] == "mcp" - assert result[0]["require_approval"] == "always" - - -def test_from_azure_ai_tools_mcp_approval_mode_never() -> None: - """Test from_azure_ai_tools converts MCP require_approval='never' to dict.""" - tools = [ - { - "type": "mcp", - "server_label": "my_mcp", - "server_url": "http://localhost:8080", - "require_approval": "never", - } - ] - result = from_azure_ai_tools(tools) - assert len(result) == 1 - assert result[0]["type"] == "mcp" - assert result[0]["require_approval"] == "never" - - -def test_from_azure_ai_tools_mcp_approval_mode_dict_always() -> None: - """Test from_azure_ai_tools converts MCP dict require_approval with 'always' key.""" - tools = [ - { - "type": "mcp", - "server_label": "my_mcp", - "server_url": "http://localhost:8080", - "require_approval": {"always": {"tool_names": ["sensitive_tool", "dangerous_tool"]}}, - } - ] - result = from_azure_ai_tools(tools) - assert len(result) == 1 - assert result[0]["type"] == "mcp" - assert result[0]["require_approval"] == {"always": {"tool_names": ["sensitive_tool", "dangerous_tool"]}} - - -def test_from_azure_ai_tools_mcp_approval_mode_dict_never() -> None: - """Test from_azure_ai_tools converts MCP dict require_approval with 'never' key.""" - tools = [ - { - "type": "mcp", - "server_label": "my_mcp", - "server_url": "http://localhost:8080", - "require_approval": {"never": {"tool_names": ["safe_tool"]}}, - } - ] - result = from_azure_ai_tools(tools) - assert len(result) == 1 - assert result[0]["type"] == "mcp" - assert result[0]["require_approval"] == {"never": {"tool_names": ["safe_tool"]}} diff --git a/python/packages/azure-cosmos/samples/README.md b/python/packages/azure-cosmos/samples/README.md index 082a9c2cfe..e3714139e4 100644 --- a/python/packages/azure-cosmos/samples/README.md +++ b/python/packages/azure-cosmos/samples/README.md @@ -4,7 +4,7 @@ This folder contains samples for `agent-framework-azure-cosmos`. | File | Description | | --- | --- | -| [`cosmos_history_provider.py`](cosmos_history_provider.py) | Demonstrates an Agent using `CosmosHistoryProvider` with `AzureOpenAIResponsesClient` (project endpoint), provider-configured container name, and `session_id` partitioning. | +| [`cosmos_history_provider.py`](cosmos_history_provider.py) | Demonstrates an Agent using `CosmosHistoryProvider` with `FoundryChatClient` (configured against an Azure AI Foundry project endpoint), provider-configured container name, and `session_id` partitioning. | ## Prerequisites diff --git a/python/packages/azure-cosmos/samples/cosmos_history_provider.py b/python/packages/azure-cosmos/samples/cosmos_history_provider.py index ff6138c1e5..8dd38a7cdc 100644 --- a/python/packages/azure-cosmos/samples/cosmos_history_provider.py +++ b/python/packages/azure-cosmos/samples/cosmos_history_provider.py @@ -4,7 +4,7 @@ import asyncio import os -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv @@ -17,13 +17,13 @@ load_dotenv() This sample demonstrates CosmosHistoryProvider as an agent context provider. Key components: -- AzureOpenAIResponsesClient configured with an Azure AI project endpoint +- FoundryChatClient configured with an Azure AI project endpoint - CosmosHistoryProvider configured for Cosmos DB-backed message history - Provider-configured container name with session_id as partition key Environment variables: - AZURE_AI_PROJECT_ENDPOINT - AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME + FOUNDRY_PROJECT_ENDPOINT + FOUNDRY_MODEL AZURE_COSMOS_ENDPOINT AZURE_COSMOS_DATABASE_NAME AZURE_COSMOS_CONTAINER_NAME @@ -34,8 +34,8 @@ Optional: async def main() -> None: """Run the Cosmos history provider sample with an Agent.""" - project_endpoint = os.getenv("AZURE_AI_PROJECT_ENDPOINT") - deployment_name = os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME") + project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT") + deployment_name = os.getenv("FOUNDRY_MODEL") cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT") cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME") cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME") @@ -49,16 +49,16 @@ async def main() -> None: or not cosmos_container_name ): print( - "Please set AZURE_AI_PROJECT_ENDPOINT, AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME, " + "Please set FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_MODEL, " "AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME." ) return - # 1. Create an Azure credential and Responses client using project endpoint auth. + # 1. Create an Azure credential and Foundry chat client using project endpoint auth. async with AzureCliCredential() as credential: - client = AzureOpenAIResponsesClient( + client = FoundryChatClient( project_endpoint=project_endpoint, - deployment_name=deployment_name, + model=deployment_name, credential=credential, ) diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py index 1c43264398..e1164154a5 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py @@ -124,16 +124,17 @@ class AgentFunctionApp(DFAppBase): .. code-block:: python - from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient + from agent_framework.azure import AgentFunctionApp + from agent_framework.openai import OpenAIChatCompletionClient # Create agents with unique names - weather_agent = AzureOpenAIChatClient(...).as_agent( + weather_agent = OpenAIChatCompletionClient(...).as_agent( name="WeatherAgent", instructions="You are a helpful weather agent.", tools=[get_weather], ) - math_agent = AzureOpenAIChatClient(...).as_agent( + math_agent = OpenAIChatCompletionClient(...).as_agent( name="MathAgent", instructions="You are a helpful math assistant.", tools=[calculate], diff --git a/python/packages/azurefunctions/tests/integration_tests/.env.example b/python/packages/azurefunctions/tests/integration_tests/.env.example index 072a0de92c..e956fe48e6 100644 --- a/python/packages/azurefunctions/tests/integration_tests/.env.example +++ b/python/packages/azurefunctions/tests/integration_tests/.env.example @@ -1,6 +1,6 @@ # Azure OpenAI Configuration AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=your-deployment-name +AZURE_OPENAI_DEPLOYMENT_NAME=your-deployment-name FUNCTIONS_WORKER_RUNTIME=python # Azure Functions Configuration diff --git a/python/packages/chatkit/README.md b/python/packages/chatkit/README.md index 874efaa097..7caacd4d82 100644 --- a/python/packages/chatkit/README.md +++ b/python/packages/chatkit/README.md @@ -64,7 +64,7 @@ from fastapi import FastAPI, Request from fastapi.responses import Response, StreamingResponse from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.chatkit import simple_to_agent_input, stream_agent_response from chatkit.server import ChatKitServer @@ -75,7 +75,7 @@ from your_store import YourStore # type: ignore[import-not-found] # Replace wi # Define your agent with tools agent = Agent( - client=AzureOpenAIChatClient(credential=AzureCliCredential()), + client=OpenAIChatCompletionClient(credential=AzureCliCredential()), instructions="You are a helpful assistant.", tools=[], # Add your tools here ) diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 859858f0ef..4a9317aa54 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -82,13 +82,12 @@ agent_framework/ ### OpenAI (`openai/`) -- **`OpenAIChatClient`** - Chat client for OpenAI API -- **`OpenAIResponsesClient`** - Client for OpenAI Responses API +- **`OpenAIChatClient`** - Chat client for the OpenAI Responses API +- **`OpenAIChatCompletionClient`** - Chat client for the OpenAI Chat Completions API -### Azure OpenAI (`azure/`) +### Foundry (`foundry/`) -- **`AzureOpenAIChatClient`** - Chat client for Azure OpenAI -- **`AzureOpenAIResponsesClient`** - Client for Azure OpenAI Responses API +- **`FoundryChatClient`** - Chat client for Azure AI Foundry project endpoints ## Key Patterns diff --git a/python/packages/core/README.md b/python/packages/core/README.md index 36f0f6e02c..cb03025db5 100644 --- a/python/packages/core/README.md +++ b/python/packages/core/README.md @@ -5,7 +5,7 @@ Highlights - Flexible Agent Framework: build, orchestrate, and deploy AI agents and multi-agent systems - Multi-Agent Orchestration: Group chat, sequential, concurrent, and handoff patterns - Plugin Ecosystem: Extend with native functions, OpenAPI, Model Context Protocol (MCP), and more -- LLM Support: OpenAI, Azure OpenAI, Azure AI, and more +- LLM Support: OpenAI, Foundry, Anthropic, and more - Runtime Support: In-process and distributed agent execution - Multimodal: Text, vision, and function calling - Cross-Platform: .NET and Python implementations @@ -16,6 +16,8 @@ Highlights pip install agent-framework-core --pre # Optional: Add Azure AI Foundry integration pip install agent-framework-foundry --pre +# Optional: Add OpenAI integration +pip install agent-framework-openai --pre ``` Supported Platforms: @@ -25,35 +27,33 @@ Supported Platforms: ## 1. Setup API Keys -Set as environment variables, or create a .env file at your project root: +Depending on the client you want to use, there are various environment variables you can set to configure the chat clients. This can be done in the environment itself, or with a `.env` file in your project root, some examples of environment variables include: ```bash +FOUNDRY_PROJECT_ENDPOINT=... +FOUNDRY_MODEL=... +... OPENAI_API_KEY=sk-... OPENAI_CHAT_MODEL=... OPENAI_RESPONSES_MODEL=... ... AZURE_OPENAI_API_KEY=... AZURE_OPENAI_ENDPOINT=... -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=... -... -FOUNDRY_PROJECT_ENDPOINT=... -FOUNDRY_MODEL=... +AZURE_OPENAI_DEPLOYMENT_NAME=... ``` You can also override environment variables by explicitly passing configuration parameters to the chat client constructor: ```python -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatClient -client = AzureOpenAIChatClient( +client = OpenAIChatClient( api_key="", - endpoint="", - deployment_name="", - api_version="", + model="", ) ``` -See the following [setup guide](../../samples/01-get-started) for more information. +See the following [getting started samples](https://github.com/microsoft/agent-framework/tree/main/python/samples/01-get-started) for more information. ## 2. Create a Simple Agent @@ -64,22 +64,19 @@ import asyncio from agent_framework import Agent from agent_framework.openai import OpenAIChatClient -async def main(): - agent = Agent( - client=OpenAIChatClient(), - instructions=""" - 1) A robot may not injure a human being... - 2) A robot must obey orders given it by human beings... - 3) A robot must protect its own existence... +agent = Agent( + client=OpenAIChatClient(), + instructions=""" + 1) A robot may not injure a human being... + 2) A robot must obey orders given it by human beings... + 3) A robot must protect its own existence... - Give me the TLDR in exactly 5 words. - """ - ) + Give me the TLDR in exactly 5 words. + """ +) - result = await agent.run("Summarize the Three Laws of Robotics") - print(result) - -asyncio.run(main()) +result = asyncio.run(agent.run("Summarize the Three Laws of Robotics")) +print(result) # Output: Protect humans, obey, self-preserve, prioritized. ``` @@ -95,12 +92,10 @@ from agent_framework import Message, Role async def main(): client = OpenAIChatClient() - messages = [ + response = await client.get_response([ Message("system", ["You are a helpful assistant."]), Message("user", ["Write a haiku about Agent Framework."]) - ] - - response = await client.get_response(messages) + ]) print(response.messages[0].text) """ @@ -122,13 +117,12 @@ Enhance your agent with custom tools and function calling: import asyncio from typing import Annotated from random import randint -from pydantic import Field from agent_framework import Agent from agent_framework.openai import OpenAIChatClient def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], + location: Annotated[str, "The location to get the weather for."], ) -> str: """Get the weather for a given location.""" conditions = ["sunny", "cloudy", "rainy", "stormy"] @@ -161,7 +155,7 @@ async def main(): asyncio.run(main()) ``` -You can explore additional agent samples [here](../../samples/02-agents). +You can explore additional agent samples [here](https://github.com/microsoft/agent-framework/tree/main/python/samples/02-agents). ## 5. Multi-Agent Orchestration @@ -213,14 +207,14 @@ if __name__ == "__main__": asyncio.run(main()) ``` -**Note**: Sequential, Concurrent, Group Chat, Handoff, and Magentic orchestrations are available. See examples in [orchestration samples](../../samples/03-workflows/orchestrations). +**Note**: Sequential, Concurrent, Group Chat, Handoff, and Magentic orchestrations are available. See examples in [orchestration samples](https://github.com/microsoft/agent-framework/tree/main/python/samples/03-workflows/orchestrations). ## More Examples & Samples -- [Getting Started with Agents](../../samples/02-agents): Basic agent creation and tool usage -- [Chat Client Examples](../../samples/02-agents/chat_client): Direct chat client usage patterns -- [Azure AI Integration](https://github.com/microsoft/agent-framework/tree/main/python/packages/azure-ai): Azure AI integration -- [Workflows Samples](../../samples/03-workflows): Advanced multi-agent patterns +- [Getting Started with Agents](https://github.com/microsoft/agent-framework/tree/main/python/samples/02-agents): Basic agent creation and tool usage +- [Chat Client Examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/02-agents/chat_client): Direct chat client usage patterns +- [Foundry Integration](https://github.com/microsoft/agent-framework/tree/main/python/packages/foundry): Foundry integration +- [Workflows Samples](https://github.com/microsoft/agent-framework/tree/main/python/samples/03-workflows): Advanced multi-agent patterns ## Agent Framework Documentation @@ -228,4 +222,4 @@ if __name__ == "__main__": - [Python Package Documentation](https://github.com/microsoft/agent-framework/tree/main/python) - [.NET Package Documentation](https://github.com/microsoft/agent-framework/tree/main/dotnet) - [Design Documents](https://github.com/microsoft/agent-framework/tree/main/docs/design) -- [Learn Documentation](https://learn.microsoft.com/en-us/agent-framework/user-guide/workflows/orchestrations/overview) +- [Learn Documentation](https://learn.microsoft.com/agent-framework/) diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index 1865da7928..15ce27ce33 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -231,8 +231,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]): streaming and non-streaming responses. For full-featured clients with middleware, telemetry, and function invocation support, - use the public client classes (e.g., ``OpenAIChatClient``, ``OpenAIResponsesClient``) - which compose these layers correctly. + use public client classes such as ``OpenAIChatClient`` which compose these layers correctly. Examples: .. code-block:: python diff --git a/python/packages/core/agent_framework/_serialization.py b/python/packages/core/agent_framework/_serialization.py index 20e873039d..30c5b80fa1 100644 --- a/python/packages/core/agent_framework/_serialization.py +++ b/python/packages/core/agent_framework/_serialization.py @@ -425,8 +425,8 @@ class SerializationMixin: from openai import AsyncOpenAI - # OpenAI chat client requires an AsyncOpenAI client instance - # The client is marked as INJECTABLE = {"client"} in OpenAIBase + # OpenAI chat client requires an AsyncOpenAI client instance. + # The client dependency is excluded from serialization. # Serialized data contains only the model configuration client_data = { diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 02b4da943c..9653091f75 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -251,21 +251,6 @@ class AgentExecutor(Executor): Returns: Dict containing serialized cache and session state """ - # Check if using AzureAIAgentClient with server-side session and warn about checkpointing limitations - if is_chat_agent(self._agent) and self._session.service_session_id is not None: - client_class_name = self._agent.client.__class__.__name__ - client_module = self._agent.client.__class__.__module__ - - if client_class_name == "AzureAIAgentClient" and "azure_ai" in client_module: - logger.warning( - "Checkpointing an AgentExecutor with AzureAIAgentClient that uses server-side sessions. " - "Currently, checkpointing does not capture messages from server-side sessions " - "(service_session_id: %s). The session state in checkpoints is not immutable and can be " - "modified by subsequent runs. If you need reliable checkpointing with Azure AI agents, " - "consider implementing a custom executor and managing the session state yourself.", - self._session.service_session_id, - ) - serialized_session = self._session.to_dict() return { diff --git a/python/packages/core/agent_framework/azure/__init__.py b/python/packages/core/agent_framework/azure/__init__.py index 1748ada1a3..9cd961c921 100644 --- a/python/packages/core/agent_framework/azure/__init__.py +++ b/python/packages/core/agent_framework/azure/__init__.py @@ -12,26 +12,11 @@ _IMPORTS: dict[str, tuple[str, str]] = { "AgentCallbackContext": ("agent_framework_durabletask", "agent-framework-durabletask"), "AgentFunctionApp": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"), "AgentResponseCallbackProtocol": ("agent_framework_durabletask", "agent-framework-durabletask"), - "AzureAIAgentClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureAIAgentOptions": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureAIProjectAgentOptions": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureAIClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureAIProjectAgentProvider": ("agent_framework_azure_ai", "agent-framework-azure-ai"), "AzureAISearchContextProvider": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"), "AzureAISearchSettings": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"), "AzureAISettings": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureAIAgentsProvider": ("agent_framework_azure_ai", "agent-framework-azure-ai"), "AzureCredentialTypes": ("agent_framework_azure_ai", "agent-framework-azure-ai"), "AzureTokenProvider": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureOpenAIAssistantsClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureOpenAIAssistantsOptions": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureOpenAIChatClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureOpenAIChatOptions": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureOpenAIEmbeddingClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureOpenAIResponsesClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureOpenAIResponsesOptions": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureOpenAISettings": ("agent_framework_azure_ai", "agent-framework-azure-ai"), - "AzureUserSecurityContext": ("agent_framework_azure_ai", "agent-framework-azure-ai"), "DurableAIAgent": ("agent_framework_durabletask", "agent-framework-durabletask"), "DurableAIAgentClient": ("agent_framework_durabletask", "agent-framework-durabletask"), "DurableAIAgentOrchestrationContext": ("agent_framework_durabletask", "agent-framework-durabletask"), diff --git a/python/packages/core/agent_framework/azure/__init__.pyi b/python/packages/core/agent_framework/azure/__init__.pyi index cdd3b66046..13694a1017 100644 --- a/python/packages/core/agent_framework/azure/__init__.pyi +++ b/python/packages/core/agent_framework/azure/__init__.pyi @@ -4,24 +4,9 @@ # Install the relevant packages for full type support. from agent_framework_azure_ai import ( - AzureAIAgentClient, - AzureAIAgentsProvider, - AzureAIClient, - AzureAIProjectAgentOptions, - AzureAIProjectAgentProvider, AzureAISettings, AzureCredentialTypes, - AzureOpenAIAssistantsClient, - AzureOpenAIAssistantsOptions, - AzureOpenAIChatClient, - AzureOpenAIChatOptions, - AzureOpenAIEmbeddingClient, - AzureOpenAIResponsesClient, - AzureOpenAIResponsesOptions, - AzureOpenAISettings, AzureTokenProvider, - AzureUserSecurityContext, - RawAzureAIClient, ) from agent_framework_azure_ai_search import ( AzureAISearchContextProvider, @@ -41,28 +26,13 @@ __all__ = [ "AgentCallbackContext", "AgentFunctionApp", "AgentResponseCallbackProtocol", - "AzureAIAgentClient", - "AzureAIAgentsProvider", - "AzureAIClient", - "AzureAIProjectAgentOptions", - "AzureAIProjectAgentProvider", "AzureAISearchContextProvider", "AzureAISearchSettings", "AzureAISettings", "AzureCredentialTypes", - "AzureOpenAIAssistantsClient", - "AzureOpenAIAssistantsOptions", - "AzureOpenAIChatClient", - "AzureOpenAIChatOptions", - "AzureOpenAIEmbeddingClient", - "AzureOpenAIResponsesClient", - "AzureOpenAIResponsesOptions", - "AzureOpenAISettings", "AzureTokenProvider", - "AzureUserSecurityContext", "DurableAIAgent", "DurableAIAgentClient", "DurableAIAgentOrchestrationContext", "DurableAIAgentWorker", - "RawAzureAIClient", ] diff --git a/python/packages/core/agent_framework/openai/__init__.py b/python/packages/core/agent_framework/openai/__init__.py index 90bc732bae..949e9cd5ed 100644 --- a/python/packages/core/agent_framework/openai/__init__.py +++ b/python/packages/core/agent_framework/openai/__init__.py @@ -9,7 +9,6 @@ Supported classes include: - OpenAIChatClient (Responses API) - OpenAIChatCompletionClient (Chat Completions API) - OpenAIEmbeddingClient -- OpenAIAssistantsClient (deprecated) """ import importlib @@ -28,13 +27,6 @@ _IMPORTS: dict[str, tuple[str, str]] = { "OpenAISettings": ("agent_framework_openai", "agent-framework-openai"), "ContentFilterResultSeverity": ("agent_framework_openai", "agent-framework-openai"), "OpenAIContentFilterException": ("agent_framework_openai", "agent-framework-openai"), - "AssistantToolResources": ("agent_framework_openai", "agent-framework-openai"), - "OpenAIAssistantProvider": ("agent_framework_openai", "agent-framework-openai"), - "OpenAIAssistantsClient": ("agent_framework_openai", "agent-framework-openai"), - "OpenAIAssistantsOptions": ("agent_framework_openai", "agent-framework-openai"), - "OpenAIResponsesClient": ("agent_framework_openai", "agent-framework-openai"), - "OpenAIResponsesOptions": ("agent_framework_openai", "agent-framework-openai"), - "RawOpenAIResponsesClient": ("agent_framework_openai", "agent-framework-openai"), } diff --git a/python/packages/core/agent_framework/openai/__init__.pyi b/python/packages/core/agent_framework/openai/__init__.pyi index 3f7ad148fb..e2c9a29ef8 100644 --- a/python/packages/core/agent_framework/openai/__init__.pyi +++ b/python/packages/core/agent_framework/openai/__init__.pyi @@ -4,11 +4,7 @@ # Install agent-framework-openai for full type support. from agent_framework_openai import ( - AssistantToolResources, ContentFilterResultSeverity, - OpenAIAssistantProvider, - OpenAIAssistantsClient, - OpenAIAssistantsOptions, OpenAIChatClient, OpenAIChatCompletionClient, OpenAIChatCompletionOptions, @@ -17,20 +13,13 @@ from agent_framework_openai import ( OpenAIContinuationToken, OpenAIEmbeddingClient, OpenAIEmbeddingOptions, - OpenAIResponsesClient, - OpenAIResponsesOptions, OpenAISettings, RawOpenAIChatClient, RawOpenAIChatCompletionClient, - RawOpenAIResponsesClient, ) __all__ = [ - "AssistantToolResources", "ContentFilterResultSeverity", - "OpenAIAssistantProvider", - "OpenAIAssistantsClient", - "OpenAIAssistantsOptions", "OpenAIChatClient", "OpenAIChatCompletionClient", "OpenAIChatCompletionOptions", @@ -39,10 +28,7 @@ __all__ = [ "OpenAIContinuationToken", "OpenAIEmbeddingClient", "OpenAIEmbeddingOptions", - "OpenAIResponsesClient", - "OpenAIResponsesOptions", "OpenAISettings", "RawOpenAIChatClient", "RawOpenAIChatCompletionClient", - "RawOpenAIResponsesClient", ] diff --git a/python/packages/declarative/agent_framework_declarative/_loader.py b/python/packages/declarative/agent_framework_declarative/_loader.py index 625189a2f4..874d5704d4 100644 --- a/python/packages/declarative/agent_framework_declarative/_loader.py +++ b/python/packages/declarative/agent_framework_declarative/_loader.py @@ -47,58 +47,73 @@ class ProviderTypeMapping(TypedDict, total=True): package: str name: str model_id_field: str + endpoint_field: str | None + api_key_field: str | None PROVIDER_TYPE_OBJECT_MAPPING: dict[str, ProviderTypeMapping] = { - "AzureOpenAI.Chat": { - "package": "agent_framework.azure", - "name": "AzureOpenAIChatClient", - "model_id_field": "deployment_name", + "AzureOpenAI": { + "package": "agent_framework.openai", + "name": "OpenAIChatClient", + "model_id_field": "model", + "endpoint_field": "azure_endpoint", + "api_key_field": "api_key", }, - "AzureOpenAI.Assistants": { - "package": "agent_framework.azure", - "name": "AzureOpenAIAssistantsClient", - "model_id_field": "deployment_name", + "AzureOpenAI.Chat": { + "package": "agent_framework.openai", + "name": "OpenAIChatCompletionClient", + "model_id_field": "model", + "endpoint_field": "azure_endpoint", + "api_key_field": "api_key", }, "AzureOpenAI.Responses": { - "package": "agent_framework.azure", - "name": "AzureOpenAIResponsesClient", - "model_id_field": "deployment_name", + "package": "agent_framework.openai", + "name": "OpenAIChatClient", + "model_id_field": "model", + "endpoint_field": "azure_endpoint", + "api_key_field": "api_key", + }, + "Foundry": { + "package": "agent_framework.foundry", + "name": "FoundryChatClient", + "model_id_field": "model", + "endpoint_field": "project_endpoint", + "api_key_field": None, }, "OpenAI.Chat": { "package": "agent_framework.openai", "name": "OpenAIChatClient", - "model_id_field": "model_id", - }, - "OpenAI.Assistants": { - "package": "agent_framework.openai", - "name": "OpenAIAssistantsClient", - "model_id_field": "model_id", + "model_id_field": "model", + "endpoint_field": "base_url", + "api_key_field": "api_key", }, "OpenAI.Responses": { "package": "agent_framework.openai", - "name": "OpenAIResponsesClient", - "model_id_field": "model_id", - }, - "AzureAIAgentClient": { - "package": "agent_framework.azure", - "name": "AzureAIAgentClient", - "model_id_field": "model_deployment_name", - }, - "AzureAIClient": { - "package": "agent_framework.azure", - "name": "AzureAIClient", - "model_id_field": "model_deployment_name", - }, - "AzureAI.ProjectProvider": { - "package": "agent_framework.azure", - "name": "AzureAIProjectAgentProvider", + "name": "OpenAIChatClient", "model_id_field": "model", + "endpoint_field": "base_url", + "api_key_field": "api_key", + }, + "OpenAI": { + "package": "agent_framework.openai", + "name": "OpenAIChatClient", + "model_id_field": "model", + "endpoint_field": "base_url", + "api_key_field": "api_key", + }, + "Foundry.Chat": { + "package": "agent_framework.foundry", + "name": "FoundryChatClient", + "model_id_field": "model", + "endpoint_field": "project_endpoint", + "api_key_field": None, }, "Anthropic.Chat": { "package": "agent_framework.anthropic", "name": "AnthropicChatClient", "model_id_field": "model_id", + "endpoint_field": None, + "api_key_field": "api_key", }, } @@ -137,11 +152,11 @@ class AgentFactory: .. code-block:: python - from agent_framework.azure import AzureOpenAIChatClient + from agent_framework.openai import OpenAIChatClient from agent_framework_declarative import AgentFactory # With pre-configured chat client - client = AzureOpenAIChatClient() + client = OpenAIChatClient() factory = AgentFactory(client=client) agent = factory.create_agent_from_yaml_path("agent.yaml") @@ -171,7 +186,7 @@ class AgentFactory: connections: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, additional_mappings: Mapping[str, ProviderTypeMapping] | None = None, - default_provider: str = "AzureAIClient", + default_provider: str = "OpenAI", safe_mode: bool = True, env_file_path: str | None = None, env_file_encoding: str | None = None, @@ -192,13 +207,15 @@ class AgentFactory: ..code-block:: python additional_mappings = { - "Provider.ApiType": { - "package": "package.name", - "name": "ClassName", - "model_id_field": "field_name_in_constructor", - }, - ... - } + "Provider.ApiType": { + "package": "package.name", + "name": "ClassName", + "model_id_field": "field_name_in_constructor", + "endpoint_field": "endpoint_kwarg_name_or_null", + "api_key_field": "api_key_kwarg_name_or_null", + }, + ... + } Here, "Provider.ApiType" is the lookup key used when both provider and apiType are specified in the model, "Provider" is also allowed. @@ -206,7 +223,7 @@ class AgentFactory: SupportsChatGetResponse implementation, and model_id_field is the name of the field in the constructor that accepts the model.id value. default_provider: The default provider used when model.provider is not specified, - default is "AzureAIClient". + default is "OpenAI". safe_mode: Whether to run in safe mode, default is True. When safe_mode is True, environment variables are not accessible in the powerfx expressions. You can still use environment variables, but through the constructors of the classes. @@ -227,11 +244,11 @@ class AgentFactory: .. code-block:: python - from agent_framework.azure import AzureOpenAIChatClient + from agent_framework.openai import OpenAIChatClient from agent_framework_declarative import AgentFactory # With shared chat client - client = AzureOpenAIChatClient() + client = OpenAIChatClient() factory = AgentFactory( client=client, env_file_path=".env", @@ -457,8 +474,8 @@ class AgentFactory: async def create_agent_from_yaml_path_async(self, yaml_path: str | Path) -> Agent: """Async version: Create a Agent from a YAML file path. - Use this method when the provider requires async initialization, such as - AzureAI.ProjectProvider which creates agents on the Azure AI Agent Service. + This is the async counterpart to ``create_agent_from_dict`` and is useful when + the rest of your setup is already async. Args: yaml_path: Path to the YAML file representation of a PromptAgent. @@ -473,7 +490,7 @@ class AgentFactory: factory = AgentFactory( client_kwargs={"credential": credential}, - default_provider="AzureAI.ProjectProvider", + default_provider="Foundry", ) agent = await factory.create_agent_from_yaml_path_async("agent.yaml") """ @@ -487,8 +504,8 @@ class AgentFactory: async def create_agent_from_yaml_async(self, yaml_str: str) -> Agent: """Async version: Create a Agent from a YAML string. - Use this method when the provider requires async initialization, such as - AzureAI.ProjectProvider which creates agents on the Azure AI Agent Service. + Use this method when the surrounding call site is already async and you + want to build an agent directly from YAML text. Args: yaml_str: YAML string representation of a PromptAgent. @@ -507,7 +524,7 @@ class AgentFactory: instructions: You are a helpful assistant. model: id: gpt-4o - provider: AzureAI.ProjectProvider + provider: Foundry ''' factory = AgentFactory(client_kwargs={"credential": credential}) @@ -518,8 +535,8 @@ class AgentFactory: async def create_agent_from_dict_async(self, agent_def: dict[str, Any]) -> Agent: """Async version: Create a Agent from a dictionary definition. - Use this method when the provider requires async initialization, such as - AzureAI.ProjectProvider which creates agents on the Azure AI Agent Service. + This is the async counterpart to ``create_agent_from_dict`` and is useful when + the rest of your setup is already async. Args: agent_def: Dictionary representation of a PromptAgent. @@ -538,7 +555,7 @@ class AgentFactory: "instructions": "You are a helpful assistant.", "model": { "id": "gpt-4o", - "provider": "AzureAI.ProjectProvider", + "provider": "Foundry", }, } @@ -551,12 +568,6 @@ class AgentFactory: if not isinstance(prompt_agent, PromptAgent): raise DeclarativeLoaderError("Only definitions for a PromptAgent are supported for agent creation.") - # Check if we're using a provider-based approach (like AzureAIProjectAgentProvider) - mapping = self._retrieve_provider_configuration(prompt_agent.model) if prompt_agent.model else None - if mapping and mapping["name"] == "AzureAIProjectAgentProvider": - return await self._create_agent_with_provider(prompt_agent, mapping) - - # Fall back to standard ChatClient approach client = self._get_client(prompt_agent) chat_options = self._parse_chat_options(prompt_agent.model) if tools := self._parse_tools(prompt_agent.tools): @@ -572,48 +583,42 @@ class AgentFactory: ) async def _create_agent_with_provider(self, prompt_agent: PromptAgent, mapping: ProviderTypeMapping) -> Agent: - """Create a Agent using AzureAIProjectAgentProvider. + """Create an Agent through a provider object that exposes ``create_agent``. - This method handles the special case where we use a provider that creates - agents on a remote service (like Azure AI Agent Service) and returns - Agent instances directly. + This remains available as an internal escape hatch for provider-style custom mappings + that return a fully constructed ``Agent`` rather than a chat client. """ - # Import the provider class module_name = mapping["package"] class_name = mapping["name"] module = __import__(module_name, fromlist=[class_name]) provider_class = getattr(module, class_name) - # Build provider kwargs from client_kwargs and connection info provider_kwargs: dict[str, Any] = {} provider_kwargs.update(self.client_kwargs) - # Handle connection settings for the model + endpoint_field = mapping.get("endpoint_field") + api_key_field = mapping.get("api_key_field", "api_key") + if prompt_agent.model and prompt_agent.model.connection: match prompt_agent.model.connection: - case RemoteConnection() | AnonymousConnection(): - if prompt_agent.model.connection.endpoint: - provider_kwargs["project_endpoint"] = prompt_agent.model.connection.endpoint case ApiKeyConnection(): - if prompt_agent.model.connection.endpoint: - provider_kwargs["project_endpoint"] = prompt_agent.model.connection.endpoint + if api_key_field: + provider_kwargs[api_key_field] = prompt_agent.model.connection.apiKey + if prompt_agent.model.connection.endpoint and endpoint_field: + provider_kwargs[endpoint_field] = prompt_agent.model.connection.endpoint + case RemoteConnection() | AnonymousConnection(): + if prompt_agent.model.connection.endpoint and endpoint_field: + provider_kwargs[endpoint_field] = prompt_agent.model.connection.endpoint case ReferenceConnection(): - # Reference connections are resolved by concrete providers when supported. pass - # Create the provider and use it to create the agent provider = provider_class(**provider_kwargs) - - # Parse tools tools = self._parse_tools(prompt_agent.tools) if prompt_agent.tools else None - # Parse response format into default_options default_options: dict[str, Any] | None = None if prompt_agent.outputSchema: default_options = {"response_format": prompt_agent.outputSchema.to_json_schema()} - # Create the agent using the provider - # The provider's create_agent returns a Agent directly return cast( Agent, await provider.create_agent( @@ -637,18 +642,35 @@ class AgentFactory: "alternatively define a model in the PromptAgent." ) + mapping = self._retrieve_provider_configuration(prompt_agent.model) setup_dict: dict[str, Any] = {} setup_dict.update(self.client_kwargs) + endpoint_field = mapping.get("endpoint_field") + api_key_field = mapping.get("api_key_field", "api_key") # parse connections if prompt_agent.model.connection: match prompt_agent.model.connection: case ApiKeyConnection(): - setup_dict["api_key"] = prompt_agent.model.connection.apiKey + if api_key_field: + setup_dict[api_key_field] = prompt_agent.model.connection.apiKey + elif prompt_agent.model.connection.apiKey: + raise DeclarativeLoaderError( + f"{mapping['name']} does not support API key-based model connections." + ) if prompt_agent.model.connection.endpoint: - setup_dict["endpoint"] = prompt_agent.model.connection.endpoint + if not endpoint_field: + raise DeclarativeLoaderError( + f"{mapping['name']} does not support endpoint-based model connections." + ) + setup_dict[endpoint_field] = prompt_agent.model.connection.endpoint case RemoteConnection() | AnonymousConnection(): - setup_dict["endpoint"] = prompt_agent.model.connection.endpoint + if prompt_agent.model.connection.endpoint: + if not endpoint_field: + raise DeclarativeLoaderError( + f"{mapping['name']} does not support endpoint-based model connections." + ) + setup_dict[endpoint_field] = prompt_agent.model.connection.endpoint case ReferenceConnection(): if not self.connections: raise ValueError("Connections must be provided to resolve ReferenceConnection") @@ -673,7 +695,6 @@ class AgentFactory: "ChatClient must be provided to create agent from PromptAgent, or define model.id in the PromptAgent." ) # if provider is defined, use that, if possible with apiType, fallback to default_provider - mapping = self._retrieve_provider_configuration(prompt_agent.model) module_name = mapping["package"] class_name = mapping["name"] module = __import__(module_name, fromlist=[class_name]) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py index 4d6f3fa35b..9ba4cb84de 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py @@ -70,11 +70,11 @@ class WorkflowFactory: .. code-block:: python - from agent_framework.azure import AzureOpenAIChatClient + from agent_framework.openai import OpenAIChatClient from agent_framework.declarative import WorkflowFactory # Pre-register agents for InvokeAzureAgent actions - client = AzureOpenAIChatClient() + client = OpenAIChatClient() agent = client.as_agent(name="MyAgent", instructions="You are helpful.") factory = WorkflowFactory(agents={"MyAgent": agent}) @@ -116,11 +116,11 @@ class WorkflowFactory: .. code-block:: python - from agent_framework.azure import AzureOpenAIChatClient + from agent_framework.openai import OpenAIChatClient from agent_framework.declarative import WorkflowFactory # With pre-registered agents - client = AzureOpenAIChatClient() + client = OpenAIChatClient() agents = { "WriterAgent": client.as_agent(name="Writer", instructions="Write content."), "ReviewerAgent": client.as_agent(name="Reviewer", instructions="Review content."), @@ -535,10 +535,10 @@ class WorkflowFactory: Examples: .. code-block:: python - from agent_framework.azure import AzureOpenAIChatClient + from agent_framework.openai import OpenAIChatClient from agent_framework.declarative import WorkflowFactory - client = AzureOpenAIChatClient() + client = OpenAIChatClient() # Method chaining to register multiple agents factory = ( diff --git a/python/packages/devui/README.md b/python/packages/devui/README.md index eca7272cfd..669e7cd4d4 100644 --- a/python/packages/devui/README.md +++ b/python/packages/devui/README.md @@ -69,11 +69,11 @@ Register cleanup hooks to properly close credentials and resources on shutdown: ```python from azure.identity.aio import DefaultAzureCredential from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework_devui import register_cleanup, serve credential = DefaultAzureCredential() -client = AzureOpenAIChatClient() +client = OpenAIChatCompletionClient() agent = Agent(name="MyAgent", client=client) # Register cleanup hook - credential will be closed on shutdown diff --git a/python/packages/devui/agent_framework_devui/ui/assets/index.js b/python/packages/devui/agent_framework_devui/ui/assets/index.js index a71e62397f..239c924fec 100644 --- a/python/packages/devui/agent_framework_devui/ui/assets/index.js +++ b/python/packages/devui/agent_framework_devui/ui/assets/index.js @@ -485,7 +485,7 @@ services: # Or Azure OpenAI - AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY} - AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT} - - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=\${AZURE_OPENAI_CHAT_DEPLOYMENT_NAME} + - AZURE_OPENAI_DEPLOYMENT_NAME=\${AZURE_OPENAI_DEPLOYMENT_NAME} # Optional: Enable instrumentation - ENABLE_INSTRUMENTATION=\${ENABLE_INSTRUMENTATION:-false} ports: @@ -519,7 +519,7 @@ az acr build --registry myregistry \\ className: "border-l-2 border-primary pl-3", children: [o.jsxs("div", { className: "flex items-center gap-2 mb-1", children: [o.jsx("div", { className: "w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold", children: "5" }), o.jsx("h5", { className: "font-medium text-sm", children: "Get Application URL" })] }), o.jsx("pre", { className: "bg-muted p-2 rounded text-xs overflow-x-auto border mt-2", children: `az containerapp show --name ${r.toLowerCase()}-app \\ --resource-group myResourceGroup \\ - --query properties.configuration.ingress.fqdn`})]})]})]}),o.jsxs("div",{className:"bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3",children:[o.jsx("h4",{className:"text-sm font-semibold mb-2",children:"Learn More"}),o.jsx("p",{className:"text-xs text-muted-foreground mb-3",children:"Explore Azure Container Apps documentation for advanced features like scaling, monitoring, and CI/CD integration."}),o.jsx(Le,{size:"sm",variant:"outline",className:"w-full",asChild:!0,children:o.jsxs("a",{href:"https://learn.microsoft.com/azure/container-apps/",target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Hu,{className:"h-3 w-3 mr-1"}),"View Azure Container Apps Documentation"]})})]})]})]})]})})})]})})}function tD({className:e,...n}){return o.jsx("div",{"data-slot":"card",className:We("bg-card text-card-foreground flex flex-col gap-6 rounded border py-6 shadow-sm",e),...n})}function nD({className:e,...n}){return o.jsx("div",{"data-slot":"card-header",className:We("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",e),...n})}function N2({className:e,...n}){return o.jsx("div",{"data-slot":"card-title",className:We("leading-none font-semibold",e),...n})}function sD({className:e,...n}){return o.jsx("div",{"data-slot":"card-description",className:We("text-muted-foreground text-sm",e),...n})}function rD({className:e,...n}){return o.jsx("div",{"data-slot":"card-content",className:We("px-6",e),...n})}function oD({className:e,...n}){return o.jsx("div",{"data-slot":"card-footer",className:We("flex items-center px-6 [.border-t]:pt-6",e),...n})}const Cr=[{id:"foundry-weather-agent",name:"Azure AI Weather Agent",description:"Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/foundry_agent/agent.py",tags:["azure-ai","foundry","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure AI Agent integration","Azure CLI authentication","Mock weather tools"],requiredEnvVars:[{name:"AZURE_AI_PROJECT_ENDPOINT",description:"Azure AI Foundry project endpoint URL",required:!0,example:"https://your-project.api.azureml.ms"},{name:"FOUNDRY_MODEL_DEPLOYMENT_NAME",description:"Name of the deployed model in Azure AI Foundry",required:!0,example:"gpt-4o"}]},{id:"weather-agent-azure",name:"Azure OpenAI Weather Agent",description:"Weather agent using Azure OpenAI with API key authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/weather_agent_azure/agent.py",tags:["azure","openai","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure OpenAI integration","API key authentication","Function calling","Mock weather tools"],requiredEnvVars:[{name:"AZURE_OPENAI_API_KEY",description:"Azure OpenAI API key",required:!0},{name:"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME",description:"Name of the deployed model in Azure OpenAI",required:!0,example:"gpt-4o"},{name:"AZURE_OPENAI_ENDPOINT",description:"Azure OpenAI endpoint URL",required:!0,example:"https://your-resource.openai.azure.com"}]},{id:"spam-workflow",name:"Spam Detection Workflow",description:"5-step workflow demonstrating email spam detection with branching logic",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/spam_workflow/workflow.py",tags:["workflow","branching","multi-step"],author:"Microsoft",difficulty:"beginner",features:["Sequential execution","Conditional branching","Mock spam detection"]},{id:"fanout-workflow",name:"Complex Fan-In/Fan-Out Workflow",description:"Advanced data processing workflow with parallel validation, transformation, and quality assurance stages",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/fanout_workflow/workflow.py",tags:["workflow","fan-out","fan-in","parallel"],author:"Microsoft",difficulty:"advanced",features:["Fan-out pattern","Parallel execution","Complex state management","Multi-stage processing"]}];Cr.filter(e=>e.type==="agent"),Cr.filter(e=>e.type==="workflow"),Cr.filter(e=>e.difficulty==="beginner"),Cr.filter(e=>e.difficulty==="intermediate"),Cr.filter(e=>e.difficulty==="advanced");const aD=e=>{switch(e){case"beginner":return"bg-green-100 text-green-700 border-green-200";case"intermediate":return"bg-yellow-100 text-yellow-700 border-yellow-200";case"advanced":return"bg-red-100 text-red-700 border-red-200";default:return"bg-gray-100 text-gray-700 border-gray-200"}},j2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,role:"alert",className:We("relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",e),...n}));j2.displayName="Alert";const S2=w.forwardRef(({className:e,...n},r)=>o.jsx("h5",{ref:r,className:We("mb-1 font-medium leading-none tracking-tight",e),...n}));S2.displayName="AlertTitle";const _2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,className:We("text-sm [&_p]:leading-relaxed",e),...n}));_2.displayName="AlertDescription";function E2({children:e,copyable:n=!1}){const[r,a]=w.useState(!1),l=()=>{navigator.clipboard.writeText(e),a(!0),setTimeout(()=>a(!1),2e3)};return o.jsxs("div",{className:"relative",children:[o.jsx("pre",{className:"bg-muted p-3 rounded-md text-sm overflow-x-auto font-mono",children:o.jsx("code",{children:e})}),n&&o.jsx(Le,{variant:"ghost",size:"sm",className:"absolute top-2 right-2 h-6 w-6 p-0",onClick:l,children:r?o.jsx(jo,{className:"h-3 w-3"}):o.jsx(uo,{className:"h-3 w-3"})})]})}function iu({number:e,title:n,description:r,code:a,action:l,copyable:c=!1}){return o.jsxs("div",{className:"flex gap-4",children:[o.jsx("div",{className:"flex-shrink-0",children:o.jsx("div",{className:"flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground font-semibold",children:e})}),o.jsxs("div",{className:"flex-1 space-y-2",children:[o.jsx("h4",{className:"font-semibold",children:n}),r&&o.jsx("p",{className:"text-sm text-muted-foreground",children:r}),a&&o.jsx(E2,{copyable:c,children:a}),l&&o.jsx("div",{children:l})]})]})}function iD({sample:e,open:n,onOpenChange:r}){const a=e.requiredEnvVars&&e.requiredEnvVars.length>0,l=a?0:-1;return o.jsx(Ir,{open:n,onOpenChange:r,children:o.jsxs(Lr,{className:"max-w-3xl",children:[o.jsxs($r,{className:"px-6 pt-6 pb-2",children:[o.jsxs(Pr,{children:["Setup: ",e.name]}),o.jsxs(OR,{children:["Follow these steps to run this sample ",e.type," locally"]})]}),o.jsx("div",{className:"px-6 pb-6",children:o.jsx(Wn,{className:"h-[500px]",children:o.jsxs("div",{className:"space-y-6 pr-4",children:[o.jsx(iu,{number:1,title:"Download the sample file",action:o.jsx(Le,{asChild:!0,size:"sm",children:o.jsxs("a",{href:e.url,download:`${e.id}.py`,target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Pu,{className:"h-4 w-4 mr-2"}),"Download ",e.id,".py"]})})}),o.jsx(iu,{number:2,title:"Create a project folder",description:"Create a dedicated folder for this sample and move the downloaded file there:",code:`mkdir -p ~/my-agents/${e.id} + --query properties.configuration.ingress.fqdn`})]})]})]}),o.jsxs("div",{className:"bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3",children:[o.jsx("h4",{className:"text-sm font-semibold mb-2",children:"Learn More"}),o.jsx("p",{className:"text-xs text-muted-foreground mb-3",children:"Explore Azure Container Apps documentation for advanced features like scaling, monitoring, and CI/CD integration."}),o.jsx(Le,{size:"sm",variant:"outline",className:"w-full",asChild:!0,children:o.jsxs("a",{href:"https://learn.microsoft.com/azure/container-apps/",target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Hu,{className:"h-3 w-3 mr-1"}),"View Azure Container Apps Documentation"]})})]})]})]})]})})})]})})}function tD({className:e,...n}){return o.jsx("div",{"data-slot":"card",className:We("bg-card text-card-foreground flex flex-col gap-6 rounded border py-6 shadow-sm",e),...n})}function nD({className:e,...n}){return o.jsx("div",{"data-slot":"card-header",className:We("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",e),...n})}function N2({className:e,...n}){return o.jsx("div",{"data-slot":"card-title",className:We("leading-none font-semibold",e),...n})}function sD({className:e,...n}){return o.jsx("div",{"data-slot":"card-description",className:We("text-muted-foreground text-sm",e),...n})}function rD({className:e,...n}){return o.jsx("div",{"data-slot":"card-content",className:We("px-6",e),...n})}function oD({className:e,...n}){return o.jsx("div",{"data-slot":"card-footer",className:We("flex items-center px-6 [.border-t]:pt-6",e),...n})}const Cr=[{id:"foundry-weather-agent",name:"Azure AI Weather Agent",description:"Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/foundry_agent/agent.py",tags:["azure-ai","foundry","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure AI Agent integration","Azure CLI authentication","Mock weather tools"],requiredEnvVars:[{name:"FOUNDRY_PROJECT_ENDPOINT",description:"Azure AI Foundry project endpoint URL",required:!0,example:"https://your-project.api.azureml.ms"},{name:"FOUNDRY_MODEL",description:"Name of the deployed model in Azure AI Foundry",required:!0,example:"gpt-4o"}]},{id:"weather-agent-azure",name:"Azure OpenAI Weather Agent",description:"Weather agent using Azure OpenAI with API key authentication",type:"agent",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/weather_agent_azure/agent.py",tags:["azure","openai","tools"],author:"Microsoft",difficulty:"beginner",features:["Azure OpenAI integration","API key authentication","Function calling","Mock weather tools"],requiredEnvVars:[{name:"AZURE_OPENAI_API_KEY",description:"Azure OpenAI API key",required:!0},{name:"AZURE_OPENAI_DEPLOYMENT_NAME",description:"Name of the deployed model in Azure OpenAI",required:!0,example:"gpt-4o"},{name:"AZURE_OPENAI_ENDPOINT",description:"Azure OpenAI endpoint URL",required:!0,example:"https://your-resource.openai.azure.com"}]},{id:"spam-workflow",name:"Spam Detection Workflow",description:"5-step workflow demonstrating email spam detection with branching logic",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/spam_workflow/workflow.py",tags:["workflow","branching","multi-step"],author:"Microsoft",difficulty:"beginner",features:["Sequential execution","Conditional branching","Mock spam detection"]},{id:"fanout-workflow",name:"Complex Fan-In/Fan-Out Workflow",description:"Advanced data processing workflow with parallel validation, transformation, and quality assurance stages",type:"workflow",url:"https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/fanout_workflow/workflow.py",tags:["workflow","fan-out","fan-in","parallel"],author:"Microsoft",difficulty:"advanced",features:["Fan-out pattern","Parallel execution","Complex state management","Multi-stage processing"]}];Cr.filter(e=>e.type==="agent"),Cr.filter(e=>e.type==="workflow"),Cr.filter(e=>e.difficulty==="beginner"),Cr.filter(e=>e.difficulty==="intermediate"),Cr.filter(e=>e.difficulty==="advanced");const aD=e=>{switch(e){case"beginner":return"bg-green-100 text-green-700 border-green-200";case"intermediate":return"bg-yellow-100 text-yellow-700 border-yellow-200";case"advanced":return"bg-red-100 text-red-700 border-red-200";default:return"bg-gray-100 text-gray-700 border-gray-200"}},j2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,role:"alert",className:We("relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",e),...n}));j2.displayName="Alert";const S2=w.forwardRef(({className:e,...n},r)=>o.jsx("h5",{ref:r,className:We("mb-1 font-medium leading-none tracking-tight",e),...n}));S2.displayName="AlertTitle";const _2=w.forwardRef(({className:e,...n},r)=>o.jsx("div",{ref:r,className:We("text-sm [&_p]:leading-relaxed",e),...n}));_2.displayName="AlertDescription";function E2({children:e,copyable:n=!1}){const[r,a]=w.useState(!1),l=()=>{navigator.clipboard.writeText(e),a(!0),setTimeout(()=>a(!1),2e3)};return o.jsxs("div",{className:"relative",children:[o.jsx("pre",{className:"bg-muted p-3 rounded-md text-sm overflow-x-auto font-mono",children:o.jsx("code",{children:e})}),n&&o.jsx(Le,{variant:"ghost",size:"sm",className:"absolute top-2 right-2 h-6 w-6 p-0",onClick:l,children:r?o.jsx(jo,{className:"h-3 w-3"}):o.jsx(uo,{className:"h-3 w-3"})})]})}function iu({number:e,title:n,description:r,code:a,action:l,copyable:c=!1}){return o.jsxs("div",{className:"flex gap-4",children:[o.jsx("div",{className:"flex-shrink-0",children:o.jsx("div",{className:"flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground font-semibold",children:e})}),o.jsxs("div",{className:"flex-1 space-y-2",children:[o.jsx("h4",{className:"font-semibold",children:n}),r&&o.jsx("p",{className:"text-sm text-muted-foreground",children:r}),a&&o.jsx(E2,{copyable:c,children:a}),l&&o.jsx("div",{children:l})]})]})}function iD({sample:e,open:n,onOpenChange:r}){const a=e.requiredEnvVars&&e.requiredEnvVars.length>0,l=a?0:-1;return o.jsx(Ir,{open:n,onOpenChange:r,children:o.jsxs(Lr,{className:"max-w-3xl",children:[o.jsxs($r,{className:"px-6 pt-6 pb-2",children:[o.jsxs(Pr,{children:["Setup: ",e.name]}),o.jsxs(OR,{children:["Follow these steps to run this sample ",e.type," locally"]})]}),o.jsx("div",{className:"px-6 pb-6",children:o.jsx(Wn,{className:"h-[500px]",children:o.jsxs("div",{className:"space-y-6 pr-4",children:[o.jsx(iu,{number:1,title:"Download the sample file",action:o.jsx(Le,{asChild:!0,size:"sm",children:o.jsxs("a",{href:e.url,download:`${e.id}.py`,target:"_blank",rel:"noopener noreferrer",children:[o.jsx(Pu,{className:"h-4 w-4 mr-2"}),"Download ",e.id,".py"]})})}),o.jsx(iu,{number:2,title:"Create a project folder",description:"Create a dedicated folder for this sample and move the downloaded file there:",code:`mkdir -p ~/my-agents/${e.id} mv ~/Downloads/${e.id}.py ~/my-agents/${e.id}/`,copyable:!0}),a&&o.jsx(iu,{number:3,title:"Set up environment variables",description:"Create a .env file in the project folder with these required variables:",code:e.requiredEnvVars.map(c=>`${c.name}=${c.example||"your-value-here"} # ${c.description}`).join(` diff --git a/python/packages/devui/dev.md b/python/packages/devui/dev.md index 0566e75429..899383e3b1 100644 --- a/python/packages/devui/dev.md +++ b/python/packages/devui/dev.md @@ -37,7 +37,7 @@ OPENAI_CHAT_MODEL="gpt-4o-mini" # Or for Azure OpenAI AZURE_OPENAI_ENDPOINT="your-endpoint" -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="your-deployment-name" +AZURE_OPENAI_DEPLOYMENT_NAME="your-deployment-name" ``` ## 4. Test DevUI diff --git a/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx b/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx index dd5ae81180..f33351185f 100644 --- a/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx +++ b/python/packages/devui/frontend/src/components/layout/deployment-modal.tsx @@ -247,7 +247,7 @@ services: # Or Azure OpenAI - AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY} - AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT} - - AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=\${AZURE_OPENAI_CHAT_DEPLOYMENT_NAME} + - AZURE_OPENAI_DEPLOYMENT_NAME=\${AZURE_OPENAI_DEPLOYMENT_NAME} # Optional: Enable instrumentation - ENABLE_INSTRUMENTATION=\${ENABLE_INSTRUMENTATION:-false} ports: diff --git a/python/packages/devui/frontend/src/data/gallery/sample-entities.ts b/python/packages/devui/frontend/src/data/gallery/sample-entities.ts index 64211ddfcb..d33e02ad7e 100644 --- a/python/packages/devui/frontend/src/data/gallery/sample-entities.ts +++ b/python/packages/devui/frontend/src/data/gallery/sample-entities.ts @@ -41,13 +41,13 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [ ], requiredEnvVars: [ { - name: "AZURE_AI_PROJECT_ENDPOINT", + name: "FOUNDRY_PROJECT_ENDPOINT", description: "Azure AI Foundry project endpoint URL", required: true, example: "https://your-project.api.azureml.ms", }, { - name: "FOUNDRY_MODEL_DEPLOYMENT_NAME", + name: "FOUNDRY_MODEL", description: "Name of the deployed model in Azure AI Foundry", required: true, example: "gpt-4o", @@ -78,7 +78,7 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [ required: true, }, { - name: "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME", + name: "AZURE_OPENAI_DEPLOYMENT_NAME", description: "Name of the deployed model in Azure OpenAI", required: true, example: "gpt-4o", diff --git a/python/packages/durabletask/AGENTS.md b/python/packages/durabletask/AGENTS.md index e0b1be1d19..1da199f5f3 100644 --- a/python/packages/durabletask/AGENTS.md +++ b/python/packages/durabletask/AGENTS.md @@ -30,7 +30,7 @@ Durable execution support for long-running agent workflows using Azure Durable F ```python from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework_durabletask import DurableAIAgentClient, DurableAIAgentWorker from durabletask.client import TaskHubGrpcClient from durabletask.worker import TaskHubGrpcWorker @@ -45,7 +45,7 @@ dt_worker = TaskHubGrpcWorker(host_address="localhost:4001") agent_worker = DurableAIAgentWorker(dt_worker) # Create a chat client for the agent -chat_client = AzureOpenAIChatClient() +chat_client = OpenAIChatCompletionClient() my_agent = Agent(client=chat_client, name="assistant") agent_worker.add_agent(my_agent) ``` diff --git a/python/packages/durabletask/README.md b/python/packages/durabletask/README.md index 5447d19cea..7758c741bf 100644 --- a/python/packages/durabletask/README.md +++ b/python/packages/durabletask/README.md @@ -16,7 +16,7 @@ The durable task integration lets you host Microsoft Agent Framework agents usin ```python from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework_durabletask import DurableAIAgentWorker from durabletask.worker import TaskHubGrpcWorker @@ -24,7 +24,7 @@ from durabletask.worker import TaskHubGrpcWorker worker = TaskHubGrpcWorker(host_address="localhost:4001") agent_worker = DurableAIAgentWorker(worker) -chat_client = AzureOpenAIChatClient() +chat_client = OpenAIChatCompletionClient() my_agent = Agent(client=chat_client, name="assistant") agent_worker.add_agent(my_agent) ``` diff --git a/python/packages/durabletask/agent_framework_durabletask/_worker.py b/python/packages/durabletask/agent_framework_durabletask/_worker.py index e9670fd3cf..728ae17629 100644 --- a/python/packages/durabletask/agent_framework_durabletask/_worker.py +++ b/python/packages/durabletask/agent_framework_durabletask/_worker.py @@ -31,7 +31,7 @@ class DurableAIAgentWorker: ```python from durabletask.worker import TaskHubGrpcWorker from agent_framework import Agent - from agent_framework.azure import AzureOpenAIChatClient + from agent_framework.openai import OpenAIChatCompletionClient from agent_framework_durabletask import DurableAIAgentWorker # Create the underlying worker @@ -41,7 +41,7 @@ class DurableAIAgentWorker: agent_worker = DurableAIAgentWorker(worker) # Register agents - client = AzureOpenAIChatClient() + client = OpenAIChatCompletionClient() my_agent = Agent(client=client, name="assistant") agent_worker.add_agent(my_agent) diff --git a/python/packages/durabletask/tests/integration_tests/.env.example b/python/packages/durabletask/tests/integration_tests/.env.example index 4e5abff232..f86f2382db 100644 --- a/python/packages/durabletask/tests/integration_tests/.env.example +++ b/python/packages/durabletask/tests/integration_tests/.env.example @@ -1,6 +1,6 @@ # Azure OpenAI Configuration AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=your-deployment-name +AZURE_OPENAI_DEPLOYMENT_NAME=your-deployment-name # Optional: Use Azure CLI authentication if not provided # AZURE_OPENAI_API_KEY=your-api-key diff --git a/python/packages/foundry/pyproject.toml b/python/packages/foundry/pyproject.toml index d07b5087fd..a81e199693 100644 --- a/python/packages/foundry/pyproject.toml +++ b/python/packages/foundry/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-foundry" -description = "Cloud Azure AI Foundry integration for Microsoft Agent Framework." +description = "Microsoft Foundry integrations for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" diff --git a/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py b/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py index 266ae8a107..78f64ff565 100644 --- a/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py +++ b/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py @@ -71,7 +71,7 @@ class GAIATelemetryConfig: Note: For Azure Monitor integration, configure using environment variables - (OTEL_EXPORTER_OTLP_ENDPOINT, etc.) or use AzureAIClient.configure_azure_monitor() + (OTEL_EXPORTER_OTLP_ENDPOINT, etc.) or call ``configure_azure_monitor()`` before creating the GAIA instance. """ self.enable_tracing = enable_tracing diff --git a/python/packages/lab/gaia/samples/azure_ai_agent.py b/python/packages/lab/gaia/samples/azure_ai_agent.py index f83625b2c4..1be1144a95 100644 --- a/python/packages/lab/gaia/samples/azure_ai_agent.py +++ b/python/packages/lab/gaia/samples/azure_ai_agent.py @@ -6,8 +6,8 @@ This module provides a factory function to create an Azure AI agent configured for GAIA benchmark tasks. Required Environment Variables: - AZURE_AI_PROJECT_ENDPOINT: Azure AI project endpoint URL - AZURE_AI_MODEL_DEPLOYMENT_NAME: Name of the model deployment to use + FOUNDRY_PROJECT_ENDPOINT: Azure AI project endpoint URL + FOUNDRY_MODEL: Name of the model deployment to use Optional Environment Variables: BING_CONNECTION_ID: ID of the Bing connection for web search @@ -17,17 +17,18 @@ Authentication: Run `az login` before executing to authenticate. Example: - export AZURE_AI_PROJECT_ENDPOINT="https://your-project.azure.com" - export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o" + export FOUNDRY_PROJECT_ENDPOINT="https://your-project.azure.com" + export FOUNDRY_MODEL="gpt-4o" export BING_CONNECTION_ID="connection-id" az login """ +import os from collections.abc import AsyncIterator from contextlib import asynccontextmanager from agent_framework import Agent -from agent_framework.azure import AzureAIAgentClient +from agent_framework.foundry import FoundryChatClient from azure.identity.aio import AzureCliCredential @@ -49,13 +50,17 @@ async def create_gaia_agent() -> AsyncIterator[Agent]: """ async with ( AzureCliCredential() as credential, - AzureAIAgentClient(credential=credential).as_agent( + FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ).as_agent( name="GaiaAgent", instructions="Solve tasks to your best ability. Use Bing Search to find " "information and Code Interpreter to perform calculations and data analysis.", tools=[ - AzureAIAgentClient.get_web_search_tool(), - AzureAIAgentClient.get_code_interpreter_tool(), + FoundryChatClient.get_web_search_tool(), + FoundryChatClient.get_code_interpreter_tool(), ], ) as agent, ): diff --git a/python/packages/lab/gaia/samples/openai_agent.py b/python/packages/lab/gaia/samples/openai_agent.py index 227b12c03c..ba0ef4ab49 100644 --- a/python/packages/lab/gaia/samples/openai_agent.py +++ b/python/packages/lab/gaia/samples/openai_agent.py @@ -26,7 +26,7 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from agent_framework import Agent -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient @asynccontextmanager @@ -47,15 +47,15 @@ async def create_gaia_agent() -> AsyncIterator[Agent]: result = await agent.run("What is the capital of France?") print(result.text) """ - client = OpenAIResponsesClient() + client = OpenAIChatClient() async with client.as_agent( name="GaiaAgent", instructions="Solve tasks to your best ability. Use Web Search to find " "information and Code Interpreter to perform calculations and data analysis.", tools=[ - OpenAIResponsesClient.get_web_search_tool(), - OpenAIResponsesClient.get_code_interpreter_tool(), + OpenAIChatClient.get_web_search_tool(), + OpenAIChatClient.get_code_interpreter_tool(), ], ) as agent: yield agent diff --git a/python/packages/lab/tau2/tests/test_message_utils.py b/python/packages/lab/tau2/tests/test_message_utils.py index 8908140f94..601c716854 100644 --- a/python/packages/lab/tau2/tests/test_message_utils.py +++ b/python/packages/lab/tau2/tests/test_message_utils.py @@ -2,6 +2,13 @@ from unittest.mock import patch +import pytest + +try: + from litellm import completion as _litellm_completion # noqa: F401 +except Exception: + pytest.skip("LiteLLM import surface required by tau2 is unavailable.", allow_module_level=True) + from agent_framework._types import Content, Message from agent_framework_lab_tau2._message_utils import flip_messages, log_messages diff --git a/python/packages/lab/tau2/tests/test_sliding_window.py b/python/packages/lab/tau2/tests/test_sliding_window.py index e2960b19f6..8da46e8695 100644 --- a/python/packages/lab/tau2/tests/test_sliding_window.py +++ b/python/packages/lab/tau2/tests/test_sliding_window.py @@ -4,6 +4,13 @@ from unittest.mock import patch +import pytest + +try: + from litellm import completion as _litellm_completion # noqa: F401 +except Exception: + pytest.skip("LiteLLM import surface required by tau2 is unavailable.", allow_module_level=True) + from agent_framework import InMemoryHistoryProvider from agent_framework._types import Content, Message from agent_framework_lab_tau2._sliding_window import SlidingWindowHistoryProvider diff --git a/python/packages/lab/tau2/tests/test_tau2_utils.py b/python/packages/lab/tau2/tests/test_tau2_utils.py index 15957d5120..671ce0a695 100644 --- a/python/packages/lab/tau2/tests/test_tau2_utils.py +++ b/python/packages/lab/tau2/tests/test_tau2_utils.py @@ -2,6 +2,13 @@ """Tests for tau2 utils module.""" +import pytest + +try: + from litellm import completion as _litellm_completion # noqa: F401 +except Exception: + pytest.skip("LiteLLM import surface required by tau2 is unavailable.", allow_module_level=True) + from agent_framework import Content, FunctionTool, Message from agent_framework_lab_tau2._tau2_utils import ( convert_agent_framework_messages_to_tau2_messages, diff --git a/python/packages/openai/AGENTS.md b/python/packages/openai/AGENTS.md index 48c3a306bd..752919fa28 100644 --- a/python/packages/openai/AGENTS.md +++ b/python/packages/openai/AGENTS.md @@ -11,9 +11,7 @@ agent_framework_openai/ ├── _chat_completion_client.py # OpenAIChatCompletionClient (Chat Completions API) + RawOpenAIChatCompletionClient ├── _embedding_client.py # OpenAIEmbeddingClient ├── _exceptions.py # OpenAI-specific exceptions -├── _shared.py # OpenAIBase, OpenAIConfigMixin, OpenAISettings -├── _assistants_client.py # OpenAIAssistantsClient (DEPRECATED) -└── _assistant_provider.py # OpenAIAssistantProvider (DEPRECATED) +└── _shared.py # OpenAISettings and shared config helpers ``` ## Key Classes @@ -23,7 +21,6 @@ agent_framework_openai/ | `OpenAIChatClient` | Responses API | Primary | | `OpenAIChatCompletionClient` | Chat Completions API | Primary | | `OpenAIEmbeddingClient` | Embeddings API | Primary | -| `OpenAIAssistantsClient` | Assistants API | Deprecated | All clients follow the Raw + Full-Featured pattern (e.g., `RawOpenAIChatClient` + `OpenAIChatClient`). @@ -35,4 +32,3 @@ explicit Azure inputs (`credential`, `azure_endpoint`, `api_version`) → OpenAI - `agent-framework-core` — core abstractions - `openai` — OpenAI Python SDK -- `packaging` — version checking diff --git a/python/packages/openai/README.md b/python/packages/openai/README.md index e04a1f947a..a1847e4e44 100644 --- a/python/packages/openai/README.md +++ b/python/packages/openai/README.md @@ -22,7 +22,7 @@ Use `OpenAIChatClient` for new work unless you specifically need the Chat Comple - `OpenAIChatCompletionClient` uses the Chat Completions API and is mainly for compatibility with existing Chat Completions-based integrations. -The deprecated `OpenAIResponsesClient` alias points to `OpenAIChatClient`. +The previous deprecated Responses alias has been removed. Use `OpenAIChatClient` directly. ## Environment variables diff --git a/python/packages/openai/agent_framework_openai/__init__.py b/python/packages/openai/agent_framework_openai/__init__.py index 5744c16b43..68e00c39db 100644 --- a/python/packages/openai/agent_framework_openai/__init__.py +++ b/python/packages/openai/agent_framework_openai/__init__.py @@ -7,19 +7,7 @@ including clients for the Responses API and Chat Completions API. """ import importlib.metadata -import sys -if sys.version_info >= (3, 13): - from warnings import deprecated # type: ignore # pragma: no cover -else: - from typing_extensions import deprecated # type: ignore # pragma: no cover - -from ._assistant_provider import OpenAIAssistantProvider -from ._assistants_client import ( - AssistantToolResources, - OpenAIAssistantsClient, # type: ignore[reportDeprecated] - OpenAIAssistantsOptions, -) from ._chat_client import ( OpenAIChatClient, OpenAIChatOptions, @@ -40,35 +28,8 @@ try: except importlib.metadata.PackageNotFoundError: __version__ = "0.0.0" # Fallback for development mode -# Deprecated aliases for old names — use subclasses so the warning only fires for the alias - - -@deprecated( - "OpenAIResponsesClient is deprecated, use OpenAIChatClient instead.", - category=DeprecationWarning, -) -class OpenAIResponsesClient(OpenAIChatClient): # type: ignore[misc] - """Deprecated alias for :class:`OpenAIChatClient`.""" - - -@deprecated( - "RawOpenAIResponsesClient is deprecated, use RawOpenAIChatClient instead.", - category=DeprecationWarning, -) -class RawOpenAIResponsesClient(RawOpenAIChatClient): # type: ignore[misc] - """Deprecated alias for :class:`RawOpenAIChatClient`.""" - - -OpenAIResponsesOptions = OpenAIChatOptions -"""Deprecated alias for :class:`OpenAIChatOptions`.""" - - __all__ = [ - "AssistantToolResources", "ContentFilterResultSeverity", - "OpenAIAssistantProvider", - "OpenAIAssistantsClient", - "OpenAIAssistantsOptions", "OpenAIChatClient", "OpenAIChatCompletionClient", "OpenAIChatCompletionOptions", @@ -77,11 +38,8 @@ __all__ = [ "OpenAIContinuationToken", "OpenAIEmbeddingClient", "OpenAIEmbeddingOptions", - "OpenAIResponsesClient", - "OpenAIResponsesOptions", "OpenAISettings", "RawOpenAIChatClient", "RawOpenAIChatCompletionClient", - "RawOpenAIResponsesClient", "__version__", ] diff --git a/python/packages/openai/agent_framework_openai/_assistant_provider.py b/python/packages/openai/agent_framework_openai/_assistant_provider.py deleted file mode 100644 index f899607039..0000000000 --- a/python/packages/openai/agent_framework_openai/_assistant_provider.py +++ /dev/null @@ -1,564 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import sys -from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence -from typing import TYPE_CHECKING, Any, Generic, cast - -from agent_framework._agents import Agent -from agent_framework._middleware import MiddlewareTypes -from agent_framework._sessions import BaseContextProvider -from agent_framework._settings import SecretString, load_settings -from agent_framework._tools import FunctionTool, ToolTypes, normalize_tools -from openai import AsyncOpenAI -from openai.types.beta.assistant import Assistant -from pydantic import BaseModel - -from ._assistants_client import OpenAIAssistantsClient # type: ignore[reportDeprecated] -from ._shared import OpenAISettings, from_assistant_tools, to_assistant_tools - -if TYPE_CHECKING: - from ._assistants_client import OpenAIAssistantsOptions - -if sys.version_info >= (3, 13): - from typing import TypeVar # type:ignore # pragma: no cover -else: - from typing_extensions import TypeVar # type:ignore # pragma: no cover -if sys.version_info >= (3, 11): - from typing import Self, TypedDict # type:ignore # pragma: no cover -else: - from typing_extensions import Self, TypedDict # type:ignore # pragma: no cover - - -# Type variable for options - allows typed OpenAIAssistantProvider[OptionsCoT] returns -# Default matches OpenAIAssistantsClient's default options type -OptionsCoT = TypeVar( - "OptionsCoT", - bound=TypedDict, # type: ignore[valid-type] - default="OpenAIAssistantsOptions", - covariant=True, -) - - -class OpenAIAssistantProvider(Generic[OptionsCoT]): - """Provider for creating Agent instances from OpenAI Assistants API. - - This provider allows you to create, retrieve, and wrap OpenAI Assistants - as Agent instances for use in the agent framework. - - Examples: - Basic usage with automatic client creation: - - .. code-block:: python - - from agent_framework.openai import OpenAIAssistantProvider - - # Uses OPENAI_API_KEY environment variable - provider = OpenAIAssistantProvider() - - # Create a new assistant - agent = await provider.create_agent( - name="MyAssistant", - model="gpt-4", - instructions="You are a helpful assistant.", - tools=[my_function], - ) - - result = await agent.run("Hello!") - - Using an existing client: - - .. code-block:: python - - from openai import AsyncOpenAI - from agent_framework.openai import OpenAIAssistantProvider - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Get an existing assistant by ID - agent = await provider.get_agent( - assistant_id="asst_123", - tools=[my_function], # Provide implementations for function tools - ) - - Wrapping an SDK Assistant object: - - .. code-block:: python - - # Fetch assistant directly via SDK - assistant = await client.beta.assistants.retrieve("asst_123") - - # Wrap without additional HTTP call - agent = provider.as_agent(assistant, tools=[my_function]) - """ - - def __init__( - self, - client: AsyncOpenAI | None = None, - *, - api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, - org_id: str | None = None, - base_url: str | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize the OpenAI Assistant Provider. - - Args: - client: An existing AsyncOpenAI client to use. If not provided, - a new client will be created using the other parameters. - - Keyword Args: - api_key: OpenAI API key. Can also be set via OPENAI_API_KEY env var. - org_id: OpenAI organization ID. Can also be set via OPENAI_ORG_ID env var. - base_url: Base URL for the OpenAI API. Can also be set via OPENAI_BASE_URL env var. - env_file_path: Path to .env file for configuration. - env_file_encoding: Encoding of the .env file. - - Raises: - ValueError: If no client is provided and API key is missing. - - Examples: - .. code-block:: python - - # Using environment variables - provider = OpenAIAssistantProvider() - - # Using explicit API key - provider = OpenAIAssistantProvider(api_key="sk-...") - - # Using existing client - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - """ - self._client: AsyncOpenAI | None = client - self._should_close_client: bool = client is None - - if client is None: - # Load settings and create client - settings = load_settings( - OpenAISettings, - env_prefix="OPENAI_", - api_key=api_key, - org_id=org_id, - base_url=base_url, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - - api_key_setting = settings.get("api_key") - if not api_key_setting: - raise ValueError( - "OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable." - ) - - # Get API key value - api_key_value: str | Callable[[], str | Awaitable[str]] - if isinstance(api_key_setting, SecretString): - api_key_value = api_key_setting.get_secret_value() - else: - api_key_value = api_key_setting - - # Create client - client_args: dict[str, Any] = {"api_key": api_key_value} - if org_id_value := settings.get("org_id"): - client_args["organization"] = org_id_value - if base_url_value := settings.get("base_url"): - client_args["base_url"] = base_url_value - - self._client = AsyncOpenAI(**client_args) - - async def __aenter__(self) -> Self: - """Async context manager entry.""" - return self - - async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None: - """Async context manager exit.""" - await self.close() - - async def close(self) -> None: - """Close the provider and clean up resources. - - If the provider created its own client, it will be closed. - If an external client was provided, it will not be closed. - """ - if self._should_close_client and self._client is not None: - await self._client.close() - - async def create_agent( - self, - *, - name: str, - model: str, - instructions: str | None = None, - description: str | None = None, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - metadata: dict[str, str] | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Create a new assistant on OpenAI and return a Agent. - - This method creates a new assistant on the OpenAI service and wraps it - in a Agent instance. The assistant will persist on OpenAI until deleted. - - Keyword Args: - name: The name of the assistant (required). - model: The model ID to use, e.g., "gpt-4", "gpt-4o" (required). - instructions: System instructions for the assistant. - description: A description of the assistant. - tools: Tools available to the assistant. Can include: - - FunctionTool instances or callables decorated with @tool - - Dict-based tools from OpenAIAssistantsClient.get_code_interpreter_tool() - - Dict-based tools from OpenAIAssistantsClient.get_file_search_tool() - - Raw tool dictionaries - metadata: Metadata to attach to the assistant (max 16 key-value pairs). - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - Include ``response_format`` here for structured output responses. - middleware: MiddlewareTypes for the Agent. - context_providers: Context providers for the Agent. - - Returns: - A Agent instance wrapping the created assistant. - - Raises: - ValueError: If assistant creation fails. - - Examples: - .. code-block:: python - - provider = OpenAIAssistantProvider() - - # Create with function tools - agent = await provider.create_agent( - name="WeatherBot", - model="gpt-4", - instructions="You are a helpful weather assistant.", - tools=[get_weather], - ) - - # Create with structured output - agent = await provider.create_agent( - name="StructuredBot", - model="gpt-4", - default_options={"response_format": MyPydanticModel}, - ) - """ - # Normalize tools - normalized_tools = normalize_tools(tools) - assistant_tools: list[FunctionTool | MutableMapping[str, Any]] = [ - tool for tool in normalized_tools if isinstance(tool, (FunctionTool, MutableMapping)) - ] - api_tools = to_assistant_tools(assistant_tools) if assistant_tools else [] - - # Extract response_format from default_options if present - opts = dict(default_options) if default_options else {} - response_format = opts.get("response_format") - - # Build assistant creation parameters - create_params: dict[str, Any] = { - "model": model, - "name": name, - } - - if instructions is not None: - create_params["instructions"] = instructions - if description is not None: - create_params["description"] = description - if api_tools: - create_params["tools"] = api_tools - if metadata is not None: - create_params["metadata"] = metadata - - # Handle response format for OpenAI API - if response_format is not None and isinstance(response_format, type) and issubclass(response_format, BaseModel): - create_params["response_format"] = { - "type": "json_schema", - "json_schema": { - "name": response_format.__name__, - "schema": response_format.model_json_schema(), - "strict": True, - }, - } - - # Create the assistant - if not self._client: - raise RuntimeError("OpenAI client is not initialized.") - - assistant = await self._client.beta.assistants.create(**create_params) # type: ignore[reportDeprecated] - - # Create Agent - pass default_options which contains response_format - return self._create_chat_agent_from_assistant( - assistant=assistant, - tools=normalized_tools, - instructions=instructions, - middleware=middleware, - context_providers=context_providers, - default_options=default_options, - ) - - async def get_agent( - self, - assistant_id: str, - *, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - instructions: str | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Retrieve an existing assistant by ID and return a Agent. - - This method fetches an existing assistant from OpenAI by its ID - and wraps it in a Agent instance. - - Args: - assistant_id: The ID of the assistant to retrieve (e.g., "asst_123"). - - Keyword Args: - tools: Function tools to make available. IMPORTANT: If the assistant - was created with function tools, you MUST provide matching - implementations here. Hosted tools (code_interpreter, file_search) - are automatically included. - instructions: Override the assistant's instructions (optional). - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: MiddlewareTypes for the Agent. - context_providers: Context providers for the Agent. - - Returns: - A Agent instance wrapping the retrieved assistant. - - Raises: - RuntimeError: If the assistant cannot be retrieved. - ValueError: If required function tools are missing. - - Examples: - .. code-block:: python - - provider = OpenAIAssistantProvider() - - # Get assistant without function tools - agent = await provider.get_agent(assistant_id="asst_123") - - # Get assistant with function tools - agent = await provider.get_agent( - assistant_id="asst_456", - tools=[get_weather, search_database], # Implementations required! - ) - """ - # Fetch the assistant - if not self._client: - raise RuntimeError("OpenAI client is not initialized.") - - assistant = await self._client.beta.assistants.retrieve(assistant_id) # type: ignore[reportDeprecated] - - # Use as_agent to wrap it - return self.as_agent( - assistant=assistant, - tools=tools, - instructions=instructions, - default_options=default_options, - middleware=middleware, - context_providers=context_providers, - ) - - def as_agent( - self, - assistant: Assistant, - *, - tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, - instructions: str | None = None, - default_options: OptionsCoT | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - context_providers: Sequence[BaseContextProvider] | None = None, - ) -> Agent[OptionsCoT]: - """Wrap an existing SDK Assistant object as a Agent. - - This method does NOT make any HTTP calls. It simply wraps an already- - fetched Assistant object in a Agent. - - Args: - assistant: The OpenAI Assistant SDK object to wrap. - - Keyword Args: - tools: Function tools to make available. If the assistant has - function tools defined, you MUST provide matching implementations. - Hosted tools (code_interpreter, file_search) are automatically included. - instructions: Override the assistant's instructions (optional). - default_options: A TypedDict containing default chat options for the agent. - These options are applied to every run unless overridden. - middleware: MiddlewareTypes for the Agent. - context_providers: Context providers for the Agent. - - Returns: - A Agent instance wrapping the assistant. - - Raises: - ValueError: If required function tools are missing. - - Examples: - .. code-block:: python - - client = AsyncOpenAI() - provider = OpenAIAssistantProvider(client) - - # Fetch assistant via SDK - assistant = await client.beta.assistants.retrieve("asst_123") - - # Wrap without additional HTTP call - agent = provider.as_agent( - assistant, - tools=[my_function], - instructions="Custom instructions override", - ) - """ - # Validate that required function tools are provided - self._validate_function_tools(assistant.tools or [], tools) - - # Merge hosted tools with user-provided function tools - merged_tools = self._merge_tools(assistant.tools or [], tools) - - # Create Agent - return self._create_chat_agent_from_assistant( - assistant=assistant, - tools=merged_tools, - instructions=instructions, - default_options=default_options, - middleware=middleware, - context_providers=context_providers, - ) - - def _validate_function_tools( - self, - assistant_tools: list[Any], - provided_tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None, - ) -> None: - """Validate that required function tools are provided. - - Args: - assistant_tools: Tools defined on the assistant. - provided_tools: Tools provided by the user. - - Raises: - ValueError: If a required function tool is missing. - """ - # Get function tool names from assistant - required_functions: set[str] = set() - for tool in assistant_tools: - if ( - hasattr(tool, "type") - and tool.type == "function" - and hasattr(tool, "function") - and hasattr(tool.function, "name") - ): - required_functions.add(tool.function.name) - - if not required_functions: - return # No function tools required - - # Get provided function names using normalize_tools - provided_functions: set[str] = set() - if provided_tools is not None: - normalized = normalize_tools(provided_tools) - for tool in normalized: - if isinstance(tool, FunctionTool): - provided_functions.add(tool.name) - elif isinstance(tool, Mapping): - typed_tool = cast(Mapping[str, Any], tool) - raw_func_spec = typed_tool.get("function") - if isinstance(raw_func_spec, Mapping): - typed_func_spec = cast(Mapping[str, Any], raw_func_spec) - raw_name = typed_func_spec.get("name") - if isinstance(raw_name, str) and raw_name: - provided_functions.add(raw_name) - - # Check for missing functions - missing = required_functions - provided_functions - if missing: - missing_list = ", ".join(sorted(missing)) - raise ValueError( - f"Assistant requires function tool(s) '{missing_list}' but no implementation was provided. " - f"Please pass the function implementation(s) in the 'tools' parameter." - ) - - def _merge_tools( - self, - assistant_tools: list[Any], - user_tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None, - ) -> list[FunctionTool | MutableMapping[str, Any] | Any]: - """Merge hosted tools from assistant with user-provided function tools. - - Args: - assistant_tools: Tools defined on the assistant. - user_tools: Tools provided by the user. - - Returns: - A list of all tools (hosted tools + user function implementations). - """ - merged: list[FunctionTool | MutableMapping[str, Any] | Any] = [] - - # Add hosted tools from assistant using shared conversion - hosted_tools = from_assistant_tools(assistant_tools) - merged.extend(hosted_tools) - - # Add user-provided tools (normalized) - if user_tools is not None: - normalized_user_tools = normalize_tools(user_tools) - merged.extend(normalized_user_tools) - - return merged - - def _create_chat_agent_from_assistant( - self, - assistant: Assistant, - tools: list[FunctionTool | MutableMapping[str, Any] | Any] | None, - instructions: str | None, - middleware: Sequence[MiddlewareTypes] | None, - context_providers: Sequence[BaseContextProvider] | None, - default_options: OptionsCoT | None = None, - **kwargs: Any, - ) -> Agent[OptionsCoT]: - """Create a Agent from an Assistant. - - Args: - assistant: The OpenAI Assistant object. - tools: Tools for the agent. - instructions: Instructions override. - middleware: MiddlewareTypes for the agent. - context_providers: Context providers for the agent. - default_options: Default chat options for the agent (may include response_format). - **kwargs: Additional arguments passed to Agent. - - Returns: - A configured Agent instance. - """ - # Create the chat client with the assistant - client = OpenAIAssistantsClient( # type: ignore[reportDeprecated] - model=assistant.model, - assistant_id=assistant.id, - assistant_name=assistant.name, - assistant_description=assistant.description, - async_client=self._client, - ) - - # Use instructions from assistant if not overridden - final_instructions = instructions if instructions is not None else assistant.instructions - - # Create and return Agent - return Agent( - client=client, - id=assistant.id, - name=assistant.name, - description=assistant.description, - instructions=final_instructions, - tools=tools if tools else None, - middleware=middleware, - context_providers=context_providers, - default_options=default_options, # type: ignore[arg-type] - **kwargs, - ) diff --git a/python/packages/openai/agent_framework_openai/_assistants_client.py b/python/packages/openai/agent_framework_openai/_assistants_client.py deleted file mode 100644 index 14aa764492..0000000000 --- a/python/packages/openai/agent_framework_openai/_assistants_client.py +++ /dev/null @@ -1,968 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import json -import logging -import sys -from collections.abc import ( - AsyncIterable, - Awaitable, - Callable, - Mapping, - MutableMapping, - Sequence, -) -from typing import TYPE_CHECKING, Any, Generic, Literal, TypedDict, cast - -from agent_framework._clients import BaseChatClient -from agent_framework._middleware import ChatMiddlewareLayer -from agent_framework._settings import load_settings -from agent_framework._tools import ( - FunctionInvocationConfiguration, - FunctionInvocationLayer, - FunctionTool, - normalize_tools, -) -from agent_framework._types import ( - Annotation, - ChatOptions, - ChatResponse, - ChatResponseUpdate, - Content, - Message, - ResponseStream, - TextSpanRegion, - UsageDetails, -) -from agent_framework.observability import ChatTelemetryLayer -from openai import AsyncOpenAI -from openai.types.beta.threads import ( - FileCitationAnnotation, - FileCitationDeltaAnnotation, - FilePathAnnotation, - FilePathDeltaAnnotation, - ImageURLContentBlockParam, - ImageURLParam, - MessageContentPartParam, - MessageDeltaEvent, - Run, - TextContentBlockParam, - TextDeltaBlock, -) -from openai.types.beta.threads import ( - Message as ThreadMessage, -) -from openai.types.beta.threads.run_create_params import AdditionalMessage -from openai.types.beta.threads.run_submit_tool_outputs_params import ToolOutput -from openai.types.beta.threads.runs import RunStep -from pydantic import BaseModel - -from ._shared import OpenAIConfigMixin, OpenAISettings - -if sys.version_info >= (3, 13): - from typing import TypeVar # type: ignore # pragma: no cover -else: - from typing_extensions import TypeVar # type: ignore # pragma: no cover - -if sys.version_info >= (3, 12): - from typing import override # type: ignore # pragma: no cover -else: - from typing_extensions import override # type: ignore # pragma: no cover - -if sys.version_info >= (3, 13): - from warnings import deprecated # type: ignore # pragma: no cover -else: - from typing_extensions import deprecated # type: ignore # pragma: no cover - -if sys.version_info >= (3, 11): - from typing import Self, TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import Self, TypedDict # type: ignore # pragma: no cover - -if TYPE_CHECKING: - from agent_framework._middleware import MiddlewareTypes - -logger = logging.getLogger("agent_framework.openai") - - -# region OpenAI Assistants Options TypedDict - -ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) - - -class VectorStoreToolResource(TypedDict, total=False): - """Vector store configuration for file search tool resources.""" - - vector_store_ids: list[str] - """IDs of vector stores attached to this assistant.""" - - -class CodeInterpreterToolResource(TypedDict, total=False): - """Code interpreter tool resource configuration.""" - - file_ids: list[str] - """File IDs accessible by the code interpreter tool. Max 20 files per assistant.""" - - -class AssistantToolResources(TypedDict, total=False): - """Tool resources attached to the assistant. - - See: https://platform.openai.com/docs/api-reference/assistants/createAssistant#assistants-createassistant-tool_resources - """ - - code_interpreter: CodeInterpreterToolResource - """Resources for code interpreter tool, including file IDs.""" - - file_search: VectorStoreToolResource - """Resources for file search tool, including vector store IDs.""" - - -class OpenAIAssistantsOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): - """OpenAI Assistants API-specific options dict. - - Extends base ChatOptions with Assistants API-specific parameters - for creating and running assistants. - - See: https://platform.openai.com/docs/api-reference/assistants - - Keys: - # Inherited from ChatOptions: - model_id: Deprecated. The model to use for the assistant, - translates to ``model`` in OpenAI API. - temperature: Sampling temperature between 0 and 2. - top_p: Nucleus sampling parameter. - max_tokens: Maximum number of tokens to generate, - translates to ``max_completion_tokens`` in OpenAI API. - tools: List of tools (functions, code_interpreter, file_search). - tool_choice: How the model should use tools. - allow_multiple_tool_calls: Whether to allow parallel tool calls, - translates to ``parallel_tool_calls`` in OpenAI API. - response_format: Structured output schema. - metadata: Request metadata for tracking. - - # Options not supported in Assistants API (inherited but unused): - stop: Not supported. - seed: Not supported (use assistant-level configuration instead). - frequency_penalty: Not supported. - presence_penalty: Not supported. - user: Not supported. - store: Not supported. - - # Assistants-specific options: - name: Name of the assistant. - description: Description of the assistant. - instructions: System instructions for the assistant. - tool_resources: Resources for tools (file IDs, vector stores). - reasoning_effort: Effort level for o-series reasoning models. - conversation_id: Thread ID to continue conversation in. - """ - - # Assistants-specific options - name: str - """Name of the assistant (max 256 characters).""" - - description: str - """Description of the assistant (max 512 characters).""" - - tool_resources: AssistantToolResources - """Tool-specific resources like file IDs and vector stores.""" - - reasoning_effort: Literal["low", "medium", "high"] - """Effort level for o-series reasoning models (o1, o3-mini). - Higher effort = more reasoning time and potentially better results.""" - - conversation_id: str # type: ignore[misc] - """Thread ID to continue a conversation in an existing thread.""" - - # OpenAI/ChatOptions fields not supported in Assistants API - stop: None # type: ignore[misc] - """Not supported in Assistants API.""" - - seed: None # type: ignore[misc] - """Not supported in Assistants API (use assistant-level configuration).""" - - frequency_penalty: None # type: ignore[misc] - """Not supported in Assistants API.""" - - presence_penalty: None # type: ignore[misc] - """Not supported in Assistants API.""" - - user: None # type: ignore[misc] - """Not supported in Assistants API.""" - - store: None # type: ignore[misc] - """Not supported in Assistants API.""" - - -ASSISTANTS_OPTION_TRANSLATIONS: dict[str, str] = { - "model_id": "model", # backward compat: accept model_id in options - "max_tokens": "max_completion_tokens", - "allow_multiple_tool_calls": "parallel_tool_calls", -} -"""Maps ChatOptions keys to OpenAI Assistants API parameter names.""" - -OpenAIAssistantsOptionsT = TypeVar( - "OpenAIAssistantsOptionsT", - bound=TypedDict, # type: ignore[valid-type] - default="OpenAIAssistantsOptions", - covariant=True, -) - - -# endregion - - -@deprecated("OpenAIAssistantsClient is deprecated. Use OpenAIChatClient instead.") -class OpenAIAssistantsClient( # type: ignore[misc] - OpenAIConfigMixin, - FunctionInvocationLayer[OpenAIAssistantsOptionsT], - ChatMiddlewareLayer[OpenAIAssistantsOptionsT], - ChatTelemetryLayer[OpenAIAssistantsOptionsT], - BaseChatClient[OpenAIAssistantsOptionsT], - Generic[OpenAIAssistantsOptionsT], -): - """OpenAI Assistants client with middleware, telemetry, and function invocation support. - - .. deprecated:: - OpenAIAssistantsClient is deprecated. Use :class:`OpenAIChatClient` instead. - """ - - # region Hosted Tool Factory Methods - - @staticmethod - def get_code_interpreter_tool() -> dict[str, Any]: - """Create a code interpreter tool configuration for the Assistants API. - - Returns: - A dict tool configuration ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.openai import OpenAIAssistantsClient - - # Enable code interpreter - tool = OpenAIAssistantsClient.get_code_interpreter_tool() - - agent = ChatAgent(client, tools=[tool]) - """ - return {"type": "code_interpreter"} - - @staticmethod - def get_file_search_tool( - *, - max_num_results: int | None = None, - ) -> dict[str, Any]: - """Create a file search tool configuration for the Assistants API. - - Keyword Args: - max_num_results: Maximum number of results to return from file search. - - Returns: - A dict tool configuration ready to pass to ChatAgent. - - Examples: - .. code-block:: python - - from agent_framework.openai import OpenAIAssistantsClient - - # Basic file search - tool = OpenAIAssistantsClient.get_file_search_tool() - - # With result limit - tool = OpenAIAssistantsClient.get_file_search_tool(max_num_results=10) - - agent = ChatAgent(client, tools=[tool]) - """ - tool: dict[str, Any] = {"type": "file_search"} - - if max_num_results is not None: - tool["file_search"] = {"max_num_results": max_num_results} - - return tool - - # endregion - - def __init__( - self, - *, - model: str | None = None, - model_id: str | None = None, - assistant_id: str | None = None, - assistant_name: str | None = None, - assistant_description: str | None = None, - thread_id: str | None = None, - api_key: str | Callable[[], str | Awaitable[str]] | None = None, - org_id: str | None = None, - base_url: str | None = None, - default_headers: Mapping[str, str] | None = None, - async_client: AsyncOpenAI | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - middleware: Sequence[MiddlewareTypes] | None = None, - function_invocation_configuration: FunctionInvocationConfiguration | None = None, - **kwargs: Any, - ) -> None: - """Initialize an OpenAI Assistants client. - - Keyword Args: - model: OpenAI model name, see https://platform.openai.com/docs/models. - Can also be set via environment variable OPENAI_MODEL. - model_id: Deprecated alias for ``model``. - assistant_id: The ID of an OpenAI assistant to use. - If not provided, a new assistant will be created (and deleted after the request). - assistant_name: The name to use when creating new assistants. - assistant_description: The description to use when creating new assistants. - thread_id: Default thread ID to use for conversations. Can be overridden by - conversation_id property when making a request. - If not provided, a new thread will be created (and deleted after the request). - api_key: The API key to use. If provided will override the env vars or .env file value. - Can also be set via environment variable OPENAI_API_KEY. - org_id: The org ID to use. If provided will override the env vars or .env file value. - Can also be set via environment variable OPENAI_ORG_ID. - base_url: The base URL to use. If provided will override the standard value. - Can also be set via environment variable OPENAI_BASE_URL. - default_headers: The default headers mapping of string keys to - string values for HTTP requests. - async_client: An existing client to use. - env_file_path: Use the environment settings file as a fallback - to environment variables. - env_file_encoding: The encoding of the environment settings file. - middleware: Optional sequence of middleware to apply to requests. - function_invocation_configuration: Optional configuration for function invocation behavior. - kwargs: Other keyword parameters. - - Examples: - .. code-block:: python - - from agent_framework.openai import OpenAIAssistantsClient - - # Using environment variables - # Set OPENAI_API_KEY=sk-... - # Set OPENAI_MODEL=gpt-4 - client = OpenAIAssistantsClient() - - # Or passing parameters directly - client = OpenAIAssistantsClient(model="gpt-4", api_key="sk-...") - - # Or loading from a .env file - client = OpenAIAssistantsClient(env_file_path="path/to/.env") - - # Using custom ChatOptions with type safety: - from typing import TypedDict - from agent_framework.openai import OpenAIAssistantsOptions - - - class MyOptions(OpenAIAssistantsOptions, total=False): - my_custom_option: str - - - client: OpenAIAssistantsClient[MyOptions] = OpenAIAssistantsClient(model="gpt-4") - response = await client.get_response("Hello", options={"my_custom_option": "value"}) - """ - if model_id is not None and model is None: - import warnings - - warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) - model = model_id - openai_settings = load_settings( - OpenAISettings, - env_prefix="OPENAI_", - api_key=api_key, - base_url=base_url, - org_id=org_id, - model=model, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - - api_key_value = openai_settings.get("api_key") - if not async_client and not api_key_value: - raise ValueError( - "OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable." - ) - - resolved_model = openai_settings.get("model") - if not resolved_model: - raise ValueError( - "OpenAI model is required. Set via 'model' parameter or 'OPENAI_MODEL' environment variable." - ) - - super().__init__( - model=resolved_model, - api_key=self._get_api_key(api_key_value), - org_id=openai_settings.get("org_id"), - default_headers=default_headers, - client=async_client, - base_url=openai_settings.get("base_url"), - middleware=middleware, - function_invocation_configuration=function_invocation_configuration, - ) - self.assistant_id: str | None = assistant_id - self.assistant_name: str | None = assistant_name - self.assistant_description: str | None = assistant_description - self.thread_id: str | None = thread_id - self._should_delete_assistant: bool = False - - async def __aenter__(self) -> Self: - """Async context manager entry.""" - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: Any, - ) -> None: - """Async context manager exit - clean up any assistants we created.""" - await self.close() - - async def close(self) -> None: - """Clean up any assistants we created.""" - if self._should_delete_assistant and self.assistant_id is not None: - client = await self._ensure_client() - await client.beta.assistants.delete(self.assistant_id) # type: ignore[reportDeprecated] - object.__setattr__(self, "assistant_id", None) - object.__setattr__(self, "_should_delete_assistant", False) - - @override - def _inner_get_response( - self, - *, - messages: Sequence[Message], - options: Mapping[str, Any], - stream: bool = False, - **kwargs: Any, - ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: - if stream: - # Streaming mode - return the async generator directly - async def _stream() -> AsyncIterable[ChatResponseUpdate]: - # prepare - run_options, tool_results = self._prepare_options(messages, options, **kwargs) - - # Get the thread ID - thread_id: str | None = options.get( - "conversation_id", run_options.get("conversation_id", self.thread_id) - ) - - if thread_id is None and tool_results is not None: - raise ValueError("No thread ID was provided, but chat messages includes tool results.") - - # Determine which assistant to use and create if needed - assistant_id = await self._get_assistant_id_or_create() - - # execute - stream_obj, thread_id = await self._create_assistant_stream( - thread_id, assistant_id, run_options, tool_results - ) - - # process - async for update in self._process_stream_events(stream_obj, thread_id): - yield update - - return self._build_response_stream(_stream(), response_format=options.get("response_format")) - - # Non-streaming mode - collect updates and convert to response - async def _get_response() -> ChatResponse: - stream_result = self._inner_get_response(messages=messages, options=options, stream=True, **kwargs) - return await ChatResponse.from_update_generator( - updates=stream_result, # type: ignore[arg-type] - output_format_type=options.get("response_format"), # type: ignore[arg-type] - ) - - return _get_response() - - async def _get_assistant_id_or_create(self) -> str: - """Determine which assistant to use and create if needed. - - Returns: - str: The assistant_id to use. - """ - # If no assistant is provided, create a temporary assistant - if self.assistant_id is None: - if not self.model: - raise ValueError("Parameter 'model' is required for assistant creation.") - - client = await self._ensure_client() - created_assistant = await client.beta.assistants.create( # type: ignore[reportDeprecated] - model=self.model, - description=self.assistant_description, - name=self.assistant_name, - ) - self.assistant_id = created_assistant.id - self._should_delete_assistant = True - - return self.assistant_id - - async def _create_assistant_stream( - self, - thread_id: str | None, - assistant_id: str, - run_options: dict[str, Any], - tool_results: list[Content] | None, - ) -> tuple[Any, str]: - """Create the assistant stream for processing. - - Returns: - tuple: (stream, final_thread_id) - """ - client = await self._ensure_client() - # Get any active run for this thread - thread_run = await self._get_active_thread_run(thread_id) - - tool_run_id, tool_outputs = self._prepare_tool_outputs_for_assistants(tool_results) - - if thread_run is not None and tool_run_id is not None and tool_run_id == thread_run.id and tool_outputs: - # There's an active run and we have tool results to submit, so submit the results. - stream = client.beta.threads.runs.submit_tool_outputs_stream( # type: ignore[reportDeprecated] - run_id=tool_run_id, - thread_id=thread_run.thread_id, - tool_outputs=tool_outputs, - ) - final_thread_id = thread_run.thread_id - else: - # Handle thread creation or cancellation - final_thread_id = await self._prepare_thread(thread_id, thread_run, run_options) - - # Now create a new run and stream the results. - stream = client.beta.threads.runs.stream( # type: ignore[reportDeprecated] - assistant_id=assistant_id, thread_id=final_thread_id, **run_options - ) - - return stream, final_thread_id - - async def _get_active_thread_run(self, thread_id: str | None) -> Run | None: - """Get any active run for the given thread.""" - client = await self._ensure_client() - if thread_id is None: - return None - - async for run in client.beta.threads.runs.list(thread_id=thread_id, limit=1, order="desc"): # type: ignore[reportDeprecated] - if run.status not in ["completed", "cancelled", "failed", "expired"]: - return run - return None - - async def _prepare_thread(self, thread_id: str | None, thread_run: Run | None, run_options: dict[str, Any]) -> str: - """Prepare the thread for a new run, creating or cleaning up as needed.""" - client = await self._ensure_client() - if thread_id is None: - # No thread ID was provided, so create a new thread. - thread = await client.beta.threads.create( # type: ignore[reportDeprecated] - messages=run_options["additional_messages"], - tool_resources=run_options.get("tool_resources"), - metadata=run_options.get("metadata"), - ) - run_options["additional_messages"] = [] - run_options.pop("tool_resources", None) - return thread.id - - if thread_run is not None: - # There was an active run; we need to cancel it before starting a new run. - await client.beta.threads.runs.cancel(run_id=thread_run.id, thread_id=thread_id) # type: ignore[reportDeprecated] - - return thread_id - - async def _process_stream_events(self, stream: Any, thread_id: str) -> AsyncIterable[ChatResponseUpdate]: - response_id: str | None = None - - async with stream as response_stream: - async for response in response_stream: - if response.event == "thread.run.created": - yield ChatResponseUpdate( - contents=[], - conversation_id=thread_id, - message_id=response_id, - raw_representation=response.data, - response_id=response_id, - role="assistant", - ) - elif response.event == "thread.run.step.created" and isinstance(response.data, RunStep): - response_id = response.data.run_id - elif response.event == "thread.message.delta" and isinstance(response.data, MessageDeltaEvent): - delta = response.data.delta - role = "user" if delta.role == "user" else "assistant" - - for delta_block in delta.content or []: - if isinstance(delta_block, TextDeltaBlock) and delta_block.text and delta_block.text.value: - text_content = Content.from_text(delta_block.text.value) - if delta_block.text.annotations: - annotations: list[Annotation] = [] - text_content.annotations = annotations - for annotation in delta_block.text.annotations: - if isinstance(annotation, FileCitationDeltaAnnotation): - ann: Annotation = Annotation( - type="citation", - additional_properties={ - "text": annotation.text, - "index": annotation.index, - }, - raw_representation=annotation, - ) - if annotation.file_citation and annotation.file_citation.file_id: - ann["file_id"] = annotation.file_citation.file_id - if annotation.start_index is not None and annotation.end_index is not None: - ann["annotated_regions"] = [ - TextSpanRegion( - type="text_span", - start_index=annotation.start_index, - end_index=annotation.end_index, - ) - ] - annotations.append(ann) - elif isinstance(annotation, FilePathDeltaAnnotation): - ann = Annotation( - type="citation", - additional_properties={ - "text": annotation.text, - "index": annotation.index, - }, - raw_representation=annotation, - ) - if annotation.file_path and annotation.file_path.file_id: - ann["file_id"] = annotation.file_path.file_id - if annotation.start_index is not None and annotation.end_index is not None: - ann["annotated_regions"] = [ - TextSpanRegion( - type="text_span", - start_index=annotation.start_index, - end_index=annotation.end_index, - ) - ] - annotations.append(ann) - yield ChatResponseUpdate( - role=role, # type: ignore[arg-type] - contents=[text_content], - conversation_id=thread_id, - message_id=response_id, - raw_representation=response.data, - response_id=response_id, - ) - elif response.event == "thread.message.completed" and isinstance(response.data, ThreadMessage): - # Process completed message to extract fully resolved annotations. - # Delta events may carry partial/empty annotation data; the completed - # message contains the final text with all citation details populated. - completed_contents: list[Content] = [] - for block in response.data.content: - if block.type != "text": - continue - text_content = Content.from_text(block.text.value) - if block.text.annotations: - completed_annotations: list[Annotation] = [] - text_content.annotations = completed_annotations - for completed_annotation in block.text.annotations: - if isinstance(completed_annotation, FileCitationAnnotation): - props: dict[str, Any] = { - "text": completed_annotation.text, - } - ann = Annotation( - type="citation", - additional_properties=props, - raw_representation=completed_annotation, - ) - if ( - completed_annotation.file_citation - and completed_annotation.file_citation.file_id - ): - ann["file_id"] = completed_annotation.file_citation.file_id - ann["annotated_regions"] = [ - TextSpanRegion( - type="text_span", - start_index=completed_annotation.start_index, - end_index=completed_annotation.end_index, - ) - ] - text_content.annotations.append(ann) - elif isinstance(completed_annotation, FilePathAnnotation): - ann = Annotation( - type="citation", - additional_properties={ - "text": completed_annotation.text, - }, - raw_representation=completed_annotation, - ) - if completed_annotation.file_path and completed_annotation.file_path.file_id: - ann["file_id"] = completed_annotation.file_path.file_id - ann["annotated_regions"] = [ - TextSpanRegion( - type="text_span", - start_index=completed_annotation.start_index, - end_index=completed_annotation.end_index, - ) - ] - text_content.annotations.append(ann) - else: - logger.debug("Unparsed annotation type: %s", completed_annotation.type) - completed_contents.append(text_content) - if completed_contents: - yield ChatResponseUpdate( - role="assistant", - contents=completed_contents, - conversation_id=thread_id, - message_id=response_id, - raw_representation=response.data, - response_id=response_id, - ) - elif response.event == "thread.run.requires_action" and isinstance(response.data, Run): - contents = self._parse_function_calls_from_assistants(response.data, response_id) - if contents: - yield ChatResponseUpdate( - role="assistant", - contents=contents, - conversation_id=thread_id, - message_id=response_id, - raw_representation=response.data, - response_id=response_id, - ) - elif ( - response.event == "thread.run.completed" - and isinstance(response.data, Run) - and response.data.usage is not None - ): - usage = response.data.usage - usage_content = Content.from_usage( - UsageDetails( - input_token_count=usage.prompt_tokens, - output_token_count=usage.completion_tokens, - total_token_count=usage.total_tokens, - ) - ) - yield ChatResponseUpdate( - role="assistant", - contents=[usage_content], - conversation_id=thread_id, - message_id=response_id, - raw_representation=response.data, - response_id=response_id, - ) - else: - yield ChatResponseUpdate( - contents=[], - conversation_id=thread_id, - message_id=response_id, - raw_representation=response.data, - response_id=response_id, - role="assistant", - ) - - def _parse_function_calls_from_assistants(self, event_data: Run, response_id: str | None) -> list[Content]: - """Parse function call contents from an assistants tool action event.""" - contents: list[Content] = [] - - if event_data.required_action is not None: - for tool_call in event_data.required_action.submit_tool_outputs.tool_calls: - tool_call_any = cast(Any, tool_call) - call_id = json.dumps([response_id, tool_call.id]) - tool_type = getattr(tool_call, "type", None) - if tool_type == "code_interpreter" and getattr(tool_call_any, "code_interpreter", None): - code_input = getattr(tool_call_any.code_interpreter, "input", None) - inputs = ( - [Content.from_text(text=code_input, raw_representation=tool_call)] - if code_input is not None - else None - ) - contents.append( - Content.from_code_interpreter_tool_call( - call_id=call_id, - inputs=inputs, - raw_representation=tool_call, - ) - ) - elif tool_type == "mcp": - contents.append( - Content.from_mcp_server_tool_call( - call_id=call_id, - tool_name=getattr(tool_call, "name", "") or "", - server_name=getattr(tool_call, "server_label", None), - arguments=getattr(tool_call, "args", None), - raw_representation=tool_call, - ) - ) - else: - function_name = tool_call.function.name - function_arguments = json.loads(tool_call.function.arguments) - contents.append( - Content.from_function_call( - call_id=call_id, - name=function_name, - arguments=function_arguments, - ) - ) - - return contents - - def _prepare_options( - self, - messages: Sequence[Message], - options: Mapping[str, Any], - **kwargs: Any, - ) -> tuple[dict[str, Any], list[Content] | None]: - from agent_framework._types import validate_tool_mode - - run_options: dict[str, Any] = {**kwargs} - - # Extract options from the dict - max_tokens = options.get("max_tokens") - model = options.get("model") or options.get("model_id") # backward compat - top_p = options.get("top_p") - temperature = options.get("temperature") - allow_multiple_tool_calls = options.get("allow_multiple_tool_calls") - tool_choice = options.get("tool_choice") - tools = options.get("tools") - response_format = options.get("response_format") - tool_resources = options.get("tool_resources") - - if max_tokens is not None: - run_options["max_completion_tokens"] = max_tokens - if model is not None: - run_options["model"] = model - if top_p is not None: - run_options["top_p"] = top_p - if temperature is not None: - run_options["temperature"] = temperature - - if allow_multiple_tool_calls is not None: - run_options["parallel_tool_calls"] = allow_multiple_tool_calls - - if tool_resources is not None: - run_options["tool_resources"] = tool_resources - - tool_mode = validate_tool_mode(tool_choice) - tool_definitions: list[MutableMapping[str, Any]] = [] - # Always include tools if provided, regardless of tool_choice - # tool_choice="none" means the model won't call tools, but tools should still be available - for tool in normalize_tools(tools): - if isinstance(tool, FunctionTool): - tool_definitions.append(tool.to_json_schema_spec()) # type: ignore[reportUnknownArgumentType] - elif isinstance(tool, MutableMapping): - # Pass through dict-based tools directly (from static factory methods) - tool_definitions.append(cast(MutableMapping[str, Any], tool)) - - if len(tool_definitions) > 0: - run_options["tools"] = tool_definitions - - if tool_mode is not None: - mode = tool_mode.get("mode") - if mode is None: - raise ValueError("tool_choice mode is required") - if mode == "required" and (func_name := tool_mode.get("required_function_name")) is not None: - run_options["tool_choice"] = { - "type": "function", - "function": {"name": func_name}, - } - else: - run_options["tool_choice"] = mode - - if response_format is not None: - if isinstance(response_format, dict): - run_options["response_format"] = response_format - else: - run_options["response_format"] = { - "type": "json_schema", - "json_schema": { - "name": response_format.__name__, - "schema": response_format.model_json_schema(), - "strict": True, - }, - } - - instructions: list[str] = [] - tool_results: list[Content] | None = None - - additional_messages: list[AdditionalMessage] | None = None - - # System/developer messages are turned into instructions, - # since there is no such message roles in OpenAI Assistants. - # All other messages are added 1:1. - for chat_message in messages: - if chat_message.role in ["system", "developer"]: - for text_content in [content for content in chat_message.contents if content.type == "text"]: - text = getattr(text_content, "text", None) - if text: - instructions.append(text) - - continue - - message_contents: list[MessageContentPartParam] = [] - - for content in chat_message.contents: - if content.type == "text": - message_contents.append(TextContentBlockParam(type="text", text=content.text)) # type: ignore[attr-defined, typeddict-item] - elif content.type == "uri" and content.has_top_level_media_type("image"): - message_contents.append( - ImageURLContentBlockParam(type="image_url", image_url=ImageURLParam(url=content.uri)) # type: ignore[attr-defined, typeddict-item] - ) - elif content.type == "function_result": - if tool_results is None: - tool_results = [] - tool_results.append(content) - - if len(message_contents) > 0: - if additional_messages is None: - additional_messages = [] - additional_messages.append( - AdditionalMessage( - role="assistant" if chat_message.role == "assistant" else "user", - content=message_contents, - ) - ) - - if additional_messages is not None: - run_options["additional_messages"] = additional_messages - - if len(instructions) > 0: - run_options["instructions"] = "".join(instructions) - - return run_options, tool_results - - def _prepare_tool_outputs_for_assistants( - self, - tool_results: list[Content] | None, - ) -> tuple[str | None, list[ToolOutput] | None]: - """Prepare function results for submission to the assistants API.""" - run_id: str | None = None - tool_outputs: list[ToolOutput] | None = None - - if tool_results: - for function_result_content in tool_results: - # When creating the FunctionCallContent, we created it with a CallId == [runId, callId]. - # We need to extract the run ID and ensure that the ToolOutput we send back to Azure - # is only the call ID. - run_and_call_ids: list[str] = json.loads(function_result_content.call_id) # type: ignore[arg-type] - - if ( - not run_and_call_ids - or len(run_and_call_ids) != 2 - or not run_and_call_ids[0] - or not run_and_call_ids[1] - or (run_id is not None and run_id != run_and_call_ids[0]) - ): - continue - - run_id = run_and_call_ids[0] - call_id = run_and_call_ids[1] - - if tool_outputs is None: - tool_outputs = [] - output = ( - function_result_content.result - if function_result_content.result is not None - else "No output received." - ) - tool_outputs.append(ToolOutput(tool_call_id=call_id, output=output)) - - return run_id, tool_outputs - - def _update_agent_name_and_description(self, agent_name: str | None, description: str | None = None) -> None: - """Update the agent name in the chat client. - - Args: - agent_name: The new name for the agent. - description: The new description for the agent. - """ - # This is a no-op in the base class, but can be overridden by subclasses - # to update the agent name in the client. - if agent_name and not self.assistant_name: - self.assistant_name = agent_name - if description and not self.assistant_description: - self.assistant_description = description diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index bc7f2f3ba8..30c88b0848 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -1235,7 +1235,7 @@ class RawOpenAIChatClient( # type: ignore[misc] def _check_model_presence(self, options: dict[str, Any]) -> None: """Check if the 'model' param is present, and if not raise a Error. - Since AzureAIClients use a different param for this, this method is overridden in those clients. + Subclasses can override this when they populate the model through a different option field. """ if not options.get("model"): if not self.model: diff --git a/python/packages/openai/agent_framework_openai/_shared.py b/python/packages/openai/agent_framework_openai/_shared.py index feb30cc7d5..704583c30f 100644 --- a/python/packages/openai/agent_framework_openai/_shared.py +++ b/python/packages/openai/agent_framework_openai/_shared.py @@ -2,17 +2,13 @@ from __future__ import annotations -import logging import sys -from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence +from collections.abc import Awaitable, Callable, Mapping, Sequence from copy import copy -from typing import TYPE_CHECKING, Any, ClassVar, Literal, Union, cast +from typing import TYPE_CHECKING, Any, Literal, Union -import openai -from agent_framework._serialization import SerializationMixin from agent_framework._settings import SecretString, load_settings -from agent_framework._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent -from agent_framework._tools import FunctionTool +from agent_framework._telemetry import APP_INFO, prepend_agent_framework_to_user_agent from agent_framework.exceptions import SettingNotFoundError from openai import AsyncAzureOpenAI, AsyncOpenAI, AsyncStream, _legacy_response # type: ignore from openai.types import Completion @@ -21,7 +17,6 @@ from openai.types.chat import ChatCompletion, ChatCompletionChunk from openai.types.images_response import ImagesResponse from openai.types.responses.response import Response from openai.types.responses.response_stream_event import ResponseStreamEvent -from packaging.version import parse if sys.version_info >= (3, 11): from typing import TypedDict # type: ignore # pragma: no cover @@ -35,8 +30,6 @@ if TYPE_CHECKING: AzureCredentialTypes = TokenCredential | AsyncTokenCredential -logger: logging.Logger = logging.getLogger("agent_framework.openai") - AZURE_OPENAI_TOKEN_SCOPE = "https://cognitiveservices.azure.com/.default" # noqa: S105 # nosec B105 @@ -56,29 +49,6 @@ RESPONSE_TYPE = Union[ AzureTokenProvider = Callable[[], str | Awaitable[str]] -def _check_openai_version_for_callable_api_key() -> None: - """Check if OpenAI version supports callable API keys. - - Callable API keys require OpenAI >= 1.106.0. - If the version is too old, raise a ValueError with helpful message. - """ - try: - current_version = parse(openai.__version__) - min_required_version = parse("1.106.0") - - if current_version < min_required_version: - raise ValueError( - f"Callable API keys require OpenAI SDK >= 1.106.0, but you have {openai.__version__}. " - f"Please upgrade with 'pip install openai>=1.106.0' or provide a string API key instead. " - f"Note: If you're using mem0ai, you may need to upgrade to mem0ai>=1.0.0 " - f"to allow newer OpenAI versions." - ) - except ValueError: - raise # Re-raise our own exception - except Exception as e: - logger.warning(f"Could not check OpenAI version for callable API key support: {e}") - - class OpenAISettings(TypedDict, total=False): """OpenAI environment settings. @@ -374,256 +344,4 @@ def get_api_key( if isinstance(api_key, SecretString): return api_key.get_secret_value() - # Check version compatibility for callable API keys - if callable(api_key): - _check_openai_version_for_callable_api_key() - return api_key # Pass callable, string, or None directly to OpenAI SDK - - -class OpenAIBase(SerializationMixin): - """Base class for OpenAI Clients. - - .. deprecated:: - ``OpenAIBase`` is deprecated and only used by ``OpenAIAssistantsClient`` - and ``AzureOpenAIAssistantsClient``. New clients should manage ``client`` - and ``model`` directly in their own ``__init__``. - """ - - INJECTABLE: ClassVar[set[str]] = {"client"} - - def __init__( - self, *, model: str | None = None, model_id: str | None = None, client: AsyncOpenAI | None = None, **kwargs: Any - ) -> None: - """Initialize OpenAIBase. - - Keyword Args: - client: The AsyncOpenAI client instance. - model: The AI model to use. - model_id: Deprecated alias for ``model``. - **kwargs: Additional keyword arguments. - """ - if model_id is not None and model is None: - import warnings - - warnings.warn("model_id is deprecated, use model instead", DeprecationWarning, stacklevel=2) - model = model_id - self.client = client - self.model: str | None = None - if model: - self.model = model.strip() - - # Call super().__init__() to continue MRO chain (e.g., RawChatClient) - # Extract known kwargs that belong to other base classes - additional_properties = kwargs.pop("additional_properties", None) - middleware = kwargs.pop("middleware", None) - instruction_role = kwargs.pop("instruction_role", None) - function_invocation_configuration = kwargs.pop("function_invocation_configuration", None) - - # Build super().__init__() args - super_kwargs = {} - if additional_properties is not None: - super_kwargs["additional_properties"] = additional_properties - if middleware is not None: - super_kwargs["middleware"] = middleware - if function_invocation_configuration is not None: - super_kwargs["function_invocation_configuration"] = function_invocation_configuration - - # Call super().__init__() with filtered kwargs - super().__init__(**super_kwargs) - - # Store instruction_role and any remaining kwargs as instance attributes - if instruction_role is not None: - self.instruction_role = instruction_role - for key, value in kwargs.items(): - setattr(self, key, value) - - async def _initialize_client(self) -> None: - """Initialize OpenAI client asynchronously. - - Override in subclasses to initialize the OpenAI client asynchronously. - """ - pass - - async def _ensure_client(self) -> AsyncOpenAI: - """Ensure OpenAI client is initialized.""" - await self._initialize_client() - if self.client is None: - raise RuntimeError("OpenAI client is not initialized") - - return self.client - - def _get_api_key( - self, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None - ) -> str | Callable[[], str | Awaitable[str]] | None: - """Get the appropriate API key value for client initialization. - - Args: - api_key: The API key parameter which can be a string, SecretString, callable, or None. - - Returns: - For callable API keys: returns the callable directly. - For SecretString/string/None API keys: returns as-is (SecretString is a str subclass). - """ - if isinstance(api_key, SecretString): - return api_key.get_secret_value() - - # Check version compatibility for callable API keys - if callable(api_key): - _check_openai_version_for_callable_api_key() - - return api_key # Pass callable, string, or None directly to OpenAI SDK - - -class OpenAIConfigMixin(OpenAIBase): - """Internal class for configuring a connection to an OpenAI service. - - .. deprecated:: - ``OpenAIConfigMixin`` is deprecated and only used by ``OpenAIAssistantsClient`` - and ``AzureOpenAIAssistantsClient``. New clients handle configuration - directly in their own ``__init__``. - """ - - OTEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc] - - def __init__( - self, - model: str, - api_key: str | Callable[[], str | Awaitable[str]] | None = None, - org_id: str | None = None, - default_headers: Mapping[str, str] | None = None, - client: AsyncOpenAI | None = None, - instruction_role: str | None = None, - base_url: str | None = None, - **kwargs: Any, - ) -> None: - """Initialize a client for OpenAI services. - - This constructor sets up a client to interact with OpenAI's API, allowing for - different types of AI model interactions, like chat or text completion. - - Args: - model: OpenAI model identifier. Must be non-empty. - Default to a preset value. - api_key: OpenAI API key for authentication, or a callable that returns an API key. - Must be non-empty. (Optional) - org_id: OpenAI organization ID. This is optional - unless the account belongs to multiple organizations. - default_headers: Default headers - for HTTP requests. (Optional) - client: An existing OpenAI client, optional. - instruction_role: The role to use for 'instruction' - messages, for example, summarization prompts could use `developer` or `system`. (Optional) - base_url: The optional base URL to use. If provided will override the standard value for a OpenAI connector. - Will not be used when supplying a custom client. - kwargs: Additional keyword arguments. - - """ - # Merge APP_INFO into the headers if it exists - merged_headers = dict(copy(default_headers)) if default_headers else {} - if APP_INFO: - merged_headers.update(APP_INFO) - merged_headers = prepend_agent_framework_to_user_agent(merged_headers) - - # Handle callable API key using base class method - api_key_value = self._get_api_key(api_key) - - if not client: - if not api_key: - raise ValueError("Please provide an api_key") - args: dict[str, Any] = {"api_key": api_key_value, "default_headers": merged_headers} - if org_id: - args["organization"] = org_id - if base_url: - args["base_url"] = base_url - client = AsyncOpenAI(**args) - - # Store configuration as instance attributes for serialization - self.org_id = org_id - self.base_url = str(base_url) - # Store default_headers but filter out USER_AGENT_KEY for serialization - if default_headers: - self.default_headers: dict[str, Any] | None = { - k: v for k, v in default_headers.items() if k != USER_AGENT_KEY - } - else: - self.default_headers = None - - args = { - "model": model, - "client": client, - } - if instruction_role: - args["instruction_role"] = instruction_role - - # Ensure additional_properties and middleware are passed through kwargs to RawChatClient - # These are consumed by RawChatClient.__init__ via kwargs - super().__init__(**args, **kwargs) - - -def to_assistant_tools( - tools: Sequence[FunctionTool | MutableMapping[str, Any]] | None, -) -> list[dict[str, Any]]: - """Convert Agent Framework tools to OpenAI Assistants API format. - - Handles FunctionTool instances and dict-based tools from static factory methods. - - Args: - tools: Sequence of Agent Framework tools. - - Returns: - List of tool definitions for OpenAI Assistants API. - """ - if not tools: - return [] - - tool_definitions: list[dict[str, Any]] = [] - - for tool in tools: - if isinstance(tool, FunctionTool): - tool_definitions.append(tool.to_json_schema_spec()) - elif isinstance(tool, MutableMapping): - # Pass through dict-based tools directly (from static factory methods) - tool_definitions.append(dict(tool)) - - return tool_definitions - - -def from_assistant_tools( - assistant_tools: list[Any] | None, -) -> list[dict[str, Any]]: - """Convert OpenAI Assistant tools to dict-based format. - - This converts hosted tools (code_interpreter, file_search) from an OpenAI - Assistant definition back to dict-based tool definitions. - - Note: Function tools are skipped - user must provide implementations separately. - - Args: - assistant_tools: Tools from OpenAI Assistant object (assistant.tools). - - Returns: - List of dict-based tool definitions for hosted tools. - """ - if not assistant_tools: - return [] - - tools: list[dict[str, Any]] = [] - - for tool in assistant_tools: - if hasattr(tool, "type"): - tool_type = tool.type - elif isinstance(tool, Mapping): - typed_tool = cast(Mapping[str, Any], tool) - tool_type_value: Any = typed_tool.get("type") - tool_type = tool_type_value if isinstance(tool_type_value, str) else None - else: - tool_type = None - - if tool_type == "code_interpreter": - tools.append({"type": "code_interpreter"}) - elif tool_type == "file_search": - tools.append({"type": "file_search"}) - # Skip function tools - user must provide implementations - - return tools diff --git a/python/packages/openai/pyproject.toml b/python/packages/openai/pyproject.toml index eb31ca5956..7d257ef7f1 100644 --- a/python/packages/openai/pyproject.toml +++ b/python/packages/openai/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-openai" -description = "OpenAI integration for Microsoft Agent Framework." +description = "OpenAI integrations for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" @@ -25,7 +25,6 @@ classifiers = [ dependencies = [ "agent-framework-core>=1.0.0rc6", "openai>=1.99.0,<3", - "packaging>=24.1,<25", ] [tool.uv] diff --git a/python/packages/openai/tests/openai/test_assistant_provider.py b/python/packages/openai/tests/openai/test_assistant_provider.py deleted file mode 100644 index df811f6c37..0000000000 --- a/python/packages/openai/tests/openai/test_assistant_provider.py +++ /dev/null @@ -1,751 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from typing import Annotated, Any -from unittest.mock import AsyncMock, MagicMock - -import pytest -from agent_framework import Agent, normalize_tools, tool -from openai.types.beta.assistant import Assistant -from pydantic import BaseModel, Field - -from agent_framework_openai import OpenAIAssistantProvider, OpenAIAssistantsClient -from agent_framework_openai._shared import from_assistant_tools, to_assistant_tools - -# region Test Helpers - - -def create_mock_assistant( - assistant_id: str = "asst_test123", - name: str = "TestAssistant", - model: str = "gpt-4", - instructions: str | None = "You are a helpful assistant.", - description: str | None = None, - tools: list[Any] | None = None, -) -> Assistant: - """Create a mock Assistant object.""" - mock = MagicMock(spec=Assistant) - mock.id = assistant_id - mock.name = name - mock.model = model - mock.instructions = instructions - mock.description = description - mock.tools = tools or [] - return mock - - -def create_function_tool(name: str, description: str = "A test function") -> MagicMock: - """Create a mock FunctionTool.""" - mock = MagicMock() - mock.type = "function" - mock.function = MagicMock() - mock.function.name = name - mock.function.description = description - return mock - - -def create_code_interpreter_tool() -> MagicMock: - """Create a mock CodeInterpreterTool.""" - mock = MagicMock() - mock.type = "code_interpreter" - return mock - - -def create_file_search_tool() -> MagicMock: - """Create a mock FileSearchTool.""" - mock = MagicMock() - mock.type = "file_search" - return mock - - -@pytest.fixture -def mock_async_openai() -> MagicMock: - """Mock AsyncOpenAI client.""" - mock_client = MagicMock() - - # Mock beta.assistants - mock_client.beta.assistants.create = AsyncMock( - return_value=create_mock_assistant(assistant_id="asst_created123", name="CreatedAssistant") - ) - mock_client.beta.assistants.retrieve = AsyncMock( - return_value=create_mock_assistant(assistant_id="asst_retrieved123", name="RetrievedAssistant") - ) - mock_client.beta.assistants.delete = AsyncMock() - - # Mock close method - mock_client.close = AsyncMock() - - return mock_client - - -# Test function for tool validation -def get_weather(location: Annotated[str, Field(description="The location")]) -> str: - """Get the weather for a location.""" - return f"Weather in {location}: sunny" - - -def search_database(query: Annotated[str, Field(description="Search query")]) -> str: - """Search the database.""" - return f"Results for: {query}" - - -# Pydantic model for structured output tests -class WeatherResponse(BaseModel): - location: str - temperature: float - conditions: str - - -# endregion - -# region Initialization Tests - - -class TestOpenAIAssistantProviderInit: - """Tests for provider initialization.""" - - def test_init_with_client(self, mock_async_openai: MagicMock) -> None: - """Test initialization with existing AsyncOpenAI client.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - assert provider._client is mock_async_openai # type: ignore[reportPrivateUsage] - assert provider._should_close_client is False # type: ignore[reportPrivateUsage] - - def test_init_without_client_creates_one(self, openai_unit_test_env: dict[str, str]) -> None: - """Test initialization creates client from settings.""" - provider = OpenAIAssistantProvider() - - assert provider._client is not None # type: ignore[reportPrivateUsage] - assert provider._should_close_client is True # type: ignore[reportPrivateUsage] - - def test_init_with_api_key(self) -> None: - """Test initialization with explicit API key.""" - provider = OpenAIAssistantProvider(api_key="sk-test-key") - - assert provider._client is not None # type: ignore[reportPrivateUsage] - assert provider._should_close_client is True # type: ignore[reportPrivateUsage] - - def test_init_fails_without_api_key(self) -> None: - """Test initialization fails without API key when settings return None.""" - from unittest.mock import patch - - # Mock load_settings to return a dict with None for api_key - with patch("agent_framework_openai._assistant_provider.load_settings") as mock_load: - mock_load.return_value = { - "api_key": None, - "org_id": None, - "base_url": None, - "model": None, - } - - with pytest.raises(ValueError) as exc_info: - OpenAIAssistantProvider() - - assert "API key is required" in str(exc_info.value) - - def test_init_with_org_id_and_base_url(self) -> None: - """Test initialization with organization ID and base URL.""" - provider = OpenAIAssistantProvider( - api_key="sk-test-key", - org_id="org-123", - base_url="https://custom.openai.com", - ) - - assert provider._client is not None # type: ignore[reportPrivateUsage] - - -class TestOpenAIAssistantProviderContextManager: - """Tests for async context manager.""" - - async def test_context_manager_enter_exit(self, mock_async_openai: MagicMock) -> None: - """Test async context manager entry and exit.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - async with provider as p: - assert p is provider - - async def test_context_manager_closes_owned_client(self, openai_unit_test_env: dict[str, str]) -> None: - """Test that owned client is closed on exit.""" - provider = OpenAIAssistantProvider() - client = provider._client # type: ignore[reportPrivateUsage] - assert client is not None - client.close = AsyncMock() - - async with provider: - pass - - client.close.assert_called_once() - - async def test_context_manager_does_not_close_external_client(self, mock_async_openai: MagicMock) -> None: - """Test that external client is not closed on exit.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - async with provider: - pass - - mock_async_openai.close.assert_not_called() - - -# endregion - -# region create_agent Tests - - -class TestOpenAIAssistantProviderCreateAgent: - """Tests for create_agent method.""" - - async def test_create_agent_basic(self, mock_async_openai: MagicMock) -> None: - """Test basic assistant creation.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - agent = await provider.create_agent( - name="TestAgent", - model="gpt-4", - instructions="You are helpful.", - ) - - assert isinstance(agent, Agent) - assert agent.name == "CreatedAssistant" - mock_async_openai.beta.assistants.create.assert_called_once() - - # Verify create was called with correct parameters - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - assert call_kwargs["name"] == "TestAgent" - assert call_kwargs["model"] == "gpt-4" - assert call_kwargs["instructions"] == "You are helpful." - - async def test_create_agent_with_description(self, mock_async_openai: MagicMock) -> None: - """Test assistant creation with description.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - await provider.create_agent( - name="TestAgent", - model="gpt-4", - description="A test agent description", - ) - - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - assert call_kwargs["description"] == "A test agent description" - - async def test_create_agent_with_function_tools(self, mock_async_openai: MagicMock) -> None: - """Test assistant creation with function tools.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - agent = await provider.create_agent( - name="WeatherAgent", - model="gpt-4", - tools=[get_weather], - ) - - assert isinstance(agent, Agent) - - # Verify tools were passed to create - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - assert "tools" in call_kwargs - assert len(call_kwargs["tools"]) == 1 - assert call_kwargs["tools"][0]["type"] == "function" - assert call_kwargs["tools"][0]["function"]["name"] == "get_weather" - - async def test_create_agent_with_tool(self, mock_async_openai: MagicMock) -> None: - """Test assistant creation with FunctionTool.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - @tool - def my_function(x: int) -> int: - """Double a number.""" - return x * 2 - - await provider.create_agent( - name="TestAgent", - model="gpt-4", - tools=[my_function], - ) - - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - assert call_kwargs["tools"][0]["function"]["name"] == "my_function" - - async def test_create_agent_with_code_interpreter(self, mock_async_openai: MagicMock) -> None: - """Test assistant creation with code interpreter.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - await provider.create_agent( - name="CodeAgent", - model="gpt-4", - tools=[OpenAIAssistantsClient.get_code_interpreter_tool()], - ) - - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - assert {"type": "code_interpreter"} in call_kwargs["tools"] - - async def test_create_agent_with_file_search(self, mock_async_openai: MagicMock) -> None: - """Test assistant creation with file search.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - await provider.create_agent( - name="SearchAgent", - model="gpt-4", - tools=[OpenAIAssistantsClient.get_file_search_tool()], - ) - - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - assert any(t["type"] == "file_search" for t in call_kwargs["tools"]) - - async def test_create_agent_with_file_search_max_results(self, mock_async_openai: MagicMock) -> None: - """Test assistant creation with file search and max_results.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - await provider.create_agent( - name="SearchAgent", - model="gpt-4", - tools=[OpenAIAssistantsClient.get_file_search_tool(max_num_results=10)], - ) - - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - file_search_tool = next(t for t in call_kwargs["tools"] if t["type"] == "file_search") - assert file_search_tool.get("file_search", {}).get("max_num_results") == 10 - - async def test_create_agent_with_mixed_tools(self, mock_async_openai: MagicMock) -> None: - """Test assistant creation with multiple tool types.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - await provider.create_agent( - name="MultiToolAgent", - model="gpt-4", - tools=[ - get_weather, - OpenAIAssistantsClient.get_code_interpreter_tool(), - OpenAIAssistantsClient.get_file_search_tool(), - ], - ) - - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - assert len(call_kwargs["tools"]) == 3 - - async def test_create_agent_with_metadata(self, mock_async_openai: MagicMock) -> None: - """Test assistant creation with metadata.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - await provider.create_agent( - name="TestAgent", - model="gpt-4", - metadata={"env": "test", "version": "1.0"}, - ) - - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - assert call_kwargs["metadata"] == {"env": "test", "version": "1.0"} - - async def test_create_agent_with_response_format_pydantic(self, mock_async_openai: MagicMock) -> None: - """Test assistant creation with Pydantic response format via default_options.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - await provider.create_agent( - name="StructuredAgent", - model="gpt-4", - default_options={"response_format": WeatherResponse}, - ) - - call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs - assert call_kwargs["response_format"]["type"] == "json_schema" - assert call_kwargs["response_format"]["json_schema"]["name"] == "WeatherResponse" - - async def test_create_agent_returns_chat_agent(self, mock_async_openai: MagicMock) -> None: - """Test that create_agent returns a Agent instance.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - agent = await provider.create_agent( - name="TestAgent", - model="gpt-4", - ) - - assert isinstance(agent, Agent) - - -# endregion - -# region get_agent Tests - - -class TestOpenAIAssistantProviderGetAgent: - """Tests for get_agent method.""" - - async def test_get_agent_basic(self, mock_async_openai: MagicMock) -> None: - """Test retrieving an existing assistant.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - agent = await provider.get_agent(assistant_id="asst_123") - - assert isinstance(agent, Agent) - mock_async_openai.beta.assistants.retrieve.assert_called_once_with("asst_123") - - async def test_get_agent_with_instructions_override(self, mock_async_openai: MagicMock) -> None: - """Test retrieving assistant with instruction override.""" - provider = OpenAIAssistantProvider(mock_async_openai) - - agent = await provider.get_agent( - assistant_id="asst_123", - instructions="Custom instructions", - ) - - # Agent should be created successfully with the custom instructions - assert isinstance(agent, Agent) - assert agent.id == "asst_retrieved123" - - async def test_get_agent_with_function_tools(self, mock_async_openai: MagicMock) -> None: - """Test retrieving assistant with function tools provided.""" - # Setup assistant with function tool - assistant = create_mock_assistant(tools=[create_function_tool("get_weather")]) - mock_async_openai.beta.assistants.retrieve = AsyncMock(return_value=assistant) - - provider = OpenAIAssistantProvider(mock_async_openai) - - agent = await provider.get_agent( - assistant_id="asst_123", - tools=[get_weather], - ) - - assert isinstance(agent, Agent) - - async def test_get_agent_validates_missing_function_tools(self, mock_async_openai: MagicMock) -> None: - """Test that missing function tools raise ValueError.""" - # Setup assistant with function tool - assistant = create_mock_assistant(tools=[create_function_tool("get_weather")]) - mock_async_openai.beta.assistants.retrieve = AsyncMock(return_value=assistant) - - provider = OpenAIAssistantProvider(mock_async_openai) - - with pytest.raises(ValueError) as exc_info: - await provider.get_agent(assistant_id="asst_123") - - assert "get_weather" in str(exc_info.value) - assert "no implementation was provided" in str(exc_info.value) - - async def test_get_agent_validates_multiple_missing_function_tools(self, mock_async_openai: MagicMock) -> None: - """Test validation with multiple missing function tools.""" - assistant = create_mock_assistant( - tools=[create_function_tool("get_weather"), create_function_tool("search_database")] - ) - mock_async_openai.beta.assistants.retrieve = AsyncMock(return_value=assistant) - - provider = OpenAIAssistantProvider(mock_async_openai) - - with pytest.raises(ValueError) as exc_info: - await provider.get_agent(assistant_id="asst_123") - - error_msg = str(exc_info.value) - assert "get_weather" in error_msg or "search_database" in error_msg - - async def test_get_agent_merges_hosted_tools(self, mock_async_openai: MagicMock) -> None: - """Test that hosted tools are automatically included.""" - assistant = create_mock_assistant(tools=[create_code_interpreter_tool(), create_file_search_tool()]) - mock_async_openai.beta.assistants.retrieve = AsyncMock(return_value=assistant) - - provider = OpenAIAssistantProvider(mock_async_openai) - - agent = await provider.get_agent(assistant_id="asst_123") - - # Hosted tools should be merged automatically - assert isinstance(agent, Agent) - - -# endregion - -# region as_agent Tests - - -class TestOpenAIAssistantProviderAsAgent: - """Tests for as_agent method.""" - - def test_as_agent_no_http_call(self, mock_async_openai: MagicMock) -> None: - """Test that as_agent doesn't make HTTP calls.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant = create_mock_assistant() - - agent = provider.as_agent(assistant) - - assert isinstance(agent, Agent) - # Verify no HTTP calls were made - mock_async_openai.beta.assistants.create.assert_not_called() - mock_async_openai.beta.assistants.retrieve.assert_not_called() - - def test_as_agent_wraps_assistant(self, mock_async_openai: MagicMock) -> None: - """Test wrapping an SDK Assistant object.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant = create_mock_assistant( - assistant_id="asst_wrap123", - name="WrappedAssistant", - instructions="Original instructions", - ) - - agent = provider.as_agent(assistant) - - assert agent.id == "asst_wrap123" - assert agent.name == "WrappedAssistant" - # Instructions are passed to ChatOptions, not exposed as attribute - assert isinstance(agent, Agent) - - def test_as_agent_with_instructions_override(self, mock_async_openai: MagicMock) -> None: - """Test as_agent with instruction override.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant = create_mock_assistant(instructions="Original") - - agent = provider.as_agent(assistant, instructions="Override") - - # Agent should be created successfully with override instructions - assert isinstance(agent, Agent) - - def test_as_agent_validates_function_tools(self, mock_async_openai: MagicMock) -> None: - """Test that missing function tools raise ValueError.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant = create_mock_assistant(tools=[create_function_tool("get_weather")]) - - with pytest.raises(ValueError) as exc_info: - provider.as_agent(assistant) - - assert "get_weather" in str(exc_info.value) - - def test_as_agent_with_function_tools_provided(self, mock_async_openai: MagicMock) -> None: - """Test as_agent with function tools provided.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant = create_mock_assistant(tools=[create_function_tool("get_weather")]) - - agent = provider.as_agent(assistant, tools=[get_weather]) - - assert isinstance(agent, Agent) - - def test_as_agent_merges_hosted_tools(self, mock_async_openai: MagicMock) -> None: - """Test that hosted tools are merged automatically.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant = create_mock_assistant(tools=[create_code_interpreter_tool()]) - - agent = provider.as_agent(assistant) - - assert isinstance(agent, Agent) - - def test_as_agent_hosted_tools_not_required(self, mock_async_openai: MagicMock) -> None: - """Test that hosted tools don't require user implementations.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant = create_mock_assistant(tools=[create_code_interpreter_tool(), create_file_search_tool()]) - - # Should not raise - hosted tools don't need implementations - agent = provider.as_agent(assistant) - - assert isinstance(agent, Agent) - - -# endregion - -# region Tool Conversion Tests - - -class TestToolConversion: - """Tests for tool conversion utilities (shared functions).""" - - def test_to_assistant_tools_tool(self) -> None: - """Test FunctionTool conversion to API format.""" - - @tool - def test_func(x: int) -> int: - """Test function.""" - return x - - # Normalize tools first, then convert - normalized = normalize_tools([test_func]) - api_tools = to_assistant_tools(normalized) - - assert len(api_tools) == 1 - assert api_tools[0]["type"] == "function" - assert api_tools[0]["function"]["name"] == "test_func" - - def test_to_assistant_tools_callable(self) -> None: - """Test raw callable conversion via normalize_tools.""" - # normalize_tools converts callables to FunctionTool - normalized = normalize_tools([get_weather]) - api_tools = to_assistant_tools(normalized) - - assert len(api_tools) == 1 - assert api_tools[0]["type"] == "function" - assert api_tools[0]["function"]["name"] == "get_weather" - - def test_to_assistant_tools_code_interpreter(self) -> None: - """Test code_interpreter tool dict conversion.""" - api_tools = to_assistant_tools([OpenAIAssistantsClient.get_code_interpreter_tool()]) - - assert len(api_tools) == 1 - assert api_tools[0] == {"type": "code_interpreter"} - - def test_to_assistant_tools_file_search(self) -> None: - """Test file_search tool dict conversion.""" - api_tools = to_assistant_tools([OpenAIAssistantsClient.get_file_search_tool()]) - - assert len(api_tools) == 1 - assert api_tools[0]["type"] == "file_search" - - def test_to_assistant_tools_file_search_with_max_results(self) -> None: - """Test file_search tool with max_results conversion.""" - api_tools = to_assistant_tools([OpenAIAssistantsClient.get_file_search_tool(max_num_results=5)]) - - assert api_tools[0]["file_search"]["max_num_results"] == 5 - - def test_to_assistant_tools_dict(self) -> None: - """Test raw dict tool passthrough.""" - raw_tool = {"type": "function", "function": {"name": "custom", "description": "Custom tool"}} - - api_tools = to_assistant_tools([raw_tool]) - - assert len(api_tools) == 1 - assert api_tools[0] == raw_tool - - def test_to_assistant_tools_empty(self) -> None: - """Test conversion with no tools.""" - api_tools = to_assistant_tools(None) - - assert api_tools == [] - - def test_from_assistant_tools_code_interpreter(self) -> None: - """Test converting code_interpreter tool from OpenAI format.""" - assistant_tools = [create_code_interpreter_tool()] - - tools = from_assistant_tools(assistant_tools) - - assert len(tools) == 1 - assert tools[0] == {"type": "code_interpreter"} - - def test_from_assistant_tools_file_search(self) -> None: - """Test converting file_search tool from OpenAI format.""" - assistant_tools = [create_file_search_tool()] - - tools = from_assistant_tools(assistant_tools) - - assert len(tools) == 1 - assert tools[0] == {"type": "file_search"} - - def test_from_assistant_tools_function_skipped(self) -> None: - """Test that function tools are skipped (no implementations).""" - assistant_tools = [create_function_tool("test_func")] - - tools = from_assistant_tools(assistant_tools) - - assert len(tools) == 0 # Function tools are skipped - - def test_from_assistant_tools_empty(self) -> None: - """Test conversion with no tools.""" - tools = from_assistant_tools(None) - - assert tools == [] - - -# endregion - -# region Tool Validation Tests - - -class TestToolValidation: - """Tests for tool validation.""" - - def test_validate_missing_function_tool_raises(self, mock_async_openai: MagicMock) -> None: - """Test that missing function tools raise ValueError.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools = [create_function_tool("my_function")] - - with pytest.raises(ValueError) as exc_info: - provider._validate_function_tools(assistant_tools, None) # type: ignore[reportPrivateUsage] - - assert "my_function" in str(exc_info.value) - - def test_validate_all_tools_provided_passes(self, mock_async_openai: MagicMock) -> None: - """Test that validation passes when all tools provided.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools = [create_function_tool("get_weather")] - - # Should not raise - provider._validate_function_tools(assistant_tools, [get_weather]) # type: ignore[reportPrivateUsage] - - def test_validate_hosted_tools_not_required(self, mock_async_openai: MagicMock) -> None: - """Test that hosted tools don't require implementations.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools = [create_code_interpreter_tool(), create_file_search_tool()] - - # Should not raise - provider._validate_function_tools(assistant_tools, None) # type: ignore[reportPrivateUsage] - - def test_validate_with_tool(self, mock_async_openai: MagicMock) -> None: - """Test validation with FunctionTool.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools = [create_function_tool("get_weather")] - - wrapped = tool(get_weather) - - # Should not raise - provider._validate_function_tools(assistant_tools, [wrapped]) # type: ignore[reportPrivateUsage] - - def test_validate_partial_tools_raises(self, mock_async_openai: MagicMock) -> None: - """Test that partial tool provision raises error.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools = [ - create_function_tool("get_weather"), - create_function_tool("search_database"), - ] - - with pytest.raises(ValueError) as exc_info: - provider._validate_function_tools(assistant_tools, [get_weather]) # type: ignore[reportPrivateUsage] - - assert "search_database" in str(exc_info.value) - - -# endregion - -# region Tool Merging Tests - - -class TestToolMerging: - """Tests for tool merging.""" - - def test_merge_code_interpreter(self, mock_async_openai: MagicMock) -> None: - """Test merging code interpreter tool.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools = [create_code_interpreter_tool()] - - merged = provider._merge_tools(assistant_tools, None) # type: ignore[reportPrivateUsage] - - assert len(merged) == 1 - assert merged[0] == {"type": "code_interpreter"} - - def test_merge_file_search(self, mock_async_openai: MagicMock) -> None: - """Test merging file search tool.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools = [create_file_search_tool()] - - merged = provider._merge_tools(assistant_tools, None) # type: ignore[reportPrivateUsage] - - assert len(merged) == 1 - assert merged[0] == {"type": "file_search"} - - def test_merge_with_user_tools(self, mock_async_openai: MagicMock) -> None: - """Test merging hosted and user tools.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools = [create_code_interpreter_tool()] - - merged = provider._merge_tools(assistant_tools, [get_weather]) # type: ignore[reportPrivateUsage] - - assert len(merged) == 2 - assert merged[0] == {"type": "code_interpreter"} - - def test_merge_multiple_hosted_tools(self, mock_async_openai: MagicMock) -> None: - """Test merging multiple hosted tools.""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools = [create_code_interpreter_tool(), create_file_search_tool()] - - merged = provider._merge_tools(assistant_tools, None) # type: ignore[reportPrivateUsage] - - assert len(merged) == 2 - - def test_merge_single_user_tool(self, mock_async_openai: MagicMock) -> None: - """Test merging with single user tool (not list).""" - provider = OpenAIAssistantProvider(mock_async_openai) - assistant_tools: list[Any] = [] - - merged = provider._merge_tools(assistant_tools, get_weather) # type: ignore[reportPrivateUsage] - - assert len(merged) == 1 - - -# endregion diff --git a/python/packages/openai/tests/openai/test_openai_assistants_client.py b/python/packages/openai/tests/openai/test_openai_assistants_client.py deleted file mode 100644 index ecb211001d..0000000000 --- a/python/packages/openai/tests/openai/test_openai_assistants_client.py +++ /dev/null @@ -1,1481 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import inspect -import json -import logging -from typing import Annotated, Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from agent_framework import ( - ChatResponseUpdate, - Content, - Message, - SupportsChatGetResponse, - SupportsCodeInterpreterTool, - SupportsFileSearchTool, - SupportsImageGenerationTool, - SupportsMCPTool, - SupportsWebSearchTool, - tool, -) -from openai.types.beta.threads import ( - FileCitationAnnotation, - FilePathAnnotation, - MessageDeltaEvent, - Run, - TextDeltaBlock, -) -from openai.types.beta.threads import ( - Message as ThreadMessage, -) -from openai.types.beta.threads.file_citation_delta_annotation import FileCitationDeltaAnnotation -from openai.types.beta.threads.file_path_delta_annotation import FilePathDeltaAnnotation -from openai.types.beta.threads.runs import RunStep -from pydantic import Field - -from agent_framework_openai import OpenAIAssistantsClient - -pytestmark = pytest.mark.filterwarnings("ignore:OpenAIAssistantsClient is deprecated\\..*:DeprecationWarning") - - -def create_test_openai_assistants_client( - mock_async_openai: MagicMock, - model: str | None = None, - assistant_id: str | None = None, - assistant_name: str | None = None, - thread_id: str | None = None, - should_delete_assistant: bool = False, -) -> OpenAIAssistantsClient: - """Helper function to create OpenAIAssistantsClient instances for testing.""" - client = OpenAIAssistantsClient( - model=model or "gpt-4", - assistant_id=assistant_id, - assistant_name=assistant_name, - thread_id=thread_id, - api_key="test-api-key", - org_id="test-org-id", - async_client=mock_async_openai, - ) - # Set the _should_delete_assistant flag directly if needed - if should_delete_assistant: - object.__setattr__(client, "_should_delete_assistant", True) - return client - - -async def create_vector_store(client: OpenAIAssistantsClient) -> 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 25C."), purpose="user_data" - ) - 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) - if result.last_error is not None: - raise Exception(f"Vector store file processing failed with status: {result.last_error.message}") - - return file.id, Content.from_hosted_vector_store(vector_store_id=vector_store.id) - - -async def delete_vector_store(client: OpenAIAssistantsClient, 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) - - -@pytest.fixture -def mock_async_openai() -> MagicMock: - """Mock AsyncOpenAI client.""" - mock_client = MagicMock() - - # Mock beta.assistants - mock_client.beta.assistants.create = AsyncMock(return_value=MagicMock(id="test-assistant-id")) - mock_client.beta.assistants.delete = AsyncMock() - - # Mock beta.threads - mock_client.beta.threads.create = AsyncMock(return_value=MagicMock(id="test-thread-id")) - mock_client.beta.threads.delete = AsyncMock() - - # Mock beta.threads.runs - mock_client.beta.threads.runs.create = AsyncMock(return_value=MagicMock(id="test-run-id")) - mock_client.beta.threads.runs.retrieve = AsyncMock() - mock_client.beta.threads.runs.submit_tool_outputs = AsyncMock() - mock_client.beta.threads.runs.cancel = AsyncMock() - - # Mock beta.threads.messages - mock_client.beta.threads.messages.create = AsyncMock() - mock_client.beta.threads.messages.list = AsyncMock(return_value=MagicMock(data=[])) - - return mock_client - - -def test_openai_assistants_client_is_deprecated(mock_async_openai: MagicMock) -> None: - with pytest.warns(DeprecationWarning, match="OpenAIAssistantsClient is deprecated. Use OpenAIChatClient instead."): - OpenAIAssistantsClient(model="gpt-4", api_key="test-api-key", async_client=mock_async_openai) - - -def test_openai_assistants_client_init_keeps_var_keyword() -> None: - signature = inspect.signature(OpenAIAssistantsClient.__init__) - - assert any(parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) - - -def test_openai_assistants_client_supports_code_interpreter_and_file_search() -> None: - assert isinstance(OpenAIAssistantsClient, SupportsCodeInterpreterTool) - assert not isinstance(OpenAIAssistantsClient, SupportsWebSearchTool) - assert not isinstance(OpenAIAssistantsClient, SupportsImageGenerationTool) - assert not isinstance(OpenAIAssistantsClient, SupportsMCPTool) - assert isinstance(OpenAIAssistantsClient, SupportsFileSearchTool) - - -def test_init_with_client(mock_async_openai: MagicMock) -> None: - """Test OpenAIAssistantsClient initialization with existing client.""" - client = create_test_openai_assistants_client( - mock_async_openai, model="gpt-4", assistant_id="existing-assistant-id", thread_id="test-thread-id" - ) - - assert client.client is mock_async_openai - assert client.model == "gpt-4" - assert client.assistant_id == "existing-assistant-id" - assert client.thread_id == "test-thread-id" - assert not client._should_delete_assistant # type: ignore - assert isinstance(client, SupportsChatGetResponse) - - -def test_init_auto_create_client( - openai_unit_test_env: dict[str, str], - mock_async_openai: MagicMock, -) -> None: - """Test OpenAIAssistantsClient initialization with auto-created client.""" - client = OpenAIAssistantsClient( - model=openai_unit_test_env["OPENAI_MODEL"], - assistant_name="TestAssistant", - api_key=openai_unit_test_env["OPENAI_API_KEY"], - org_id=openai_unit_test_env["OPENAI_ORG_ID"], - async_client=mock_async_openai, - ) - - assert client.client is mock_async_openai - assert client.model == openai_unit_test_env["OPENAI_MODEL"] - assert client.assistant_id is None - assert client.assistant_name == "TestAssistant" - assert not client._should_delete_assistant # type: ignore - - -def test_init_validation_fail() -> None: - """Test OpenAIAssistantsClient initialization with validation failure.""" - with pytest.raises(ValueError): - # Force failure by providing invalid model ID type - OpenAIAssistantsClient(model=123, api_key="valid-key") # type: ignore - - -@pytest.mark.parametrize("exclude_list", [["OPENAI_MODEL"]], indirect=True) -def test_init_missing_model_id(openai_unit_test_env: dict[str, str]) -> None: - """Test OpenAIAssistantsClient initialization with missing model ID.""" - with pytest.raises(ValueError): - OpenAIAssistantsClient(api_key=openai_unit_test_env.get("OPENAI_API_KEY", "test-key")) - - -@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True) -def test_init_missing_api_key(openai_unit_test_env: dict[str, str]) -> None: - """Test OpenAIAssistantsClient initialization with missing API key.""" - with pytest.raises(ValueError): - OpenAIAssistantsClient(model="gpt-4") - - -def test_init_with_default_headers(openai_unit_test_env: dict[str, str]) -> None: - """Test OpenAIAssistantsClient initialization with default headers.""" - default_headers = {"X-Unit-Test": "test-guid"} - - client = OpenAIAssistantsClient( - model="gpt-4", - api_key=openai_unit_test_env["OPENAI_API_KEY"], - default_headers=default_headers, - ) - - assert client.model == "gpt-4" - assert isinstance(client, SupportsChatGetResponse) - - # Assert that the default header we added is present in the client's default headers - for key, value in default_headers.items(): - assert key in client.client.default_headers - assert client.client.default_headers[key] == value - - -async def test_get_assistant_id_or_create_existing_assistant( - mock_async_openai: MagicMock, -) -> None: - """Test _get_assistant_id_or_create when assistant_id is already provided.""" - client = create_test_openai_assistants_client(mock_async_openai, assistant_id="existing-assistant-id") - - assistant_id = await client._get_assistant_id_or_create() # type: ignore - - assert assistant_id == "existing-assistant-id" - assert not client._should_delete_assistant # type: ignore - mock_async_openai.beta.assistants.create.assert_not_called() - - -async def test_get_assistant_id_or_create_create_new( - mock_async_openai: MagicMock, -) -> None: - """Test _get_assistant_id_or_create when creating a new assistant.""" - client = create_test_openai_assistants_client(mock_async_openai, model="gpt-4", assistant_name="TestAssistant") - - assistant_id = await client._get_assistant_id_or_create() # type: ignore - - assert assistant_id == "test-assistant-id" - assert client._should_delete_assistant # type: ignore - mock_async_openai.beta.assistants.create.assert_called_once() - - -async def test_aclose_should_not_delete( - mock_async_openai: MagicMock, -) -> None: - """Test close when assistant should not be deleted.""" - client = create_test_openai_assistants_client( - mock_async_openai, assistant_id="assistant-to-keep", should_delete_assistant=False - ) - - await client.close() # type: ignore - - # Verify assistant deletion was not called - mock_async_openai.beta.assistants.delete.assert_not_called() - assert not client._should_delete_assistant # type: ignore - - -async def test_aclose_should_delete(mock_async_openai: MagicMock) -> None: - """Test close method calls cleanup.""" - client = create_test_openai_assistants_client( - mock_async_openai, assistant_id="assistant-to-delete", should_delete_assistant=True - ) - - await client.close() - - # Verify assistant deletion was called - mock_async_openai.beta.assistants.delete.assert_called_once_with("assistant-to-delete") - assert not client._should_delete_assistant # type: ignore - - -async def test_async_context_manager(mock_async_openai: MagicMock) -> None: - """Test async context manager functionality.""" - client = create_test_openai_assistants_client( - mock_async_openai, assistant_id="assistant-to-delete", should_delete_assistant=True - ) - - # Test context manager - async with client: - pass # Just test that we can enter and exit - - # Verify cleanup was called on exit - mock_async_openai.beta.assistants.delete.assert_called_once_with("assistant-to-delete") - - -def test_serialize(openai_unit_test_env: dict[str, str]) -> None: - """Test serialization of OpenAIAssistantsClient.""" - default_headers = {"X-Unit-Test": "test-guid"} - - # Test basic initialization and to_dict - client = OpenAIAssistantsClient( - model="gpt-4", - assistant_id="test-assistant-id", - assistant_name="TestAssistant", - thread_id="test-thread-id", - api_key=openai_unit_test_env["OPENAI_API_KEY"], - org_id=openai_unit_test_env["OPENAI_ORG_ID"], - default_headers=default_headers, - ) - - dumped_settings = client.to_dict() - - assert dumped_settings["model"] == "gpt-4" - assert dumped_settings["assistant_id"] == "test-assistant-id" - assert dumped_settings["assistant_name"] == "TestAssistant" - assert dumped_settings["thread_id"] == "test-thread-id" - assert dumped_settings["org_id"] == openai_unit_test_env["OPENAI_ORG_ID"] - - # Assert that the default header we added is present in the dumped_settings default headers - for key, value in default_headers.items(): - assert key in dumped_settings["default_headers"] - assert dumped_settings["default_headers"][key] == value - # Assert that the 'User-Agent' header is not present in the dumped_settings default headers - assert "User-Agent" not in dumped_settings["default_headers"] - - -async def test_get_active_thread_run_none_thread_id(mock_async_openai: MagicMock) -> None: - """Test _get_active_thread_run with None thread_id returns None.""" - client = create_test_openai_assistants_client(mock_async_openai) - - result = await client._get_active_thread_run(None) # type: ignore - - assert result is None - # Should not call the API when thread_id is None - mock_async_openai.beta.threads.runs.list.assert_not_called() - - -async def test_get_active_thread_run_with_active_run(mock_async_openai: MagicMock) -> None: - """Test _get_active_thread_run finds an active run.""" - - client = create_test_openai_assistants_client(mock_async_openai) - - # Mock an active run (status not in completed states) - mock_run = MagicMock() - mock_run.status = "in_progress" # Active status - - # Mock the async iterator for runs.list - async def mock_runs_list(*args: Any, **kwargs: Any) -> Any: - yield mock_run - - mock_async_openai.beta.threads.runs.list.return_value.__aiter__ = mock_runs_list - - result = await client._get_active_thread_run("thread-123") # type: ignore - - assert result == mock_run - mock_async_openai.beta.threads.runs.list.assert_called_once_with(thread_id="thread-123", limit=1, order="desc") - - -async def test_prepare_thread_create_new(mock_async_openai: MagicMock) -> None: - """Test _prepare_thread creates new thread when thread_id is None.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Mock thread creation - mock_thread = MagicMock() - mock_thread.id = "new-thread-123" - mock_async_openai.beta.threads.create.return_value = mock_thread - - # Prepare run options with additional messages - run_options: dict[str, Any] = { - "additional_messages": [{"role": "user", "content": "Hello"}], - "tool_resources": {"code_interpreter": {}}, - "metadata": {"test": "true"}, - } - - result = await client._prepare_thread(None, None, run_options) # type: ignore - - assert result == "new-thread-123" - assert run_options["additional_messages"] == [] # Should be cleared - mock_async_openai.beta.threads.create.assert_called_once_with( - messages=[{"role": "user", "content": "Hello"}], - tool_resources={"code_interpreter": {}}, - metadata={"test": "true"}, - ) - - -async def test_prepare_thread_cancel_existing_run(mock_async_openai: MagicMock) -> None: - """Test _prepare_thread cancels existing run when provided.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Mock an existing thread run - mock_thread_run = MagicMock() - mock_thread_run.id = "run-456" - - run_options: dict[str, Any] = {"additional_messages": []} - - result = await client._prepare_thread("thread-123", mock_thread_run, run_options) # type: ignore - - assert result == "thread-123" - mock_async_openai.beta.threads.runs.cancel.assert_called_once_with(run_id="run-456", thread_id="thread-123") - - -async def test_prepare_thread_existing_no_run(mock_async_openai: MagicMock) -> None: - """Test _prepare_thread with existing thread_id but no active run.""" - client = create_test_openai_assistants_client(mock_async_openai) - - run_options: dict[str, list[dict[str, str]]] = {"additional_messages": []} - - result = await client._prepare_thread("thread-123", None, run_options) # type: ignore - - assert result == "thread-123" - # Should not call cancel since no thread_run provided - mock_async_openai.beta.threads.runs.cancel.assert_not_called() - - -async def test_process_stream_events_thread_run_created(mock_async_openai: MagicMock) -> None: - """Test _process_stream_events with thread.run.created event.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a mock stream response for thread.run.created - mock_response = MagicMock() - mock_response.event = "thread.run.created" - mock_response.data = MagicMock() - - # Create a proper async iterator - async def async_iterator() -> Any: - yield mock_response - - # Create a mock stream that yields the response - mock_stream = MagicMock() - mock_stream.__aenter__ = AsyncMock(return_value=async_iterator()) - mock_stream.__aexit__ = AsyncMock(return_value=None) - - thread_id = "thread-123" - updates: list[ChatResponseUpdate] = [] - async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore - updates.append(update) - - # Should yield one ChatResponseUpdate for thread.run.created - assert len(updates) == 1 - update = updates[0] - assert isinstance(update, ChatResponseUpdate) - assert update.conversation_id == thread_id - assert update.role == "assistant" - assert update.contents == [] - assert update.raw_representation == mock_response.data - - -async def test_process_stream_events_message_delta_text(mock_async_openai: MagicMock) -> None: - """Test _process_stream_events with thread.message.delta event containing text.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a mock TextDeltaBlock with proper spec - mock_delta_block = MagicMock(spec=TextDeltaBlock) - mock_delta_block.text = MagicMock() - mock_delta_block.text.value = "Hello from assistant" - - mock_delta = MagicMock() - mock_delta.role = "assistant" - mock_delta.content = [mock_delta_block] - - mock_message_delta = MagicMock(spec=MessageDeltaEvent) - mock_message_delta.delta = mock_delta - - mock_response = MagicMock() - mock_response.event = "thread.message.delta" - mock_response.data = mock_message_delta - - # Create a proper async iterator - async def async_iterator() -> Any: - yield mock_response - - # Create a mock stream - mock_stream = MagicMock() - mock_stream.__aenter__ = AsyncMock(return_value=async_iterator()) - mock_stream.__aexit__ = AsyncMock(return_value=None) - - thread_id = "thread-456" - updates: list[ChatResponseUpdate] = [] - async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore - updates.append(update) - - # Should yield one text update - assert len(updates) == 1 - update = updates[0] - assert isinstance(update, ChatResponseUpdate) - assert update.conversation_id == thread_id - assert update.role == "assistant" - assert update.text == "Hello from assistant" - assert update.raw_representation == mock_message_delta - - -async def test_process_stream_events_message_delta_text_with_file_citation_annotations( - mock_async_openai: MagicMock, -) -> None: - """Test _process_stream_events maps file citation annotations from TextDeltaBlock.""" - client = create_test_openai_assistants_client(mock_async_openai) - - mock_annotation = FileCitationDeltaAnnotation( - index=0, - type="file_citation", - file_citation={"file_id": "file-abc123"}, - start_index=10, - end_index=24, - text="【4:0†source】", - ) - - mock_delta_block = MagicMock(spec=TextDeltaBlock) - mock_delta_block.text = MagicMock() - mock_delta_block.text.value = "Some text 【4:0†source】 more text" - mock_delta_block.text.annotations = [mock_annotation] - - mock_delta = MagicMock() - mock_delta.role = "assistant" - mock_delta.content = [mock_delta_block] - - mock_message_delta = MagicMock(spec=MessageDeltaEvent) - mock_message_delta.delta = mock_delta - - mock_response = MagicMock() - mock_response.event = "thread.message.delta" - mock_response.data = mock_message_delta - - async def async_iterator() -> Any: - yield mock_response - - mock_stream = MagicMock() - mock_stream.__aenter__ = AsyncMock(return_value=async_iterator()) - mock_stream.__aexit__ = AsyncMock(return_value=None) - - thread_id = "thread-789" - updates: list[ChatResponseUpdate] = [] - async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore - updates.append(update) - - assert len(updates) == 1 - update = updates[0] - assert update.text == "Some text 【4:0†source】 more text" - assert update.contents is not None - content = update.contents[0] - assert content.annotations is not None - assert len(content.annotations) == 1 - ann = content.annotations[0] - assert ann["type"] == "citation" - assert ann["file_id"] == "file-abc123" - assert ann["annotated_regions"] is not None - assert ann["annotated_regions"][0]["start_index"] == 10 - assert ann["annotated_regions"][0]["end_index"] == 24 - assert ann["additional_properties"]["text"] == "【4:0†source】" - - -async def test_process_stream_events_message_delta_text_with_file_path_annotations( - mock_async_openai: MagicMock, -) -> None: - """Test _process_stream_events maps file path annotations from TextDeltaBlock.""" - client = create_test_openai_assistants_client(mock_async_openai) - - mock_annotation = FilePathDeltaAnnotation( - index=0, - type="file_path", - file_path={"file_id": "file-xyz789"}, - start_index=5, - end_index=20, - text="sandbox:/path/to/file", - ) - - mock_delta_block = MagicMock(spec=TextDeltaBlock) - mock_delta_block.text = MagicMock() - mock_delta_block.text.value = "Here sandbox:/path/to/file is the file" - mock_delta_block.text.annotations = [mock_annotation] - - mock_delta = MagicMock() - mock_delta.role = "assistant" - mock_delta.content = [mock_delta_block] - - mock_message_delta = MagicMock(spec=MessageDeltaEvent) - mock_message_delta.delta = mock_delta - - mock_response = MagicMock() - mock_response.event = "thread.message.delta" - mock_response.data = mock_message_delta - - async def async_iterator() -> Any: - yield mock_response - - mock_stream = MagicMock() - mock_stream.__aenter__ = AsyncMock(return_value=async_iterator()) - mock_stream.__aexit__ = AsyncMock(return_value=None) - - thread_id = "thread-annotation" - updates: list[ChatResponseUpdate] = [] - async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore - updates.append(update) - - assert len(updates) == 1 - content = updates[0].contents[0] - assert content.annotations is not None - assert len(content.annotations) == 1 - ann = content.annotations[0] - assert ann["type"] == "citation" - assert ann["file_id"] == "file-xyz789" - assert ann["annotated_regions"] is not None - assert ann["annotated_regions"][0]["start_index"] == 5 - assert ann["annotated_regions"][0]["end_index"] == 20 - - -async def test_process_stream_events_requires_action(mock_async_openai: MagicMock) -> None: - """Test _process_stream_events with thread.run.requires_action event.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Mock the _parse_function_calls_from_assistants method to return test content - test_function_content = Content.from_function_call(call_id="call-123", name="test_func", arguments={"arg": "value"}) - client._parse_function_calls_from_assistants = MagicMock(return_value=[test_function_content]) # type: ignore - - # Create a mock Run object - mock_run = MagicMock(spec=Run) - - mock_response = MagicMock() - mock_response.event = "thread.run.requires_action" - mock_response.data = mock_run - - # Create a proper async iterator - async def async_iterator() -> Any: - yield mock_response - - # Create a mock stream - mock_stream = MagicMock() - mock_stream.__aenter__ = AsyncMock(return_value=async_iterator()) - mock_stream.__aexit__ = AsyncMock(return_value=None) - - thread_id = "thread-789" - updates: list[ChatResponseUpdate] = [] - async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore - updates.append(update) - - # Should yield one function call update - assert len(updates) == 1 - update = updates[0] - assert isinstance(update, ChatResponseUpdate) - assert update.conversation_id == thread_id - assert update.role == "assistant" - assert len(update.contents) == 1 - assert update.contents[0] == test_function_content - assert update.raw_representation == mock_run - - # Verify _parse_function_calls_from_assistants was called correctly - client._parse_function_calls_from_assistants.assert_called_once_with(mock_run, None) # type: ignore - - -async def test_process_stream_events_run_step_created(mock_async_openai: MagicMock) -> None: - """Test _process_stream_events with thread.run.step.created event.""" - - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a mock RunStep object - mock_run_step = MagicMock(spec=RunStep) - mock_run_step.run_id = "run-456" - - mock_response = MagicMock() - mock_response.event = "thread.run.step.created" - mock_response.data = mock_run_step - - # Create a proper async iterator - async def async_iterator() -> Any: - yield mock_response - - # Create a mock stream - mock_stream = MagicMock() - mock_stream.__aenter__ = AsyncMock(return_value=async_iterator()) - mock_stream.__aexit__ = AsyncMock(return_value=None) - - thread_id = "thread-789" - updates: list[ChatResponseUpdate] = [] - async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore - updates.append(update) - - # The run step creation itself doesn't yield an update, - # but it should set the response_id for subsequent events - assert len(updates) == 0 - - -async def test_process_stream_events_run_completed_with_usage( - mock_async_openai: MagicMock, -) -> None: - """Test _process_stream_events with thread.run.completed event containing usage.""" - - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a mock Run object with usage information - mock_usage = MagicMock() - mock_usage.prompt_tokens = 100 - mock_usage.completion_tokens = 50 - mock_usage.total_tokens = 150 - - mock_run = MagicMock(spec=Run) - mock_run.usage = mock_usage - - mock_response = MagicMock() - mock_response.event = "thread.run.completed" - mock_response.data = mock_run - - # Create a proper async iterator - async def async_iterator() -> Any: - yield mock_response - - # Create a mock stream - mock_stream = MagicMock() - mock_stream.__aenter__ = AsyncMock(return_value=async_iterator()) - mock_stream.__aexit__ = AsyncMock(return_value=None) - - thread_id = "thread-999" - updates: list[ChatResponseUpdate] = [] - async for update in client._process_stream_events(mock_stream, thread_id): # type: ignore - updates.append(update) - - # Should yield one usage update - assert len(updates) == 1 - update = updates[0] - assert isinstance(update, ChatResponseUpdate) - assert update.conversation_id == thread_id - assert update.role == "assistant" - assert len(update.contents) == 1 - - # Check the usage content - usage_content = update.contents[0] - assert usage_content.type == "usage" - assert usage_content.usage_details["input_token_count"] == 100 - assert usage_content.usage_details["output_token_count"] == 50 - assert usage_content.usage_details["total_token_count"] == 150 - assert update.raw_representation == mock_run - - -def test_parse_function_calls_from_assistants_basic(mock_async_openai: MagicMock) -> None: - """Test _parse_function_calls_from_assistants with a simple function call.""" - - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a mock Run event that requires action - mock_run = MagicMock() - mock_run.required_action = MagicMock() - mock_run.required_action.submit_tool_outputs = MagicMock() - - # Create a mock tool call - mock_tool_call = MagicMock() - mock_tool_call.id = "call_abc123" - mock_tool_call.function.name = "get_weather" - mock_tool_call.function.arguments = '{"location": "Seattle"}' - - mock_run.required_action.submit_tool_outputs.tool_calls = [mock_tool_call] - - # Call the method - response_id = "response_456" - contents = client._parse_function_calls_from_assistants(mock_run, response_id) # type: ignore - - # Test that one function call content was created - assert len(contents) == 1 - assert contents[0].type == "function_call" - assert contents[0].name == "get_weather" - assert contents[0].arguments == {"location": "Seattle"} - - -def test_parse_run_step_with_code_interpreter_tool_call(mock_async_openai: MagicMock) -> None: - """Test _parse_run_step_tool_call with code_interpreter type creates CodeInterpreterToolCallContent.""" - client = create_test_openai_assistants_client( - mock_async_openai, - model="test-model", - assistant_id="test-assistant", - ) - - # Mock a run with required_action containing code_interpreter tool call - mock_run = MagicMock() - mock_run.id = "run_123" - mock_run.status = "requires_action" - - mock_tool_call = MagicMock() - mock_tool_call.id = "call_code_123" - mock_tool_call.type = "code_interpreter" - mock_code_interpreter = MagicMock() - mock_code_interpreter.input = "print('Hello, World!')" - mock_tool_call.code_interpreter = mock_code_interpreter - - mock_required_action = MagicMock() - mock_required_action.submit_tool_outputs = MagicMock() - mock_required_action.submit_tool_outputs.tool_calls = [mock_tool_call] - mock_run.required_action = mock_required_action - - # Parse the run step - contents = client._parse_function_calls_from_assistants(mock_run, "response_123") - - # Should have CodeInterpreterToolCallContent - assert len(contents) == 1 - assert contents[0].type == "code_interpreter_tool_call" - assert contents[0].call_id == '["response_123", "call_code_123"]' - assert contents[0].inputs is not None - assert len(contents[0].inputs) == 1 - assert contents[0].inputs[0].type == "text" - assert contents[0].inputs[0].text == "print('Hello, World!')" - - -def test_parse_run_step_with_mcp_tool_call(mock_async_openai: MagicMock) -> None: - """Test _parse_run_step_tool_call with mcp type creates MCPServerToolCallContent.""" - client = create_test_openai_assistants_client( - mock_async_openai, - model="test-model", - assistant_id="test-assistant", - ) - - # Mock a run with required_action containing mcp tool call - mock_run = MagicMock() - mock_run.id = "run_456" - mock_run.status = "requires_action" - - mock_tool_call = MagicMock() - mock_tool_call.id = "call_mcp_456" - mock_tool_call.type = "mcp" - mock_tool_call.name = "fetch_data" - mock_tool_call.server_label = "DataServer" - mock_tool_call.args = {"key": "value"} - - mock_required_action = MagicMock() - mock_required_action.submit_tool_outputs = MagicMock() - mock_required_action.submit_tool_outputs.tool_calls = [mock_tool_call] - mock_run.required_action = mock_required_action - - # Parse the run step - contents = client._parse_function_calls_from_assistants(mock_run, "response_456") - - # Should have MCPServerToolCallContent - assert len(contents) == 1 - assert contents[0].type == "mcp_server_tool_call" - assert contents[0].call_id == '["response_456", "call_mcp_456"]' - assert contents[0].tool_name == "fetch_data" - assert contents[0].server_name == "DataServer" - assert contents[0].arguments == {"key": "value"} - - -def test_prepare_options_basic(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with basic chat options.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Create basic chat options as a dict - options = { - "max_tokens": 100, - "model": "gpt-4", - "temperature": 0.7, - "top_p": 0.9, - } - - messages = [Message(role="user", text="Hello")] - - # Call the method - run_options, tool_results = client._prepare_options(messages, options) # type: ignore - - # Check basic options were set - assert run_options["max_completion_tokens"] == 100 - assert run_options["model"] == "gpt-4" - assert run_options["temperature"] == 0.7 - assert run_options["top_p"] == 0.9 - assert "tool_choice" not in run_options - assert tool_results is None - - -def test_prepare_options_with_tool_tool(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with a FunctionTool.""" - - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a simple function for testing and decorate it - @tool(approval_mode="never_require") - def test_function(query: str) -> str: - """A test function.""" - return f"Result for {query}" - - options = { - "tools": [test_function], - "tool_choice": "auto", - } - - messages = [Message(role="user", text="Hello")] - - # Call the method - run_options, tool_results = client._prepare_options(messages, options) # type: ignore - - # Check tools were set correctly - assert "tools" in run_options - assert len(run_options["tools"]) == 1 - assert run_options["tools"][0]["type"] == "function" - assert "function" in run_options["tools"][0] - assert run_options["tool_choice"] == "auto" - - -def test_prepare_options_with_tools_without_tool_choice(mock_async_openai: MagicMock) -> None: - """Test _prepare_options keeps tool_choice unset when not provided.""" - - client = create_test_openai_assistants_client(mock_async_openai) - - @tool(approval_mode="never_require") - def test_function(query: str) -> str: - """A test function.""" - return f"Result for {query}" - - options = { - "tools": [test_function], - } - - messages = [Message(role="user", text="Hello")] - run_options, _ = client._prepare_options(messages, options) # type: ignore - - assert "tools" in run_options - assert "tool_choice" not in run_options - - -def test_prepare_options_with_single_tool_tool(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with a single FunctionTool (non-sequence).""" - client = create_test_openai_assistants_client(mock_async_openai) - - @tool(approval_mode="never_require") - def test_function(query: str) -> str: - """A test function.""" - return f"Result for {query}" - - options = { - "tools": test_function, - "tool_choice": "auto", - } - - messages = [Message(role="user", text="Hello")] - run_options, tool_results = client._prepare_options(messages, options) # type: ignore - - assert "tools" in run_options - assert len(run_options["tools"]) == 1 - assert run_options["tools"][0]["type"] == "function" - assert "function" in run_options["tools"][0] - assert run_options["tool_choice"] == "auto" - assert tool_results is None - - -def test_prepare_options_with_code_interpreter(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with code interpreter tool.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a code interpreter tool dict - code_tool = OpenAIAssistantsClient.get_code_interpreter_tool() - - options = { - "tools": [code_tool], - "tool_choice": "auto", - } - - messages = [Message(role="user", text="Calculate something")] - - # Call the method - run_options, tool_results = client._prepare_options(messages, options) # type: ignore - - # Check code interpreter tool was set correctly - assert "tools" in run_options - assert len(run_options["tools"]) == 1 - assert run_options["tools"][0] == {"type": "code_interpreter"} - assert run_options["tool_choice"] == "auto" - - -def test_prepare_options_tool_choice_none(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with tool_choice set to 'none' and no tools.""" - client = create_test_openai_assistants_client(mock_async_openai) - - options = { - "tool_choice": "none", - } - - messages = [Message(role="user", text="Hello")] - - # Call the method - run_options, tool_results = client._prepare_options(messages, options) # type: ignore - - # Should set tool_choice to none - no tools because none were provided - assert run_options["tool_choice"] == "none" - assert "tools" not in run_options - - -def test_prepare_options_tool_choice_none_with_tools(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with tool_choice='none' but tools provided. - - When tool_choice='none', the model won't call tools, but tools should still - be sent to the API so they're available for future turns in the conversation. - """ - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a function tool - @tool(approval_mode="never_require") - def test_func(arg: str) -> str: - return arg - - options = { - "tool_choice": "none", - "tools": [test_func], - } - - messages = [Message(role="user", text="Hello")] - - # Call the method - run_options, tool_results = client._prepare_options(messages, options) # type: ignore - - # Should set tool_choice to none BUT still include tools - assert run_options["tool_choice"] == "none" - assert "tools" in run_options - assert len(run_options["tools"]) == 1 - - -def test_prepare_options_required_function(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with required function tool choice.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a required function tool choice as dict - tool_choice = {"mode": "required", "required_function_name": "specific_function"} - - options = { - "tool_choice": tool_choice, - } - - messages = [Message(role="user", text="Hello")] - - # Call the method - run_options, tool_results = client._prepare_options(messages, options) # type: ignore - - # Check required function tool choice was set correctly - expected_tool_choice = { - "type": "function", - "function": {"name": "specific_function"}, - } - assert run_options["tool_choice"] == expected_tool_choice - - -def test_prepare_options_with_file_search_tool(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with file_search tool.""" - - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a file_search tool with max_results - file_search_tool = OpenAIAssistantsClient.get_file_search_tool(max_num_results=10) - - options = { - "tools": [file_search_tool], - "tool_choice": "auto", - } - - messages = [Message(role="user", text="Search for information")] - - # Call the method - run_options, tool_results = client._prepare_options(messages, options) # type: ignore - - # Check file search tool was set correctly - assert "tools" in run_options - assert len(run_options["tools"]) == 1 - expected_tool = {"type": "file_search", "file_search": {"max_num_results": 10}} - assert run_options["tools"][0] == expected_tool - assert run_options["tool_choice"] == "auto" - - -def test_prepare_options_with_mapping_tool(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with MutableMapping tool.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Create a tool as a MutableMapping (dict) - mapping_tool = {"type": "custom_tool", "parameters": {"setting": "value"}} - - options = { - "tools": [mapping_tool], # type: ignore - "tool_choice": "auto", - } - - messages = [Message(role="user", text="Use custom tool")] - - # Call the method - run_options, tool_results = client._prepare_options(messages, options) # type: ignore - - # Check mapping tool was set correctly - assert "tools" in run_options - assert len(run_options["tools"]) == 1 - assert run_options["tools"][0] == mapping_tool - assert run_options["tool_choice"] == "auto" - - -def test_prepare_options_with_pydantic_response_format(mock_async_openai: MagicMock) -> None: - """Test _prepare_options sets strict=True for Pydantic response_format.""" - from pydantic import BaseModel, ConfigDict - - class TestResponse(BaseModel): - name: str - value: int - model_config = ConfigDict(extra="forbid") - - client = create_test_openai_assistants_client(mock_async_openai) - messages = [Message(role="user", text="Test")] - options = {"response_format": TestResponse} - - run_options, _ = client._prepare_options(messages, options) # type: ignore - - assert "response_format" in run_options - assert run_options["response_format"]["type"] == "json_schema" - assert run_options["response_format"]["json_schema"]["name"] == "TestResponse" - assert run_options["response_format"]["json_schema"]["strict"] is True - - -def test_prepare_options_with_system_message(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with system message converted to instructions.""" - client = create_test_openai_assistants_client(mock_async_openai) - - messages = [ - Message(role="system", text="You are a helpful assistant."), - Message(role="user", text="Hello"), - ] - - # Call the method - run_options, tool_results = client._prepare_options(messages, {}) # type: ignore - - # Check that additional_messages only contains the user message - # System message should be converted to instructions (though this is handled internally) - assert "additional_messages" in run_options - assert len(run_options["additional_messages"]) == 1 - assert run_options["additional_messages"][0]["role"] == "user" - - -def test_prepare_options_with_image_content(mock_async_openai: MagicMock) -> None: - """Test _prepare_options with image content.""" - - client = create_test_openai_assistants_client(mock_async_openai) - - # Create message with image content - image_content = Content.from_uri(uri="https://example.com/image.jpg", media_type="image/jpeg") - messages = [Message(role="user", contents=[image_content])] - - # Call the method - run_options, tool_results = client._prepare_options(messages, {}) # type: ignore - - # Check that image content was processed - assert "additional_messages" in run_options - assert len(run_options["additional_messages"]) == 1 - message = run_options["additional_messages"][0] - assert message["role"] == "user" - assert len(message["content"]) == 1 - assert message["content"][0]["type"] == "image_url" - assert message["content"][0]["image_url"]["url"] == "https://example.com/image.jpg" - - -def test_prepare_tool_outputs_for_assistants_empty(mock_async_openai: MagicMock) -> None: - """Test _prepare_tool_outputs_for_assistants with empty list.""" - client = create_test_openai_assistants_client(mock_async_openai) - - run_id, tool_outputs = client._prepare_tool_outputs_for_assistants([]) # type: ignore - - assert run_id is None - assert tool_outputs is None - - -def test_prepare_tool_outputs_for_assistants_valid(mock_async_openai: MagicMock) -> None: - """Test _prepare_tool_outputs_for_assistants with valid function results.""" - client = create_test_openai_assistants_client(mock_async_openai) - - call_id = json.dumps(["run-123", "call-456"]) - function_result = Content.from_function_result(call_id=call_id, result="Function executed successfully") - - run_id, tool_outputs = client._prepare_tool_outputs_for_assistants([function_result]) # type: ignore - - assert run_id == "run-123" - assert tool_outputs is not None - assert len(tool_outputs) == 1 - assert tool_outputs[0].get("tool_call_id") == "call-456" - assert tool_outputs[0].get("output") == "Function executed successfully" - - -def test_prepare_tool_outputs_for_assistants_mismatched_run_ids( - mock_async_openai: MagicMock, -) -> None: - """Test _prepare_tool_outputs_for_assistants with mismatched run IDs.""" - client = create_test_openai_assistants_client(mock_async_openai) - - # Create function results with different run IDs - call_id1 = json.dumps(["run-123", "call-456"]) - call_id2 = json.dumps(["run-789", "call-xyz"]) # Different run ID - function_result1 = Content.from_function_result(call_id=call_id1, result="Result 1") - function_result2 = Content.from_function_result(call_id=call_id2, result="Result 2") - - run_id, tool_outputs = client._prepare_tool_outputs_for_assistants([function_result1, function_result2]) # type: ignore - - # Should only process the first one since run IDs don't match - assert run_id == "run-123" - assert tool_outputs is not None - assert len(tool_outputs) == 1 - assert tool_outputs[0].get("tool_call_id") == "call-456" - - -def test_update_agent_name_and_description(mock_async_openai: MagicMock) -> None: - """Test _update_agent_name_and_description method updates assistant_name when not already set.""" - # Test updating agent name when assistant_name is None - client = create_test_openai_assistants_client(mock_async_openai, assistant_name=None) - - # Call the private method to update agent name - client._update_agent_name_and_description("New Assistant Name") # type: ignore - - assert client.assistant_name == "New Assistant Name" - - -def test_update_agent_name_and_description_existing(mock_async_openai: MagicMock) -> None: - """Test _update_agent_name_and_description method doesn't override existing assistant_name.""" - # Test that existing assistant_name is not overridden - client = create_test_openai_assistants_client(mock_async_openai, assistant_name="Existing Assistant") - - # Call the private method to update agent name - client._update_agent_name_and_description("New Assistant Name") # type: ignore - - # Should keep the existing name - assert client.assistant_name == "Existing Assistant" - - -def test_update_agent_name_and_description_none(mock_async_openai: MagicMock) -> None: - """Test _update_agent_name_and_description method with None agent_name parameter.""" - # Test that None agent_name doesn't change anything - client = create_test_openai_assistants_client(mock_async_openai, assistant_name=None) - - # Call the private method with None - client._update_agent_name_and_description(None) # type: ignore - - # Should remain None - assert client.assistant_name is None - - -@tool(approval_mode="never_require") -def get_weather( - location: Annotated[str, Field(description="The location to get the weather for.")], -) -> str: - """Get the weather for a given location.""" - return f"The weather in {location} is sunny with a high of 25°C." - - -# Callable API Key Tests -def test_with_callable_api_key() -> None: - """Test OpenAIAssistantsClient initialization with callable API key.""" - - async def get_api_key() -> str: - return "test-api-key-123" - - client = OpenAIAssistantsClient(model="gpt-4o", api_key=get_api_key) - - # Verify client was created successfully - assert client.model == "gpt-4o" - # OpenAI SDK now manages callable API keys internally - assert client.client is not None - - -# region thread.message.completed helpers - - -def _make_stream_event(event: str, data: Any) -> MagicMock: - """Create a mock stream event.""" - mock = MagicMock() - mock.event = event - mock.data = data - return mock - - -def _make_text_block(text_value: str, annotations: list | None = None) -> MagicMock: - """Create a mock TextContentBlock with optional annotations.""" - block = MagicMock() - block.type = "text" - block.text = MagicMock() - block.text.value = text_value - block.text.annotations = annotations or [] - return block - - -def _make_image_block() -> MagicMock: - """Create a mock ImageContentBlock (non-text block).""" - block = MagicMock() - block.type = "image_file" - return block - - -def _make_file_citation_annotation( - text: str = "【4:0†source】", - file_id: str = "file-abc123", - start_index: int = 10, - end_index: int = 24, -) -> MagicMock: - """Create a mock FileCitationAnnotation.""" - annotation = MagicMock(spec=FileCitationAnnotation) - annotation.text = text - annotation.start_index = start_index - annotation.end_index = end_index - annotation.file_citation = MagicMock() - annotation.file_citation.file_id = file_id - return annotation - - -def _make_file_path_annotation( - text: str = "sandbox:/file.csv", - file_id: str = "file-xyz789", - start_index: int = 5, - end_index: int = 22, -) -> MagicMock: - """Create a mock FilePathAnnotation.""" - annotation = MagicMock(spec=FilePathAnnotation) - annotation.text = text - annotation.start_index = start_index - annotation.end_index = end_index - annotation.file_path = MagicMock() - annotation.file_path.file_id = file_id - return annotation - - -def _make_unknown_annotation() -> MagicMock: - """Create a mock annotation of an unrecognized type.""" - annotation = MagicMock() - annotation.__class__.__name__ = "FutureAnnotationType" - return annotation - - -def _make_thread_message(content_blocks: list) -> MagicMock: - """Create a mock ThreadMessage.""" - msg = MagicMock(spec=ThreadMessage) - msg.content = content_blocks - return msg - - -async def _collect_updates(client, stream_events, thread_id="thread_123"): - """Helper to collect ChatResponseUpdate objects from _process_stream_events.""" - - class MockAsyncStream: - def __init__(self, events): - self._events = events - - async def __aenter__(self): - return self - - async def __aexit__(self, *args): - pass - - def __aiter__(self): - return self - - async def __anext__(self): - if not self._events: - raise StopAsyncIteration - return self._events.pop(0) - - mock_stream = MockAsyncStream(list(stream_events)) - results = [] - async for update in client._process_stream_events(mock_stream, thread_id): - results.append(update) - return results - - -# endregion - - -class TestMessageCompletedAnnotations: - """Tests for thread.message.completed event handling.""" - - @pytest.fixture - def client(self): - """Create a client instance for testing.""" - with patch.object(OpenAIAssistantsClient, "__init__", lambda self, **kw: None): - return object.__new__(OpenAIAssistantsClient) - - @pytest.mark.asyncio - async def test_message_completed_with_file_citation(self, client): - """Verify file citation annotations are extracted from completed messages.""" - citation = _make_file_citation_annotation( - text="【4:0†source】", file_id="file-abc123", start_index=10, end_index=24 - ) - text_block = _make_text_block("Some text with a citation【4:0†source】", [citation]) - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - # Should yield exactly one update for the completed message - assert len(updates) == 1 - update = updates[0] - assert update.role == "assistant" - assert len(update.contents) == 1 - - content = update.contents[0] - assert content.text == "Some text with a citation【4:0†source】" - assert content.annotations is not None - assert len(content.annotations) == 1 - - ann = content.annotations[0] - assert ann["type"] == "citation" - assert ann["file_id"] == "file-abc123" - assert ann["annotated_regions"][0]["start_index"] == 10 - assert ann["annotated_regions"][0]["end_index"] == 24 - - @pytest.mark.asyncio - async def test_message_completed_with_file_path(self, client): - """Verify file path annotations are extracted from completed messages.""" - file_path = _make_file_path_annotation( - text="sandbox:/output.csv", file_id="file-xyz789", start_index=0, end_index=19 - ) - text_block = _make_text_block("sandbox:/output.csv", [file_path]) - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - assert len(updates) == 1 - content = updates[0].contents[0] - assert content.annotations is not None - assert len(content.annotations) == 1 - - ann = content.annotations[0] - assert ann["type"] == "citation" - assert ann["file_id"] == "file-xyz789" - assert ann["annotated_regions"][0]["start_index"] == 0 - assert ann["annotated_regions"][0]["end_index"] == 19 - - @pytest.mark.asyncio - async def test_message_completed_multiple_annotations(self, client): - """Verify multiple annotations on a single text block are all captured.""" - cit1 = _make_file_citation_annotation(text="【1†src】", file_id="file-a", start_index=5, end_index=12) - cit2 = _make_file_citation_annotation(text="【2†src】", file_id="file-b", start_index=20, end_index=27) - text_block = _make_text_block("Hello【1†src】world【2†src】", [cit1, cit2]) - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - assert len(updates) == 1 - assert len(updates[0].contents[0].annotations) == 2 - assert updates[0].contents[0].annotations[0]["file_id"] == "file-a" - assert updates[0].contents[0].annotations[1]["file_id"] == "file-b" - - @pytest.mark.asyncio - async def test_message_completed_no_annotations(self, client): - """Verify text-only completed messages produce content without annotations.""" - text_block = _make_text_block("Plain text response") - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - assert len(updates) == 1 - content = updates[0].contents[0] - assert content.text == "Plain text response" - assert content.annotations is None or len(content.annotations) == 0 - - @pytest.mark.asyncio - async def test_message_completed_skips_non_text_blocks(self, client): - """Verify non-text content blocks (e.g., image_file) are skipped.""" - image_block = _make_image_block() - msg = _make_thread_message([image_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - # No text blocks → no update yielded - assert len(updates) == 0 - - @pytest.mark.asyncio - async def test_message_completed_mixed_blocks(self, client): - """Verify only text blocks are processed in mixed-content messages.""" - text_block = _make_text_block("Text content here") - image_block = _make_image_block() - msg = _make_thread_message([image_block, text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events) - - assert len(updates) == 1 - assert len(updates[0].contents) == 1 - assert updates[0].contents[0].text == "Text content here" - - @pytest.mark.asyncio - async def test_message_completed_conversation_id_preserved(self, client): - """Verify the thread_id is correctly propagated as conversation_id.""" - text_block = _make_text_block("Response text") - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - updates = await _collect_updates(client, events, thread_id="thread_custom_456") - - assert len(updates) == 1 - assert updates[0].conversation_id == "thread_custom_456" - - @pytest.mark.asyncio - async def test_message_completed_unrecognized_annotation_logged(self, client, caplog): - """Verify unrecognized annotation types are logged at debug level and skipped.""" - unknown_ann = _make_unknown_annotation() - citation = _make_file_citation_annotation(text="【1†src】", file_id="file-a", start_index=0, end_index=7) - text_block = _make_text_block("Text【1†src】", [unknown_ann, citation]) - msg = _make_thread_message([text_block]) - - events = [_make_stream_event("thread.message.completed", msg)] - with caplog.at_level(logging.DEBUG, logger="agent_framework.openai"): - updates = await _collect_updates(client, events) - - # The known citation should still be processed - assert len(updates) == 1 - assert len(updates[0].contents[0].annotations) == 1 - assert updates[0].contents[0].annotations[0]["file_id"] == "file-a" - - # The unrecognized annotation should have been logged - assert any("Unparsed annotation type" in record.message for record in caplog.records) diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 87025cffe4..902e1d7c7a 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -54,7 +54,7 @@ from openai.types.responses.response_text_delta_event import ResponseTextDeltaEv from pydantic import BaseModel from pytest import param -from agent_framework_openai import OpenAIChatClient, OpenAIResponsesClient +from agent_framework_openai import OpenAIChatClient from agent_framework_openai._chat_client import OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY from agent_framework_openai._exceptions import OpenAIContentFilterException @@ -125,27 +125,27 @@ def test_init_uses_explicit_parameters() -> None: assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) -def test_deprecated_responses_client_supports_all_tool_protocols() -> None: - assert isinstance(OpenAIResponsesClient, SupportsCodeInterpreterTool) - assert isinstance(OpenAIResponsesClient, SupportsWebSearchTool) - assert isinstance(OpenAIResponsesClient, SupportsImageGenerationTool) - assert isinstance(OpenAIResponsesClient, SupportsMCPTool) - assert isinstance(OpenAIResponsesClient, SupportsFileSearchTool) +def test_openai_chat_client_supports_all_tool_protocols() -> None: + assert isinstance(OpenAIChatClient, SupportsCodeInterpreterTool) + assert isinstance(OpenAIChatClient, SupportsWebSearchTool) + assert isinstance(OpenAIChatClient, SupportsImageGenerationTool) + assert isinstance(OpenAIChatClient, SupportsMCPTool) + assert isinstance(OpenAIChatClient, SupportsFileSearchTool) -def test_protocol_isinstance_with_responses_client_instance() -> None: - client = object.__new__(OpenAIResponsesClient) +def test_protocol_isinstance_with_openai_chat_client_instance() -> None: + client = object.__new__(OpenAIChatClient) assert isinstance(client, SupportsCodeInterpreterTool) assert isinstance(client, SupportsWebSearchTool) -def test_deprecated_responses_client_tool_methods_return_dict() -> None: - code_tool = OpenAIResponsesClient.get_code_interpreter_tool() +def test_openai_chat_client_tool_methods_return_dict() -> None: + code_tool = OpenAIChatClient.get_code_interpreter_tool() assert isinstance(code_tool, dict) assert code_tool.get("type") == "code_interpreter" - web_tool = OpenAIResponsesClient.get_web_search_tool() + web_tool = OpenAIChatClient.get_web_search_tool() assert isinstance(web_tool, dict) assert web_tool.get("type") == "web_search" diff --git a/python/packages/purview/README.md b/python/packages/purview/README.md index 2b1f9b7984..cbe2999040 100644 --- a/python/packages/purview/README.md +++ b/python/packages/purview/README.md @@ -54,12 +54,12 @@ Add Purview when you need to: ```python import asyncio from agent_framework import Agent, Message, Role -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.microsoft import PurviewPolicyMiddleware, PurviewSettings from azure.identity import InteractiveBrowserCredential async def main(): - client = AzureOpenAIChatClient() # uses environment for endpoint + deployment + client = OpenAIChatCompletionClient() # uses environment for endpoint + deployment purview_middleware = PurviewPolicyMiddleware( credential=InteractiveBrowserCredential(), @@ -219,12 +219,12 @@ Use the agent middleware when you already have / want the full agent pipeline: ```python from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.microsoft import PurviewPolicyMiddleware, PurviewSettings from azure.identity import DefaultAzureCredential credential = DefaultAzureCredential() -client = AzureOpenAIChatClient() +client = OpenAIChatCompletionClient() agent = Agent( client=client, @@ -238,15 +238,15 @@ Use the chat middleware when you attach directly to a chat client (e.g. minimal ```python import os from agent_framework import Agent -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.microsoft import PurviewChatPolicyMiddleware, PurviewSettings from azure.identity import DefaultAzureCredential credential = DefaultAzureCredential() -client = AzureOpenAIChatClient( - deployment_name=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], - endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], +client = OpenAIChatCompletionClient( + model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], + azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], credential=credential, middleware=[ PurviewChatPolicyMiddleware(credential, PurviewSettings(app_name="My App (Chat)")) diff --git a/python/samples/01-get-started/README.md b/python/samples/01-get-started/README.md index 7d696ba528..d89457468a 100644 --- a/python/samples/01-get-started/README.md +++ b/python/samples/01-get-started/README.md @@ -9,6 +9,13 @@ concepts of **Agent Framework** one step at a time. pip install agent-framework --pre ``` +Set the required environment variables: + +```bash +export FOUNDRY_PROJECT_ENDPOINT="https://your-project-endpoint" +export FOUNDRY_MODEL="gpt-4o" # optional, defaults to gpt-4o +``` + ## Samples | # | File | What you'll learn | diff --git a/python/samples/02-agents/chat_client/README.md b/python/samples/02-agents/chat_client/README.md index 6650e510a9..e037877291 100644 --- a/python/samples/02-agents/chat_client/README.md +++ b/python/samples/02-agents/chat_client/README.md @@ -15,22 +15,19 @@ This folder contains examples for direct chat client usage patterns. `built_in_chat_clients.py` starts with: ```python -asyncio.run(main("openai_chat")) +asyncio.run(main("openai_responses")) ``` Change the argument to pick a client: -- `openai_chat` - `openai_responses` -- `openai_assistants` +- `openai_chat_completion` - `anthropic` - `ollama` - `bedrock` -- `azure_openai_chat` - `azure_openai_responses` -- `azure_openai_responses_foundry` -- `azure_openai_assistants` -- `azure_ai_agent` +- `azure_openai_chat_completion` +- `foundry_chat` Example: @@ -42,22 +39,19 @@ uv run samples/02-agents/chat_client/built_in_chat_clients.py Depending on the selected client, set the appropriate environment variables: -**For Azure clients:** +**For Azure OpenAI clients (`azure_openai_responses` and `azure_openai_chat_completion`):** - `AZURE_OPENAI_ENDPOINT`: Your Azure OpenAI endpoint -- `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`: The name of your Azure OpenAI chat deployment -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your Azure OpenAI responses deployment +- `AZURE_OPENAI_DEPLOYMENT_NAME`: The Azure OpenAI deployment used by the sample +- `AZURE_OPENAI_API_VERSION` (optional): Azure OpenAI API version override +- `AZURE_OPENAI_API_KEY` (optional): Azure OpenAI API key if you are not using `AzureCliCredential` -**For Azure OpenAI Foundry responses client (`azure_openai_responses_foundry`):** -- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI project endpoint -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your Azure OpenAI responses deployment - -**For Azure AI agent client (`azure_ai_agent`):** -- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI project endpoint -- `AZURE_AI_MODEL_DEPLOYMENT_NAME`: The name of your model deployment (used by `azure_ai_agent`) +**For Foundry client (`foundry_chat`):** +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint +- `FOUNDRY_MODEL`: The Foundry deployment used by the sample **For OpenAI clients:** - `OPENAI_API_KEY`: Your OpenAI API key -- `OPENAI_CHAT_MODEL`: The OpenAI model for `openai_chat` and `openai_assistants` +- `OPENAI_CHAT_MODEL`: The OpenAI model for `openai_chat_completion` - `OPENAI_RESPONSES_MODEL`: The OpenAI model for `openai_responses` **For Anthropic client (`anthropic`):** diff --git a/python/samples/02-agents/chat_client/built_in_chat_clients.py b/python/samples/02-agents/chat_client/built_in_chat_clients.py index b83a077bc5..d0a14bc9ff 100644 --- a/python/samples/02-agents/chat_client/built_in_chat_clients.py +++ b/python/samples/02-agents/chat_client/built_in_chat_clients.py @@ -6,13 +6,9 @@ from random import randint from typing import Annotated, Any, Literal from agent_framework import Message, SupportsChatGetResponse, tool -from agent_framework.azure import ( - AzureOpenAIAssistantsClient, -) from agent_framework.foundry import FoundryChatClient -from agent_framework.openai import OpenAIAssistantsClient +from agent_framework.openai import OpenAIChatClient, OpenAIChatCompletionClient from azure.identity import AzureCliCredential -from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential from dotenv import load_dotenv from pydantic import Field @@ -26,31 +22,25 @@ This sample demonstrates how to run the same prompt flow against different built chat clients using a single `get_client` factory. Select one of these client names: -- openai_chat - openai_responses -- openai_assistants +- openai_chat_completion - anthropic - ollama - bedrock -- azure_openai_chat - azure_openai_responses -- azure_openai_responses_foundry -- azure_openai_assistants -- azure_ai_agent +- azure_openai_chat_completion +- foundry_chat """ ClientName = Literal[ - "openai_chat", "openai_responses", - "openai_assistants", + "openai_chat_completion", "anthropic", "ollama", "bedrock", - "azure_openai_chat", "azure_openai_responses", - "azure_openai_responses_foundry", - "azure_openai_assistants", - "azure_ai_agent", + "azure_openai_chat_completion", + "foundry_chat", ] @@ -71,55 +61,41 @@ def get_client(client_name: ClientName) -> SupportsChatGetResponse[Any]: from agent_framework.amazon import BedrockChatClient from agent_framework.anthropic import AnthropicClient from agent_framework.ollama import OllamaChatClient - from agent_framework.openai import OpenAIResponsesClient - # 1. Create OpenAI clients. - if client_name == "openai_chat": - return FoundryChatClient() if client_name == "openai_responses": - return OpenAIResponsesClient() - if client_name == "openai_assistants": - return OpenAIAssistantsClient() + return OpenAIChatClient() + if client_name == "openai_chat_completion": + return OpenAIChatCompletionClient() if client_name == "anthropic": return AnthropicClient() if client_name == "ollama": return OllamaChatClient() if client_name == "bedrock": return BedrockChatClient() - - # 2. Create Azure OpenAI clients. - if client_name == "azure_openai_chat": - return FoundryChatClient(credential=AzureCliCredential()) if client_name == "azure_openai_responses": - return FoundryChatClient(credential=AzureCliCredential(), api_version="preview") - if client_name == "azure_openai_responses_foundry": + return OpenAIChatClient(credential=AzureCliCredential()) + if client_name == "azure_openai_chat_completion": + return OpenAIChatCompletionClient(credential=AzureCliCredential()) + if client_name == "foundry_chat": return FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], model=os.environ["FOUNDRY_MODEL"], credential=AzureCliCredential(), ) - if client_name == "azure_openai_assistants": - return AzureOpenAIAssistantsClient(credential=AzureCliCredential()) - - # 3. Create Azure AI client. - if client_name == "azure_ai_agent": - return FoundryChatClient(credential=AsyncAzureCliCredential()) raise ValueError(f"Unsupported client name: {client_name}") -async def main(client_name: ClientName = "openai_chat") -> None: +async def main(client_name: ClientName = "openai_responses") -> None: """Run a basic prompt using a selected built-in client.""" client = get_client(client_name) - # 1. Configure prompt and streaming mode. message = Message("user", text="What's the weather in Amsterdam and in Paris?") stream = os.getenv("STREAM", "false").lower() == "true" print(f"Client: {client_name}") print(f"User: {message.text}") - # 2. Run with context-managed clients. - if isinstance(client, OpenAIAssistantsClient | AzureOpenAIAssistantsClient | FoundryChatClient): + if isinstance(client, FoundryChatClient): async with client: if stream: response_stream = client.get_response([message], stream=True, options={"tools": get_weather}) @@ -134,7 +110,6 @@ async def main(client_name: ClientName = "openai_chat") -> None: ) return - # 3. Run with non-context-managed clients. if stream: response_stream = client.get_response([message], stream=True, options={"tools": get_weather}) print("Assistant: ", end="") @@ -147,7 +122,7 @@ async def main(client_name: ClientName = "openai_chat") -> None: if __name__ == "__main__": - asyncio.run(main("openai_chat")) + asyncio.run(main("openai_responses")) """ diff --git a/python/samples/02-agents/context_providers/azure_ai_search/README.md b/python/samples/02-agents/context_providers/azure_ai_search/README.md index 6c7f7de711..9e5f6c03f2 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/README.md +++ b/python/samples/02-agents/context_providers/azure_ai_search/README.md @@ -49,14 +49,14 @@ Run `az login` if using Entra ID authentication. **Common (both modes):** - `AZURE_SEARCH_ENDPOINT`: Your Azure AI Search endpoint (e.g., `https://myservice.search.windows.net`) - `AZURE_SEARCH_INDEX_NAME`: Name of your search index -- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint -- `AZURE_AI_MODEL_DEPLOYMENT_NAME`: Model deployment name (e.g., `gpt-4o`, defaults to `gpt-4o`) +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint +- `FOUNDRY_MODEL`: Model deployment name (e.g., `gpt-4o`, defaults to `gpt-4o`) - `AZURE_SEARCH_API_KEY`: _(Optional)_ Your search API key - if not provided, uses DefaultAzureCredential **Agentic mode only:** - `AZURE_SEARCH_KNOWLEDGE_BASE_NAME`: Name of your Knowledge Base in Azure AI Search - `AZURE_OPENAI_RESOURCE_URL`: Your Azure OpenAI resource URL (e.g., `https://myresource.openai.azure.com`) - - **Important**: This is different from `AZURE_AI_PROJECT_ENDPOINT` - Knowledge Base needs the OpenAI endpoint for model calls + - **Important**: This is different from `FOUNDRY_PROJECT_ENDPOINT` - Knowledge Base needs the OpenAI endpoint for model calls ### Example .env file @@ -64,8 +64,8 @@ Run `az login` if using Entra ID authentication. ```env AZURE_SEARCH_ENDPOINT=https://myservice.search.windows.net AZURE_SEARCH_INDEX_NAME=my-index -AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ -AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +FOUNDRY_MODEL=gpt-4o # Optional - omit to use Entra ID AZURE_SEARCH_API_KEY=your-search-key ``` @@ -127,7 +127,8 @@ AZURE_OPENAI_RESOURCE_URL=https://myresource.openai.azure.com ```python from agent_framework import Agent -from agent_framework.azure import AzureAIAgentClient, AzureAISearchContextProvider +from agent_framework.azure import AzureAISearchContextProvider +from agent_framework.foundry import FoundryChatClient from azure.identity.aio import DefaultAzureCredential # Create search provider with semantic mode (default) @@ -140,10 +141,13 @@ search_provider = AzureAISearchContextProvider( ) # Create agent with search context -async with AzureAIAgentClient(credential=DefaultAzureCredential()) as client: +async with FoundryChatClient( + project_endpoint=project_endpoint, + model=model_deployment, + credential=DefaultAzureCredential(), +) as client: async with Agent( client=client, - model=model_deployment, context_providers=[search_provider], ) as agent: response = await agent.run("What information is in the knowledge base?") diff --git a/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py b/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py index 36612d3b8e..298b748dc4 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py +++ b/python/samples/02-agents/context_providers/azure_ai_search/search_context_agentic.py @@ -34,7 +34,7 @@ Environment variables: - AZURE_SEARCH_ENDPOINT: Your Azure AI Search endpoint - AZURE_SEARCH_API_KEY: (Optional) API key - if not provided, uses AzureCliCredential - FOUNDRY_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint - - AZURE_AI_MODEL_DEPLOYMENT_NAME: Your model deployment name (e.g., "gpt-4o") + - FOUNDRY_MODEL: Your model deployment name (e.g., "gpt-4o") For using an existing Knowledge Base (recommended): - AZURE_SEARCH_KNOWLEDGE_BASE_NAME: Your Knowledge Base name @@ -59,7 +59,7 @@ async def main() -> None: search_endpoint = os.environ["AZURE_SEARCH_ENDPOINT"] search_key = os.environ.get("AZURE_SEARCH_API_KEY") project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] - model_deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o") + model_deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o") # Agentic mode requires exactly ONE of: knowledge_base_name OR index_name # Option 1: Use existing Knowledge Base (recommended) diff --git a/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py b/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py index 98b7d66f88..cacc9556e9 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py +++ b/python/samples/02-agents/context_providers/azure_ai_search/search_context_semantic.py @@ -31,7 +31,7 @@ Prerequisites: - AZURE_SEARCH_API_KEY: (Optional) Your search API key - if not provided, uses AzureCliCredential for Entra ID - AZURE_SEARCH_INDEX_NAME: Your search index name - FOUNDRY_PROJECT_ENDPOINT: Your Azure AI Foundry project endpoint - - AZURE_AI_MODEL_DEPLOYMENT_NAME: Your model deployment name (e.g., "gpt-4o") + - FOUNDRY_MODEL: Your model deployment name (e.g., "gpt-4o") - AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: (Optional) Your Azure OpenAI embedding deployment for hybrid search - AZURE_OPENAI_ENDPOINT: (Optional) Your Azure OpenAI resource URL, required if using Azure OpenAI embeddings """ @@ -54,7 +54,7 @@ async def main() -> None: search_key = os.environ.get("AZURE_SEARCH_API_KEY") index_name = os.environ["AZURE_SEARCH_INDEX_NAME"] project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] - model_deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o") + model_deployment = os.environ.get("FOUNDRY_MODEL", "gpt-4o") openai_endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT") embedding_deployment = os.environ.get("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") diff --git a/python/samples/02-agents/context_providers/mem0/README.md b/python/samples/02-agents/context_providers/mem0/README.md index 4c12bb67d8..2a7e3416d6 100644 --- a/python/samples/02-agents/context_providers/mem0/README.md +++ b/python/samples/02-agents/context_providers/mem0/README.md @@ -33,8 +33,8 @@ Set the following environment variables: - `OPENAI_API_KEY`: Your OpenAI API key (used by Mem0 OSS for embedding generation and automatic memory extraction) **For Azure AI:** -- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI project endpoint -- `AZURE_AI_MODEL_DEPLOYMENT_NAME`: The name of your model deployment +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI project endpoint +- `FOUNDRY_MODEL`: The name of your model deployment ## Key Concepts diff --git a/python/samples/02-agents/context_providers/redis/README.md b/python/samples/02-agents/context_providers/redis/README.md index 060061c908..097aa6b4eb 100644 --- a/python/samples/02-agents/context_providers/redis/README.md +++ b/python/samples/02-agents/context_providers/redis/README.md @@ -51,8 +51,8 @@ See quickstart: `https://learn.microsoft.com/azure/redis/quickstart-create-manag ### Environment variables -- `AZURE_AI_PROJECT_ENDPOINT` (required): Azure AI Foundry project endpoint for `AzureOpenAIResponsesClient` -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` (required): Azure OpenAI Responses deployment name +- `FOUNDRY_PROJECT_ENDPOINT` (required): Azure AI Foundry project endpoint for `FoundryChatClient` +- `FOUNDRY_MODEL` (required): Foundry model deployment name - `OPENAI_API_KEY` (optional): Required only if you set `vectorizer_choice="openai"` to enable hybrid search. ### Provider configuration highlights @@ -73,7 +73,7 @@ The provider supports both full‑text only and hybrid vector search: 2. Agent integration: teaches the agent a preference and verifies it is remembered across turns. 3. Agent + tool: calls a sample tool (flight search) and then asks the agent to recall details remembered from the tool output. -It uses `AzureOpenAIResponsesClient` (Foundry project endpoint setup) for chat and, in some steps, optional OpenAI embeddings for hybrid search. +It uses `FoundryChatClient` for chat and, in some steps, optional OpenAI embeddings for hybrid search. ## How to run @@ -82,8 +82,8 @@ It uses `AzureOpenAIResponsesClient` (Foundry project endpoint setup) for chat a 2) Set Azure Foundry/OpenAI responses environment variables: ```bash -export AZURE_AI_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -export AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME="" +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +export FOUNDRY_MODEL="" ``` 3) (Optional) Set your OpenAI key if using embeddings: @@ -119,6 +119,6 @@ You should see the agent responses and, when using embeddings, context retrieved ## Troubleshooting - Ensure at least one of `application_id`, `agent_id`, `user_id`, or `thread_id` is set; the provider requires a scope. -- Verify `AZURE_AI_PROJECT_ENDPOINT` and `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` are set for the chat client. +- Verify `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL` are set for the chat client. - If using embeddings, verify `OPENAI_API_KEY` is set and reachable. - Make sure Redis exposes RediSearch (Redis Stack image or managed service with search enabled). diff --git a/python/samples/02-agents/declarative/mcp_tool_yaml.py b/python/samples/02-agents/declarative/mcp_tool_yaml.py index fd6c233034..3931d19ae1 100644 --- a/python/samples/02-agents/declarative/mcp_tool_yaml.py +++ b/python/samples/02-agents/declarative/mcp_tool_yaml.py @@ -10,11 +10,11 @@ Key Features Demonstrated: 1. Loading agent definitions from YAML using AgentFactory 2. Configuring MCP tools with different authentication methods: - API key authentication (OpenAI.Responses provider) - - Azure AI Foundry connection references (AzureAI.ProjectProvider) + - Azure AI Foundry connection references (Foundry provider) Authentication Options: - OpenAI.Responses: Supports inline API key auth via headers -- AzureAI.ProjectProvider: Uses Foundry connections for secure credential storage +- Foundry: Uses project-backed chat with Foundry connections for secure credential storage (no secrets passed in API calls - connection name references pre-configured auth) Prerequisites: @@ -79,7 +79,7 @@ instructions: | model: id: gpt-4o - provider: AzureAI.ProjectProvider + provider: Foundry tools: - kind: mcp diff --git a/python/samples/02-agents/devui/README.md b/python/samples/02-agents/devui/README.md index c5ce2095b8..4315582009 100644 --- a/python/samples/02-agents/devui/README.md +++ b/python/samples/02-agents/devui/README.md @@ -55,15 +55,15 @@ agent_name/ | Sample | Description | Features | Required Environment Variables | | ------------------------------------------------ | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| [**weather_agent_azure/**](weather_agent_azure/) | Weather agent using Azure OpenAI with API key authentication | Azure OpenAI integration, function calling, mock weather tools | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, `AZURE_OPENAI_ENDPOINT` | -| [**foundry_agent/**](foundry_agent/) | Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication (run `az login` first) | Azure AI Agent integration, Azure CLI authentication, mock weather tools | `AZURE_AI_PROJECT_ENDPOINT`, `FOUNDRY_MODEL_DEPLOYMENT_NAME` | +| [**weather_agent_azure/**](weather_agent_azure/) | Weather agent using Azure OpenAI with API key authentication | Azure OpenAI integration, function calling, mock weather tools | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_DEPLOYMENT_NAME`, `AZURE_OPENAI_ENDPOINT` | +| [**foundry_agent/**](foundry_agent/) | Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication (run `az login` first) | Azure AI Agent integration, Azure CLI authentication, mock weather tools | `FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_MODEL` | ### Workflows | Sample | Description | Features | Required Environment Variables | | -------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | [**declarative/**](declarative/) | Declarative YAML workflow with conditional branching | YAML-based workflow definition, conditional logic, no Python code required | None - uses mock data | -| [**workflow_agents/**](workflow_agents/) | Content review workflow with agents as executors | Agents as workflow nodes, conditional routing based on structured outputs, quality-based paths (Writer -> Reviewer -> Editor/Publisher) | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, `AZURE_OPENAI_ENDPOINT` | +| [**workflow_agents/**](workflow_agents/) | Content review workflow with agents as executors | Agents as workflow nodes, conditional routing based on structured outputs, quality-based paths (Writer -> Reviewer -> Editor/Publisher) | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_DEPLOYMENT_NAME`, `AZURE_OPENAI_ENDPOINT` | | [**spam_workflow/**](spam_workflow/) | 5-step email spam detection workflow with branching logic | Sequential execution, conditional branching (spam vs. legitimate), multiple executors, mock spam detection | None - uses mock data | | [**fanout_workflow/**](fanout_workflow/) | Advanced data processing workflow with parallel execution | Fan-out/fan-in patterns, complex state management, multi-stage processing (validation -> transformation -> quality assurance) | None - uses mock data | diff --git a/python/samples/02-agents/devui/azure_responses_agent/.env.example b/python/samples/02-agents/devui/azure_responses_agent/.env.example index 4d0751a863..975324fa02 100644 --- a/python/samples/02-agents/devui/azure_responses_agent/.env.example +++ b/python/samples/02-agents/devui/azure_responses_agent/.env.example @@ -12,4 +12,4 @@ AZURE_OPENAI_API_KEY=your-azure-openai-api-key-here AZURE_OPENAI_ENDPOINT=https://your-resource.cognitiveservices.azure.com/ # Required: Deployment name (must support Responses API) -AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=gpt-4.1-mini +FOUNDRY_MODEL=gpt-4.1-mini diff --git a/python/samples/02-agents/devui/foundry_agent/.env.example b/python/samples/02-agents/devui/foundry_agent/.env.example index 79a6108b53..bd24359800 100644 --- a/python/samples/02-agents/devui/foundry_agent/.env.example +++ b/python/samples/02-agents/devui/foundry_agent/.env.example @@ -2,5 +2,5 @@ # Get your credentials from Azure AI Foundry portal # Make sure to run 'az login' before starting devui -AZURE_AI_PROJECT_ENDPOINT=https://your-project.api.azureml.ms -FOUNDRY_MODEL_DEPLOYMENT_NAME=gpt-4o +FOUNDRY_PROJECT_ENDPOINT=https://your-project.api.azureml.ms +FOUNDRY_MODEL=gpt-4o diff --git a/python/samples/02-agents/devui/foundry_agent/agent.py b/python/samples/02-agents/devui/foundry_agent/agent.py index 1eb7feb9e2..8550c1a32c 100644 --- a/python/samples/02-agents/devui/foundry_agent/agent.py +++ b/python/samples/02-agents/devui/foundry_agent/agent.py @@ -53,7 +53,7 @@ agent = Agent( name="FoundryWeatherAgent", client=FoundryChatClient( project_endpoint=os.environ.get("FOUNDRY_PROJECT_ENDPOINT"), - model_model=os.environ.get("FOUNDRY_MODEL_DEPLOYMENT_NAME"), + model_model=os.environ.get("FOUNDRY_MODEL"), credential=AzureCliCredential(), ), instructions=""" diff --git a/python/samples/02-agents/devui/weather_agent_azure/.env.example b/python/samples/02-agents/devui/weather_agent_azure/.env.example index ed48950be0..70817b460c 100644 --- a/python/samples/02-agents/devui/weather_agent_azure/.env.example +++ b/python/samples/02-agents/devui/weather_agent_azure/.env.example @@ -2,5 +2,5 @@ # Get your credentials from Azure Portal AZURE_OPENAI_API_KEY=your-azure-openai-api-key-here -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=gpt-4o +AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com diff --git a/python/samples/02-agents/devui/workflow_agents/.env.example b/python/samples/02-agents/devui/workflow_agents/.env.example index 98243da83e..1153ab182a 100644 --- a/python/samples/02-agents/devui/workflow_agents/.env.example +++ b/python/samples/02-agents/devui/workflow_agents/.env.example @@ -2,6 +2,6 @@ # Get your credentials from Azure Portal AZURE_OPENAI_API_KEY=your-azure-openai-api-key-here -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=gpt-4o +AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com AZURE_OPENAI_API_VERSION=2024-10-21 diff --git a/python/samples/02-agents/evaluation/evaluate_multimodal.py b/python/samples/02-agents/evaluation/evaluate_multimodal.py index 5d456ef4dc..f51bfc77e7 100644 --- a/python/samples/02-agents/evaluation/evaluate_multimodal.py +++ b/python/samples/02-agents/evaluation/evaluate_multimodal.py @@ -22,7 +22,6 @@ from agent_framework import ( evaluator, ) - # -- Custom evaluators that inspect multimodal content -- diff --git a/python/samples/02-agents/middleware/README.md b/python/samples/02-agents/middleware/README.md index 5bd318575c..75380d93db 100644 --- a/python/samples/02-agents/middleware/README.md +++ b/python/samples/02-agents/middleware/README.md @@ -21,7 +21,7 @@ This folder contains focused middleware samples for `Agent`, chat clients, tools ## Running the usage tracking sample -The new usage tracking sample uses `OpenAIResponsesClient`, so set the usual OpenAI responses environment variables first: +The new usage tracking sample uses `OpenAIChatClient`, so set the usual OpenAI responses environment variables first: ```bash export OPENAI_API_KEY="your-openai-api-key" diff --git a/python/samples/02-agents/middleware/override_result_with_middleware.py b/python/samples/02-agents/middleware/override_result_with_middleware.py index 14bf42acd0..dde54e4238 100644 --- a/python/samples/02-agents/middleware/override_result_with_middleware.py +++ b/python/samples/02-agents/middleware/override_result_with_middleware.py @@ -19,7 +19,7 @@ from agent_framework import ( ResponseStream, tool, ) -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from pydantic import Field @@ -190,7 +190,7 @@ async def main() -> None: # For authentication, run `az login` command in terminal or replace AzureCliCredential with preferred # authentication option. agent = Agent( - client=OpenAIResponsesClient( + client=OpenAIChatClient( middleware=[validate_weather_middleware, weather_override_middleware], ), name="WeatherAgent", diff --git a/python/samples/02-agents/middleware/usage_tracking_middleware.py b/python/samples/02-agents/middleware/usage_tracking_middleware.py index bffa01f7d8..056d518fd4 100644 --- a/python/samples/02-agents/middleware/usage_tracking_middleware.py +++ b/python/samples/02-agents/middleware/usage_tracking_middleware.py @@ -19,7 +19,7 @@ from agent_framework import ( chat_middleware, tool, ) -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from pydantic import Field @@ -53,7 +53,7 @@ def _reset_usage_counters() -> None: def _create_agent() -> Agent: """Create the shared agent used by both demonstrations.""" return Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions=( "You are a weather assistant. Always call the weather tool before answering weather questions, " "then summarize the tool result in one short paragraph." diff --git a/python/samples/02-agents/multimodal_input/README.md b/python/samples/02-agents/multimodal_input/README.md index 2254fe89f7..6feda8e62c 100644 --- a/python/samples/02-agents/multimodal_input/README.md +++ b/python/samples/02-agents/multimodal_input/README.md @@ -32,8 +32,8 @@ Set the following environment variables before running the examples: **For Azure OpenAI:** - `AZURE_OPENAI_ENDPOINT`: Your Azure OpenAI endpoint -- `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`: The name of your Azure OpenAI chat model deployment -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your Azure OpenAI responses model deployment +- `AZURE_OPENAI_DEPLOYMENT_NAME`: The name of your Azure OpenAI chat model deployment +- `AZURE_OPENAI_DEPLOYMENT_NAME`: The name of your Azure OpenAI responses model deployment Optionally for Azure OpenAI: - `AZURE_OPENAI_API_VERSION`: The API version to use (default is `2024-10-21`) @@ -41,11 +41,11 @@ Optionally for Azure OpenAI: **Note:** You can also provide configuration directly in code instead of using environment variables: ```python -# Example: Pass deployment_name directly -client = AzureOpenAIChatClient( +# Example: Pass the Foundry project endpoint directly +client = FoundryChatClient( credential=AzureCliCredential(), - deployment_name="your-deployment-name", - endpoint="https://your-resource.openai.azure.com" + project_endpoint="https://your-project.services.ai.azure.com", + model="your-deployment-name", ) ``` diff --git a/python/samples/02-agents/observability/.env.example b/python/samples/02-agents/observability/.env.example index c1c24a5a72..bbcf8c1301 100644 --- a/python/samples/02-agents/observability/.env.example +++ b/python/samples/02-agents/observability/.env.example @@ -45,5 +45,5 @@ OPENAI_CHAT_MODEL="gpt-4o-2024-08-06" # Azure AI Foundry specific variables # ==================================== -AZURE_AI_PROJECT_ENDPOINT="..." -AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" +FOUNDRY_PROJECT_ENDPOINT="..." +FOUNDRY_MODEL="gpt-4o-mini" diff --git a/python/samples/02-agents/providers/anthropic/README.md b/python/samples/02-agents/providers/anthropic/README.md index 84a3b855d7..5151ade855 100644 --- a/python/samples/02-agents/providers/anthropic/README.md +++ b/python/samples/02-agents/providers/anthropic/README.md @@ -33,7 +33,8 @@ This folder contains examples demonstrating how to use Anthropic's Claude models ### Foundry - `ANTHROPIC_FOUNDRY_API_KEY`: Your Foundry Anthropic API key -- `ANTHROPIC_FOUNDRY_ENDPOINT`: The endpoint URL for your Foundry Anthropic resource +- `ANTHROPIC_FOUNDRY_RESOURCE`: Your Foundry resource name (for example `my-foundry-resource`) +- `ANTHROPIC_FOUNDRY_BASE_URL`: Optional full Foundry Anthropic base URL alternative to `ANTHROPIC_FOUNDRY_RESOURCE` - `ANTHROPIC_CHAT_MODEL_ID`: The Claude model to use in Foundry (e.g., `claude-haiku-4-5`) ### Claude Agent diff --git a/python/samples/02-agents/providers/anthropic/anthropic_foundry.py b/python/samples/02-agents/providers/anthropic/anthropic_foundry.py index fdbe59d90f..dd66262131 100644 --- a/python/samples/02-agents/providers/anthropic/anthropic_foundry.py +++ b/python/samples/02-agents/providers/anthropic/anthropic_foundry.py @@ -22,8 +22,11 @@ This example requires `anthropic>=0.74.0` and an endpoint in Foundry for Anthrop To use the Foundry integration ensure you have the following environment variables set: - ANTHROPIC_FOUNDRY_API_KEY Alternatively you can pass in a azure_ad_token_provider function to the AsyncAnthropicFoundry constructor. -- ANTHROPIC_FOUNDRY_ENDPOINT - Should be something like https://.services.ai.azure.com/anthropic/ +- ANTHROPIC_FOUNDRY_RESOURCE + Should be the resource name portion of your Foundry Anthropic URL, such as . +- ANTHROPIC_FOUNDRY_BASE_URL + Optional alternative to ANTHROPIC_FOUNDRY_RESOURCE. Should be something like + https://.services.ai.azure.com/anthropic/ - ANTHROPIC_CHAT_MODEL_ID Should be something like claude-haiku-4-5 """ diff --git a/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py b/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py index cf26f6ba38..613aa03000 100644 --- a/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py +++ b/python/samples/02-agents/providers/azure/openai_chat_completion_client_with_explicit_settings.py @@ -41,7 +41,7 @@ async def main() -> None: # authentication option. agent = Agent( client=OpenAIChatCompletionClient( - model=os.environ["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"], + model=os.environ["AZURE_OPENAI_DEPLOYMENT_NAME"], azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], credential=AzureCliCredential(), ), diff --git a/python/samples/02-agents/providers/custom/README.md b/python/samples/02-agents/providers/custom/README.md index 766e5e0269..976edd29a0 100644 --- a/python/samples/02-agents/providers/custom/README.md +++ b/python/samples/02-agents/providers/custom/README.md @@ -27,7 +27,7 @@ Both approaches allow you to extend the framework for your specific use cases wh ## Understanding Raw Client Classes -The framework provides `Raw...Client` classes (e.g., `RawOpenAIChatClient`, `RawOpenAIChatCompletionClient`, `RawAzureAIClient`) that are intermediate implementations without middleware, telemetry, or function invocation support. +The framework provides `Raw...Client` classes (e.g., `RawOpenAIChatClient`, `RawOpenAIChatCompletionClient`, `RawFoundryChatClient`) that are intermediate implementations without middleware, telemetry, or function invocation support. ### Warning: Raw Clients Should Not Normally Be Used Directly @@ -62,8 +62,8 @@ For most use cases, use the fully-featured public client classes which already h - `OpenAIChatCompletionClient` - OpenAI Chat Completions API with all layers - `OpenAIChatClient` - OpenAI Responses API with all layers -- `AzureOpenAIChatClient` - Azure OpenAI Chat with all layers -- `AzureOpenAIResponsesClient` - Azure OpenAI Responses with all layers -- `AzureAIClient` - Azure AI Project with all layers +- `OpenAIChatCompletionClient` - Azure OpenAI Chat Completions with all layers +- `OpenAIChatClient` - Azure OpenAI Responses with all layers +- `FoundryChatClient` - Azure AI Foundry project-backed chat with all layers These clients handle the layer composition correctly and provide the full feature set out of the box. diff --git a/python/samples/02-agents/skills/code_defined_skill/README.md b/python/samples/02-agents/skills/code_defined_skill/README.md index ae70268ca4..281580bcc7 100644 --- a/python/samples/02-agents/skills/code_defined_skill/README.md +++ b/python/samples/02-agents/skills/code_defined_skill/README.md @@ -27,8 +27,8 @@ code_defined_skill/ Set the required environment variables in a `.env` file (see `python/.env.example`): -- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your model deployment (defaults to `gpt-4o-mini`) +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint +- `AZURE_OPENAI_DEPLOYMENT_NAME`: The name of your model deployment (defaults to `gpt-4o-mini`) ### Authentication diff --git a/python/samples/02-agents/skills/file_based_skill/README.md b/python/samples/02-agents/skills/file_based_skill/README.md index ebc686941f..23a27d1c24 100644 --- a/python/samples/02-agents/skills/file_based_skill/README.md +++ b/python/samples/02-agents/skills/file_based_skill/README.md @@ -47,8 +47,8 @@ file_based_skill/ Set the required environment variables in a `.env` file (see `python/.env.example`): -- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your model deployment (defaults to `gpt-4o-mini`) +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint +- `AZURE_OPENAI_DEPLOYMENT_NAME`: The name of your model deployment (defaults to `gpt-4o-mini`) ### Authentication diff --git a/python/samples/02-agents/skills/mixed_skills/README.md b/python/samples/02-agents/skills/mixed_skills/README.md index 33b6760719..0703ce5d20 100644 --- a/python/samples/02-agents/skills/mixed_skills/README.md +++ b/python/samples/02-agents/skills/mixed_skills/README.md @@ -60,8 +60,8 @@ File scripts are executed as **local Python subprocesses** via the Set environment variables (or create a `.env` file): ``` -AZURE_AI_PROJECT_ENDPOINT=https://your-project.openai.azure.com/ -AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=gpt-4o-mini +FOUNDRY_PROJECT_ENDPOINT=https://your-project.openai.azure.com/ +AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o-mini ``` Authenticate with Azure CLI: diff --git a/python/samples/02-agents/skills/script_approval/README.md b/python/samples/02-agents/skills/script_approval/README.md index 5392e3f2ae..81ef08c416 100644 --- a/python/samples/02-agents/skills/script_approval/README.md +++ b/python/samples/02-agents/skills/script_approval/README.md @@ -28,8 +28,8 @@ When `require_script_approval=True` is set, the agent pauses before executing an Set the required environment variables in a `.env` file (see `python/.env.example`): -- `AZURE_AI_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME`: The name of your model deployment (defaults to `gpt-4o-mini`) +- `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint +- `AZURE_OPENAI_DEPLOYMENT_NAME`: The name of your model deployment (defaults to `gpt-4o-mini`) ### Authentication diff --git a/python/samples/02-agents/tools/function_invocation_configuration.py b/python/samples/02-agents/tools/function_invocation_configuration.py index 78fdecbb2c..a23701bb7d 100644 --- a/python/samples/02-agents/tools/function_invocation_configuration.py +++ b/python/samples/02-agents/tools/function_invocation_configuration.py @@ -4,7 +4,7 @@ import asyncio from typing import Annotated from agent_framework import Agent, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -28,7 +28,7 @@ def add( async def main(): - client = OpenAIResponsesClient() + client = OpenAIChatClient() client.function_invocation_configuration["include_detailed_errors"] = True client.function_invocation_configuration["max_iterations"] = 40 print(f"Function invocation configured as: \n{client.function_invocation_configuration}") diff --git a/python/samples/02-agents/tools/function_tool_declaration_only.py b/python/samples/02-agents/tools/function_tool_declaration_only.py index efa98d85bc..a8c4bd826e 100644 --- a/python/samples/02-agents/tools/function_tool_declaration_only.py +++ b/python/samples/02-agents/tools/function_tool_declaration_only.py @@ -3,7 +3,7 @@ import asyncio from agent_framework import Agent, FunctionTool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -26,7 +26,7 @@ async def main(): ) agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="DeclarationOnlyToolAgent", instructions="You are a helpful agent that uses tools.", tools=function_declaration, diff --git a/python/samples/02-agents/tools/function_tool_from_dict_with_dependency_injection.py b/python/samples/02-agents/tools/function_tool_from_dict_with_dependency_injection.py index 7f6b8896f0..83d2e34cf2 100644 --- a/python/samples/02-agents/tools/function_tool_from_dict_with_dependency_injection.py +++ b/python/samples/02-agents/tools/function_tool_from_dict_with_dependency_injection.py @@ -22,7 +22,7 @@ Usage: import asyncio from agent_framework import Agent, FunctionTool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -62,7 +62,7 @@ async def main() -> None: tool = FunctionTool.from_dict(definition, dependencies={"function_tool": {"name:add_numbers": {"func": func}}}) agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="FunctionToolAgent", instructions="You are a helpful assistant.", tools=tool, diff --git a/python/samples/02-agents/tools/function_tool_with_explicit_schema.py b/python/samples/02-agents/tools/function_tool_with_explicit_schema.py index 231a980b45..8090d4a2a0 100644 --- a/python/samples/02-agents/tools/function_tool_with_explicit_schema.py +++ b/python/samples/02-agents/tools/function_tool_with_explicit_schema.py @@ -18,7 +18,7 @@ import asyncio from typing import Annotated from agent_framework import Agent, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from pydantic import BaseModel, Field @@ -70,7 +70,7 @@ def get_current_time(timezone: str = "UTC") -> str: async def main(): agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="AssistantAgent", instructions="You are a helpful assistant. Use the available tools to answer questions.", tools=[get_weather, get_current_time], diff --git a/python/samples/02-agents/tools/function_tool_with_kwargs.py b/python/samples/02-agents/tools/function_tool_with_kwargs.py index 77664d6f8c..183ce0c999 100644 --- a/python/samples/02-agents/tools/function_tool_with_kwargs.py +++ b/python/samples/02-agents/tools/function_tool_with_kwargs.py @@ -4,7 +4,7 @@ import asyncio from typing import Annotated from agent_framework import Agent, FunctionInvocationContext, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from pydantic import Field @@ -44,7 +44,7 @@ def get_weather( async def main() -> None: agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="WeatherAgent", instructions="You are a helpful weather assistant.", tools=[get_weather], diff --git a/python/samples/02-agents/tools/function_tool_with_max_exceptions.py b/python/samples/02-agents/tools/function_tool_with_max_exceptions.py index b8ed2f5b58..4a40ba3cc0 100644 --- a/python/samples/02-agents/tools/function_tool_with_max_exceptions.py +++ b/python/samples/02-agents/tools/function_tool_with_max_exceptions.py @@ -4,7 +4,7 @@ import asyncio from typing import Annotated from agent_framework import Agent, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -36,7 +36,7 @@ def safe_divide( async def main(): # tools = Tools() agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="ToolAgent", instructions="Use the provided tools.", tools=[safe_divide], diff --git a/python/samples/02-agents/tools/function_tool_with_max_invocations.py b/python/samples/02-agents/tools/function_tool_with_max_invocations.py index 0cea02c23e..63d33df683 100644 --- a/python/samples/02-agents/tools/function_tool_with_max_invocations.py +++ b/python/samples/02-agents/tools/function_tool_with_max_invocations.py @@ -4,7 +4,7 @@ import asyncio from typing import Annotated from agent_framework import Agent, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -25,7 +25,7 @@ def unicorn_function(times: Annotated[int, "The number of unicorns to return."]) async def main(): # tools = Tools() agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="ToolAgent", instructions="Use the provided tools.", tools=[unicorn_function], diff --git a/python/samples/02-agents/tools/function_tool_with_session_injection.py b/python/samples/02-agents/tools/function_tool_with_session_injection.py index 21df5cc2c9..b1316227f1 100644 --- a/python/samples/02-agents/tools/function_tool_with_session_injection.py +++ b/python/samples/02-agents/tools/function_tool_with_session_injection.py @@ -4,7 +4,7 @@ import asyncio from typing import Annotated from agent_framework import Agent, AgentSession, FunctionInvocationContext, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv from pydantic import Field @@ -37,7 +37,7 @@ async def get_weather( async def main() -> None: agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="WeatherAgent", instructions="You are a helpful weather assistant.", tools=[get_weather], diff --git a/python/samples/02-agents/tools/tool_in_class.py b/python/samples/02-agents/tools/tool_in_class.py index 7a4c1051b7..c7b9e17321 100644 --- a/python/samples/02-agents/tools/tool_in_class.py +++ b/python/samples/02-agents/tools/tool_in_class.py @@ -4,7 +4,7 @@ import asyncio from typing import Annotated from agent_framework import Agent, tool -from agent_framework.openai import OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from dotenv import load_dotenv # Load environment variables from .env file @@ -50,7 +50,7 @@ async def main(): add_function = tool(description="Add two numbers.")(tools.add) agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), name="ToolAgent", instructions="Use the provided tools.", ) diff --git a/python/samples/03-workflows/README.md b/python/samples/03-workflows/README.md index 0e5dbaa9c0..8d95c90a59 100644 --- a/python/samples/03-workflows/README.md +++ b/python/samples/03-workflows/README.md @@ -160,17 +160,17 @@ Sequential orchestration uses a few small adapter nodes for plumbing: These may appear in event streams (executor_invoked/executor_completed). They're analogous to concurrent’s dispatcher and aggregator and can be ignored if you only care about agent activity. -### AzureOpenAIResponsesClient vs AzureAIAgent +### Why FoundryChatClient? -Workflow and orchestration samples use `AzureOpenAIResponsesClient` rather than the CRUD-style `AzureAIAgent` client. The key difference: +Workflow and orchestration samples use `FoundryChatClient` because they create agents locally and do not need +server-managed agent resources. This lightweight, project-backed chat client is a good fit for orchestration +patterns such as Sequential, Concurrent, Handoff, GroupChat, and Magentic. -- **`AzureOpenAIResponsesClient`** — A lightweight client that uses the underlying Agent Service V2 (Responses API) for non-CRUD-style agents. Orchestrations use this client because agents are created locally and do not require server-side lifecycle management (create/update/delete). This is the recommended client for orchestration patterns (Sequential, Concurrent, Handoff, GroupChat, Magentic). - -- **`AzureAIAgent`** — A CRUD-style client for server-managed agents. Use this when you need persistent, server-side agent definitions with features like file search, code interpreter sessions, or thread management provided by the Azure AI Agent Service. +If you need persistent server-side agent resources, use the hosted-agent flows rather than these workflow samples. ### Environment Variables -Workflow samples that use `AzureOpenAIResponsesClient` expect: +Workflow samples that use `FoundryChatClient` expect: - `FOUNDRY_PROJECT_ENDPOINT` (Azure AI Foundry Agent Service (V2) project endpoint) - `FOUNDRY_MODEL` (model deployment name) diff --git a/python/samples/03-workflows/declarative/function_tools/README.md b/python/samples/03-workflows/declarative/function_tools/README.md index e831ba51d7..f7468a7af1 100644 --- a/python/samples/03-workflows/declarative/function_tools/README.md +++ b/python/samples/03-workflows/declarative/function_tools/README.md @@ -6,7 +6,7 @@ This sample demonstrates an agent with function tools responding to user queries The workflow showcases: - **Function Tools**: Agent equipped with tools to query menu data -- **Real Azure OpenAI Agent**: Uses `AzureOpenAIResponsesClient` to create an agent with tools +- **Real Foundry-backed agent**: Uses `FoundryChatClient` to create an agent with tools - **Agent Registration**: Shows how to register agents with the `WorkflowFactory` ## Tools @@ -37,7 +37,7 @@ Drinks: ## Prerequisites -- Azure OpenAI configured with required environment variables +- Microsoft Foundry configured with required environment variables - Authentication via azure-identity (run `az login` before executing) ## Usage @@ -65,16 +65,16 @@ Session Complete ## How It Works -1. Create an Azure OpenAI chat client +1. Create a Foundry chat client 2. Create an agent with instructions and function tools 3. Register the agent with the workflow factory 4. Load the workflow YAML and run it with `run()` and `stream=True` ```python # Create the agent with tools -client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], +client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], credential=AzureCliCredential(), ) menu_agent = client.as_agent( diff --git a/python/samples/03-workflows/orchestrations/README.md b/python/samples/03-workflows/orchestrations/README.md index 527a414295..1f5f43c00f 100644 --- a/python/samples/03-workflows/orchestrations/README.md +++ b/python/samples/03-workflows/orchestrations/README.md @@ -92,15 +92,17 @@ from agent_framework.orchestrations import ( These may appear in event streams (executor_invoked/executor_completed). They're analogous to concurrent's dispatcher and aggregator and can be ignored if you only care about agent activity. -## Why AzureOpenAIResponsesClient? +## Why FoundryChatClient? -Orchestration samples use `AzureOpenAIResponsesClient` rather than the CRUD-style `AzureAIAgent` client. Orchestrations create agents locally and do not require server-side lifecycle management (create/update/delete). `AzureOpenAIResponsesClient` is a lightweight client that uses the underlying Agent Service V2 (Responses API) for non-CRUD-style agents, which is ideal for orchestration patterns like Sequential, Concurrent, Handoff, GroupChat, and Magentic. +Orchestration samples use `FoundryChatClient` because they create agents locally and do not require +server-side lifecycle management. `FoundryChatClient` is a lightweight, project-backed client that fits +patterns like Sequential, Concurrent, Handoff, GroupChat, and Magentic. ## Environment Variables -Orchestration samples that use `AzureOpenAIResponsesClient` expect: +Orchestration samples that use `FoundryChatClient` expect: -- `AZURE_AI_PROJECT_ENDPOINT` (Azure AI Foundry Agent Service (V2) project endpoint) -- `AZURE_AI_MODEL_DEPLOYMENT_NAME` (model deployment name) +- `FOUNDRY_PROJECT_ENDPOINT` (Azure AI Foundry Agent Service (V2) project endpoint) +- `FOUNDRY_MODEL` (model deployment name) These values are passed directly into the client constructor via `os.getenv()` in sample code. diff --git a/python/samples/04-hosting/a2a/README.md b/python/samples/04-hosting/a2a/README.md index 71cc9336c2..f377eed8ba 100644 --- a/python/samples/04-hosting/a2a/README.md +++ b/python/samples/04-hosting/a2a/README.md @@ -22,16 +22,16 @@ The remaining files are supporting modules used by the server: Make sure to set the following environment variables before running the examples: ### Required (Server) -- `AZURE_AI_PROJECT_ENDPOINT` — Your Azure AI Foundry project endpoint -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` — Model deployment name (e.g. `gpt-4o`) +- `FOUNDRY_PROJECT_ENDPOINT` — Your Azure AI Foundry project endpoint +- `FOUNDRY_MODEL` — Model deployment name (e.g. `gpt-4o`) ### Required (Client) - `A2A_AGENT_HOST` — URL of the A2A server (e.g. `http://localhost:5001/`) ### Required (Function Tools Sample) - `A2A_AGENT_HOST` — URL of the A2A server (e.g. `http://localhost:5000/`) -- `AZURE_AI_PROJECT_ENDPOINT` — Your Azure AI Foundry project endpoint -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` — Model deployment name (e.g. `gpt-4o`) +- `FOUNDRY_PROJECT_ENDPOINT` — Your Azure AI Foundry project endpoint +- `FOUNDRY_MODEL` — Model deployment name (e.g. `gpt-4o`) ## Quick Start @@ -65,7 +65,7 @@ uv run python agent_with_a2a.py ### 3. Run the Function Tools Sample This sample resolves the remote agent's skills and registers each one as a function tool -on a host OpenAI-powered agent. The host agent then autonomously selects the right skill +on a host Foundry-backed agent. The host agent then autonomously selects the right skill to handle the user's request. ```powershell diff --git a/python/samples/04-hosting/a2a/a2a_agent_as_function_tools.py b/python/samples/04-hosting/a2a/a2a_agent_as_function_tools.py index 60d8317725..c219166fc5 100644 --- a/python/samples/04-hosting/a2a/a2a_agent_as_function_tools.py +++ b/python/samples/04-hosting/a2a/a2a_agent_as_function_tools.py @@ -7,7 +7,7 @@ import re import httpx from a2a.client import A2ACardResolver from agent_framework.a2a import A2AAgent -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -29,8 +29,8 @@ Key concepts demonstrated: Prerequisites: - Set A2A_AGENT_HOST to the URL of a running A2A server -- Set AZURE_AI_PROJECT_ENDPOINT to your Azure AI Foundry project endpoint -- Set AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME to the model deployment name (e.g. gpt-4o) +- Set FOUNDRY_PROJECT_ENDPOINT to your Azure AI Foundry project endpoint +- Set FOUNDRY_MODEL to the model deployment name (e.g. gpt-4o) To run this sample: cd python/samples/04-hosting/a2a @@ -45,11 +45,11 @@ async def main() -> None: if not a2a_agent_host: raise ValueError("A2A_AGENT_HOST environment variable is not set") - project_endpoint = os.getenv("AZURE_AI_PROJECT_ENDPOINT") - deployment_name = os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME") - if not project_endpoint or not deployment_name: + project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT") + model = os.getenv("FOUNDRY_MODEL") + if not project_endpoint or not model: raise ValueError( - "AZURE_AI_PROJECT_ENDPOINT and AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME must be set" + "FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL must be set" ) print(f"Connecting to A2A agent at: {a2a_agent_host}") @@ -83,9 +83,9 @@ async def main() -> None: # 5. Create the host agent with the skill tools. credential = AzureCliCredential() - client = AzureOpenAIResponsesClient( + client = FoundryChatClient( project_endpoint=project_endpoint, - deployment_name=deployment_name, + model=model, credential=credential, ) host_agent = client.as_agent( diff --git a/python/samples/04-hosting/azure_functions/08_mcp_server/README.md b/python/samples/04-hosting/azure_functions/08_mcp_server/README.md index ec085a8582..71c963a25b 100644 --- a/python/samples/04-hosting/azure_functions/08_mcp_server/README.md +++ b/python/samples/04-hosting/azure_functions/08_mcp_server/README.md @@ -138,23 +138,35 @@ Expected response: The sample shows how to enable MCP tool triggers with flexible agent configuration: ```python -from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient +import os -# Create Azure OpenAI Chat Client -client = AzureOpenAIChatClient() +from agent_framework import Agent +from agent_framework.azure import AgentFunctionApp +from agent_framework.foundry import FoundryChatClient +from azure.identity.aio import AzureCliCredential + +# Create Foundry chat client +client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), +) # Define agents with different roles -joker_agent = client.as_agent( +joker_agent = Agent( + client=client, name="Joker", instructions="You are good at telling jokes.", ) -stock_agent = client.as_agent( +stock_agent = Agent( + client=client, name="StockAdvisor", instructions="Check stock prices.", ) -plant_agent = client.as_agent( +plant_agent = Agent( + client=client, name="PlantAdvisor", instructions="Recommend plants.", description="Get plant recommendations.", diff --git a/python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md b/python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md index 51e1c9fc1c..099c1b6c14 100644 --- a/python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md +++ b/python/samples/05-end-to-end/ag_ui_workflow_handoff/README.md @@ -27,8 +27,8 @@ The backend uses Azure OpenAI responses and supports intent-driven, non-linear h - Node.js 18+ - npm 9+ - Azure AI project + model deployment configured in environment variables: - - `AZURE_AI_PROJECT_ENDPOINT` - - `AZURE_AI_MODEL_DEPLOYMENT_NAME` + - `FOUNDRY_PROJECT_ENDPOINT` + - `FOUNDRY_MODEL` ## 1) Run Backend diff --git a/python/samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py b/python/samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py index 3a19b941fc..02329e8e16 100644 --- a/python/samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py +++ b/python/samples/05-end-to-end/ag_ui_workflow_handoff/backend/server.py @@ -85,7 +85,7 @@ def create_agents() -> tuple[Agent, Agent, Agent]: client = FoundryChatClient( project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + model=os.environ["FOUNDRY_MODEL"], credential=AzureCliCredential(), ) diff --git a/python/samples/05-end-to-end/chatkit-integration/README.md b/python/samples/05-end-to-end/chatkit-integration/README.md index 692145196e..365677eaad 100644 --- a/python/samples/05-end-to-end/chatkit-integration/README.md +++ b/python/samples/05-end-to-end/chatkit-integration/README.md @@ -177,7 +177,7 @@ pip install agent-framework-chatkit fastapi uvicorn azure-identity ```bash export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" export AZURE_OPENAI_API_VERSION="2024-06-01" -export AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="gpt-4o" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o" ``` 3. **Authenticate with Azure:** diff --git a/python/samples/05-end-to-end/evaluation/red_teaming/.env.example b/python/samples/05-end-to-end/evaluation/red_teaming/.env.example index c19da5af2a..d74003f39b 100644 --- a/python/samples/05-end-to-end/evaluation/red_teaming/.env.example +++ b/python/samples/05-end-to-end/evaluation/red_teaming/.env.example @@ -5,4 +5,4 @@ AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o # Azure AI Project Configuration (for red teaming) # Create these resources at: https://portal.azure.com -AZURE_AI_PROJECT_ENDPOINT=your-ai-project-name +FOUNDRY_PROJECT_ENDPOINT=your-ai-project-name diff --git a/python/samples/05-end-to-end/evaluation/red_teaming/README.md b/python/samples/05-end-to-end/evaluation/red_teaming/README.md index 9a9efaafe6..bdd3dfcb72 100644 --- a/python/samples/05-end-to-end/evaluation/red_teaming/README.md +++ b/python/samples/05-end-to-end/evaluation/red_teaming/README.md @@ -11,7 +11,7 @@ For more details on the Red Team setup see [the Azure AI Foundry docs](https://l A focused sample demonstrating Azure AI's RedTeam functionality to assess the safety and resilience of Agent Framework agents against adversarial attacks. **What it demonstrates:** -1. Creating a financial advisor agent inline using `AzureOpenAIChatClient` +1. Creating a financial advisor agent inline using `FoundryChatClient` 2. Setting up an async callback to interface the agent with RedTeam evaluator 3. Running comprehensive evaluations with 11 different attack strategies: - Basic: EASY and MODERATE difficulty levels @@ -47,7 +47,7 @@ AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4o # AZURE_OPENAI_API_KEY is optional if using Azure CLI authentication # Azure AI Project (for red teaming) -AZURE_AI_PROJECT_ENDPOINT=https://your-project.api.azureml.ms +FOUNDRY_PROJECT_ENDPOINT=https://your-project.api.azureml.ms ``` See `.env.example` for a template. @@ -113,7 +113,7 @@ async def main() -> None: credential = AzureCliCredential() # 2. Create agent inline - agent = AzureOpenAIChatClient(credential=credential).as_agent( + agent = FoundryChatClient(credential=credential).as_agent( model="gpt-4o", instructions="You are a helpful financial advisor..." ) @@ -125,7 +125,7 @@ async def main() -> None: # 4. Run red team scan with multiple strategies red_team = RedTeam( - azure_ai_project=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + azure_ai_project=os.environ["FOUNDRY_PROJECT_ENDPOINT"], credential=credential ) results = await red_team.scan( diff --git a/python/samples/05-end-to-end/hosted_agents/README.md b/python/samples/05-end-to-end/hosted_agents/README.md index 4f067ee4ab..c343fa66b2 100644 --- a/python/samples/05-end-to-end/hosted_agents/README.md +++ b/python/samples/05-end-to-end/hosted_agents/README.md @@ -10,7 +10,7 @@ These samples demonstrate how to build and host AI agents in Python using the [A | [`agent_with_text_search_rag`](./agent_with_text_search_rag/) | Retrieval-augmented generation using a custom `BaseContextProvider` with Contoso Outdoors sample data | | [`agents_in_workflow`](./agents_in_workflow/) | Concurrent workflow that combines researcher, marketer, and legal specialist agents | | [`agent_with_local_tools`](./agent_with_local_tools/) | Local Python tool execution for Seattle hotel search | -| [`writer_reviewer_agents_in_workflow`](./writer_reviewer_agents_in_workflow/) | Writer/Reviewer workflow using `AzureOpenAIResponsesClient` | +| [`writer_reviewer_agents_in_workflow`](./writer_reviewer_agents_in_workflow/) | Writer/Reviewer workflow using `FoundryChatClient` | ## Common Prerequisites @@ -76,14 +76,14 @@ Example `.env` for Azure OpenAI samples: ```dotenv AZURE_OPENAI_ENDPOINT=https://.openai.azure.com/ -AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=gpt-4.1 +AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4.1 ``` Example `.env` for Foundry project samples: ```dotenv -PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ -MODEL_DEPLOYMENT_NAME=gpt-4.1 +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +FOUNDRY_MODEL=gpt-4.1 ``` ## Interacting with the Agent diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/agent.yaml b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/agent.yaml index 5a0f58554d..ce59158393 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/agent.yaml +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/agent.yaml @@ -22,7 +22,7 @@ template: environment_variables: - name: AZURE_OPENAI_ENDPOINT value: ${AZURE_OPENAI_ENDPOINT} - - name: AZURE_OPENAI_CHAT_DEPLOYMENT_NAME + - name: AZURE_OPENAI_DEPLOYMENT_NAME value: "{{chat}}" resources: - kind: model diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/agent.yaml b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/agent.yaml index 1e23818b0f..a54917bba5 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/agent.yaml +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/agent.yaml @@ -25,7 +25,7 @@ template: environment_variables: - name: AZURE_OPENAI_ENDPOINT value: ${AZURE_OPENAI_ENDPOINT} - - name: AZURE_OPENAI_CHAT_DEPLOYMENT_NAME + - name: AZURE_OPENAI_DEPLOYMENT_NAME value: "{{chat}}" resources: - kind: model diff --git a/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/agent.yaml b/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/agent.yaml index 584b462a40..bd2824276a 100644 --- a/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/agent.yaml +++ b/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/agent.yaml @@ -20,7 +20,7 @@ template: environment_variables: - name: AZURE_OPENAI_ENDPOINT value: ${AZURE_OPENAI_ENDPOINT} - - name: AZURE_OPENAI_CHAT_DEPLOYMENT_NAME + - name: AZURE_OPENAI_DEPLOYMENT_NAME value: "{{chat}}" resources: - kind: model diff --git a/python/samples/05-end-to-end/workflow_evaluation/.env.example b/python/samples/05-end-to-end/workflow_evaluation/.env.example index b7a06ab22a..57f0415e8c 100644 --- a/python/samples/05-end-to-end/workflow_evaluation/.env.example +++ b/python/samples/05-end-to-end/workflow_evaluation/.env.example @@ -1,3 +1,3 @@ -AZURE_AI_PROJECT_ENDPOINT="" -AZURE_AI_MODEL_DEPLOYMENT_NAME_WORKFLOW="" -AZURE_AI_MODEL_DEPLOYMENT_NAME_EVAL="" +FOUNDRY_PROJECT_ENDPOINT="" +FOUNDRY_MODEL_WORKFLOW="" +FOUNDRY_MODEL_EVAL="" diff --git a/python/samples/05-end-to-end/workflow_evaluation/run_evaluation.py b/python/samples/05-end-to-end/workflow_evaluation/run_evaluation.py index 2ac8b90cb7..f4bc8ebb85 100644 --- a/python/samples/05-end-to-end/workflow_evaluation/run_evaluation.py +++ b/python/samples/05-end-to-end/workflow_evaluation/run_evaluation.py @@ -99,7 +99,7 @@ def fetch_agent_responses(openai_client: OpenAI, workflow_data: dict[str, Any], def create_evaluation(openai_client: OpenAI, deployment_name: str | None = "gpt-5.2") -> EvalCreateResponse: """Create evaluation with multiple evaluators.""" - deployment_name = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", deployment_name) + deployment_name = os.environ.get("FOUNDRY_MODEL", deployment_name) data_source_config = {"type": "azure_ai_source", "scenario": "responses"} testing_criteria = [ @@ -199,8 +199,8 @@ async def main(): openai_client = create_openai_client() # Model configuration - workflow_agent_model = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME_WORKFLOW", "gpt-4.1-nano") - eval_model = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME_EVAL", "gpt-5.2") + workflow_agent_model = os.environ.get("FOUNDRY_MODEL_WORKFLOW", "gpt-4.1-nano") + eval_model = os.environ.get("FOUNDRY_MODEL_EVAL", "gpt-5.2") # Focus on these agents, uncomment other ones you want to have evals run on agents_to_evaluate = [ diff --git a/python/samples/AGENTS.md b/python/samples/AGENTS.md index 09674da7d9..2a893accf6 100644 --- a/python/samples/AGENTS.md +++ b/python/samples/AGENTS.md @@ -66,26 +66,26 @@ python/samples/ ## Default provider -All canonical samples (01-get-started) use **Azure OpenAI Responses** via `AzureOpenAIResponsesClient` +All canonical samples (01-get-started) use **Azure AI Foundry project-backed chat** via `FoundryChatClient` with an Azure AI Foundry project endpoint: ```python import os -from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.foundry import FoundryChatClient from azure.identity import AzureCliCredential credential = AzureCliCredential() -client = AzureOpenAIResponsesClient( - project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], - deployment_name=os.environ["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], +client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], credential=credential, ) agent = client.as_agent(name="...", instructions="...") ``` Environment variables: -- `AZURE_AI_PROJECT_ENDPOINT` — Your Azure AI Foundry project endpoint -- `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` — Model deployment name (e.g. gpt-4o) +- `FOUNDRY_PROJECT_ENDPOINT` — Your Azure AI Foundry project endpoint +- `FOUNDRY_MODEL` — Model deployment name (e.g. gpt-4o) For authentication, run `az login` before running samples. diff --git a/python/samples/README.md b/python/samples/README.md index 33a385a4f9..4e178c2a78 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -50,7 +50,7 @@ export FOUNDRY_MODEL="gpt-4o" **Option 3: Using `env_file_path` parameter** (for per-client configuration): -All client classes (e.g., `OpenAIChatClient`, `AzureOpenAIResponsesClient`) support an `env_file_path` parameter to load environment variables from a specific file: +All client classes (e.g., `OpenAIChatClient`, `OpenAIChatCompletionClient`) support an `env_file_path` parameter to load environment variables from a specific file: ```python from agent_framework.openai import OpenAIChatClient @@ -77,6 +77,92 @@ FOUNDRY_PROJECT_ENDPOINT="your-foundry-project-endpoint" FOUNDRY_MODEL="gpt-4o" ``` +#### Consolidated sample env inventory + +This is the single source of truth for package-level environment variables read by packages included by +`agent-framework-core[all]`. It intentionally excludes variables that are only read by standalone samples, +package sample folders, or tests. When package code adds, removes, or renames an environment variable, +update this table in the same change. + +Example values below are illustrative. For entries not backed by a single public class, the `class` +column names the closest public surface, helper, or package-level initialization point that reads the +variable. + +| package | class | env var | example value | +| --- | --- | --- | --- | +| `agent-framework-anthropic` | `AnthropicClient` | `ANTHROPIC_API_KEY` | `sk-ant-api03-...` | +| `agent-framework-anthropic` | `AnthropicClient` | `ANTHROPIC_CHAT_MODEL_ID` | `claude-sonnet-4-5-20250929` | +| `agent-framework-azure-ai` | `AzureAIInferenceEmbeddingClient` | `AZURE_AI_INFERENCE_ENDPOINT` | `https://my-endpoint.inference.ai.azure.com` | +| `agent-framework-azure-ai` | `AzureAIInferenceEmbeddingClient` | `AZURE_AI_INFERENCE_API_KEY` | `env-key` | +| `agent-framework-azure-ai` | `AzureAIInferenceEmbeddingClient` | `AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID` | `text-embedding-3-small` | +| `agent-framework-azure-ai` | `AzureAIInferenceEmbeddingClient` | `AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID` | `Cohere-embed-v3-english` | +| `agent-framework-azure-ai-search` | `AzureAISearchContextProvider` | `AZURE_SEARCH_ENDPOINT` | `https://my-search.search.windows.net` | +| `agent-framework-azure-ai-search` | `AzureAISearchContextProvider` | `AZURE_SEARCH_API_KEY` | `search-key` | +| `agent-framework-azure-ai-search` | `AzureAISearchContextProvider` | `AZURE_SEARCH_INDEX_NAME` | `hotels-index` | +| `agent-framework-azure-ai-search` | `AzureAISearchContextProvider` | `AZURE_SEARCH_KNOWLEDGE_BASE_NAME` | `hotels-kb` | +| `agent-framework-azure-cosmos` | `CosmosHistoryProvider` | `AZURE_COSMOS_ENDPOINT` | `https://my-cosmos.documents.azure.com:443/` | +| `agent-framework-azure-cosmos` | `CosmosHistoryProvider` | `AZURE_COSMOS_DATABASE_NAME` | `agent-history` | +| `agent-framework-azure-cosmos` | `CosmosHistoryProvider` | `AZURE_COSMOS_CONTAINER_NAME` | `messages` | +| `agent-framework-azure-cosmos` | `CosmosHistoryProvider` | `AZURE_COSMOS_KEY` | `C2F...==` | +| `agent-framework-bedrock` | `BedrockChatClient` | `BEDROCK_REGION` | `us-east-1` | +| `agent-framework-bedrock` | `BedrockChatClient` | `BEDROCK_CHAT_MODEL_ID` | `anthropic.claude-3-5-sonnet-20241022-v2:0` | +| `agent-framework-bedrock` | `BedrockEmbeddingClient` | `BEDROCK_REGION` | `us-east-1` | +| `agent-framework-bedrock` | `BedrockEmbeddingClient` | `BEDROCK_EMBEDDING_MODEL_ID` | `amazon.titan-embed-text-v2:0` | +| `agent-framework-bedrock` | `BedrockChatClient / BedrockEmbeddingClient` | `AWS_ACCESS_KEY_ID` | `AKIAIOSFODNN7EXAMPLE` | +| `agent-framework-bedrock` | `BedrockChatClient / BedrockEmbeddingClient` | `AWS_SECRET_ACCESS_KEY` | `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` | +| `agent-framework-bedrock` | `BedrockChatClient / BedrockEmbeddingClient` | `AWS_SESSION_TOKEN` | `IQoJb3JpZ2luX2VjEO7//////////wEaCXVzLXdlc3QtMiJHMEUCIQD...` | +| `agent-framework-copilotstudio` | `CopilotStudioAgent` | `COPILOTSTUDIOAGENT__ENVIRONMENTID` | `00000000-0000-0000-0000-000000000000` | +| `agent-framework-copilotstudio` | `CopilotStudioAgent` | `COPILOTSTUDIOAGENT__SCHEMANAME` | `cr123_agentname` | +| `agent-framework-copilotstudio` | `CopilotStudioAgent` | `COPILOTSTUDIOAGENT__TENANTID` | `11111111-1111-1111-1111-111111111111` | +| `agent-framework-copilotstudio` | `CopilotStudioAgent` | `COPILOTSTUDIOAGENT__AGENTAPPID` | `22222222-2222-2222-2222-222222222222` | +| `agent-framework-core` | `enable_instrumentation()` | `ENABLE_INSTRUMENTATION` | `true` | +| `agent-framework-core` | `enable_instrumentation()` | `ENABLE_SENSITIVE_DATA` | `false` | +| `agent-framework-core` | `enable_instrumentation()` | `ENABLE_CONSOLE_EXPORTERS` | `true` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4317` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | `http://localhost:4318/v1/traces` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | `http://localhost:4318/v1/metrics` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | `http://localhost:4318/v1/logs` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_HEADERS` | `api-key=demo` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | `api-key=trace-demo` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_METRICS_HEADERS` | `api-key=metric-demo` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_EXPORTER_OTLP_LOGS_HEADERS` | `api-key=log-demo` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_SERVICE_NAME` | `sample-agent` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_SERVICE_VERSION` | `1.0.0` | +| `agent-framework-core` | `enable_instrumentation()` | `OTEL_RESOURCE_ATTRIBUTES` | `deployment.environment=dev,service.namespace=agent-framework` | +| `agent-framework-devui` | `DevUI server` | `DEVUI_AUTH_TOKEN` | `my-devui-token` | +| `agent-framework-foundry` | `FoundryChatClient` | `FOUNDRY_PROJECT_ENDPOINT` | `https://my-project.services.ai.azure.com/api/projects/my-project` | +| `agent-framework-foundry` | `FoundryChatClient` | `FOUNDRY_MODEL` | `gpt-4o` | +| `agent-framework-foundry` | `FoundryAgent` | `FOUNDRY_AGENT_NAME` | `travel-planner` | +| `agent-framework-foundry` | `FoundryAgent` | `FOUNDRY_AGENT_VERSION` | `v1` | +| `agent-framework-github-copilot` | `GitHubCopilotAgent` | `GITHUB_COPILOT_CLI_PATH` | `copilot` | +| `agent-framework-github-copilot` | `GitHubCopilotAgent` | `GITHUB_COPILOT_MODEL` | `gpt-5` | +| `agent-framework-github-copilot` | `GitHubCopilotAgent` | `GITHUB_COPILOT_TIMEOUT` | `60` | +| `agent-framework-github-copilot` | `GitHubCopilotAgent` | `GITHUB_COPILOT_LOG_LEVEL` | `info` | +| `agent-framework-mem0` | `agent_framework_mem0 package import` | `MEM0_TELEMETRY` | `false` | +| `agent-framework-ollama` | `OllamaChatClient` | `OLLAMA_HOST` | `http://localhost:11434` | +| `agent-framework-ollama` | `OllamaChatClient` | `OLLAMA_MODEL_ID` | `llama3.1:8b` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `OPENAI_API_KEY` | `sk-proj-...` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `OPENAI_MODEL` | `gpt-4o-mini` | +| `agent-framework-openai` | `OpenAIChatClient` | `OPENAI_RESPONSES_MODEL` | `gpt-4.1-mini` | +| `agent-framework-openai` | `OpenAIChatCompletionClient` | `OPENAI_CHAT_MODEL` | `gpt-4o` | +| `agent-framework-openai` | `OpenAIEmbeddingClient` | `OPENAI_EMBEDDING_MODEL` | `text-embedding-3-small` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `OPENAI_BASE_URL` | `https://api.openai.com/v1/` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `OPENAI_ORG_ID` | `org_123456789` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `AZURE_OPENAI_ENDPOINT` | `https://my-resource.openai.azure.com/` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `AZURE_OPENAI_API_KEY` | `sk-azure-...` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `AZURE_OPENAI_API_VERSION` | `2024-10-21` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `AZURE_OPENAI_BASE_URL` | `https://my-resource.openai.azure.com/openai/v1/` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `AZURE_OPENAI_DEPLOYMENT_NAME` | `gpt-4o` | +| `agent-framework-openai` | `OpenAIChatClient` | `AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME` | `gpt-4.1` | +| `agent-framework-openai` | `OpenAIChatCompletionClient` | `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` | `gpt-4o-mini` | +| `agent-framework-openai` | `OpenAIEmbeddingClient` | `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME` | `text-embedding-3-large` | +| `agent-framework-openai` | `OpenAIChatClient / OpenAIChatCompletionClient / OpenAIEmbeddingClient` | `AZURE_OPENAI_RESOURCE_URL` | `https://cognitiveservices.azure.com/` | + +`agent-framework-openai` supports the Azure OpenAI client-specific deployment aliases listed above; keep +`packages/openai/README.md` as the authoritative reference for the exact fallback order and package-specific +behavior. + **Note for production**: In production environments, set environment variables through your deployment platform (e.g., Azure App Settings, Kubernetes ConfigMaps/Secrets) rather than using `.env` files. The `load_dotenv()` call in samples will have no effect when a `.env` file is not present, allowing environment variables to be loaded from the system. For Azure authentication, run `az login` before running samples. diff --git a/python/samples/semantic-kernel-migration/README.md b/python/samples/semantic-kernel-migration/README.md index f7da9de9c5..f971c4c34c 100644 --- a/python/samples/semantic-kernel-migration/README.md +++ b/python/samples/semantic-kernel-migration/README.md @@ -14,9 +14,9 @@ This gallery helps Semantic Kernel (SK) developers move to the Microsoft Agent F ### Azure AI agent parity ### OpenAI Assistants API parity -- [01_basic_openai_assistant.py](openai_assistant/01_basic_openai_assistant.py) — Baseline assistant comparison. -- [02_openai_assistant_with_code_interpreter.py](openai_assistant/02_openai_assistant_with_code_interpreter.py) — Code interpreter tool usage. -- [03_openai_assistant_function_tool.py](openai_assistant/03_openai_assistant_function_tool.py) — Custom function tooling. + +OpenAI Assistants parity samples were removed alongside the deprecated Python assistants surface and are no longer +part of this migration gallery. ### OpenAI Responses API parity - [01_basic_responses_agent.py](openai_responses/01_basic_responses_agent.py) — Basic responses agent migration. @@ -44,7 +44,7 @@ Each script is fully async and the `main()` routine runs both implementations ba - Python 3.10 or later. - Access to the necessary model endpoints (Azure OpenAI, OpenAI, Azure AI, Copilot Studio, etc.). - Installed SDKs: `semantic-kernel` and the Microsoft Agent Framework (`pip install semantic-kernel agent-framework`), or the repo’s editable packages if you are developing locally. -- Service credentials exposed through environment variables (for example `OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_KEY`, or Copilot Studio auth settings). +- Service credentials exposed through environment variables (for example `OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, or Copilot Studio auth settings). ## Running Single-Agent Samples From the repository root: diff --git a/python/samples/semantic-kernel-migration/openai_assistant/01_basic_openai_assistant.py b/python/samples/semantic-kernel-migration/openai_assistant/01_basic_openai_assistant.py deleted file mode 100644 index f171d076bd..0000000000 --- a/python/samples/semantic-kernel-migration/openai_assistant/01_basic_openai_assistant.py +++ /dev/null @@ -1,64 +0,0 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "semantic-kernel", -# ] -# /// -# Run with any PEP 723 compatible runner, e.g.: -# uv run samples/semantic-kernel-migration/openai_assistant/01_basic_openai_assistant.py - -# Copyright (c) Microsoft. All rights reserved. -"""Create an OpenAI Assistant using SK and Agent Framework.""" -import asyncio -import os - -from agent_framework import Agent -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() -ASSISTANT_MODEL = os.environ.get("OPENAI_ASSISTANT_MODEL", "gpt-4o-mini") - - -async def run_semantic_kernel() -> None: - from semantic_kernel.agents import AssistantAgentThread, OpenAIAssistantAgent - client = OpenAIAssistantAgent.create_client() - # Provision the assistant on the OpenAI Assistants service. - definition = await client.beta.assistants.create( - model=ASSISTANT_MODEL, - name="Helper", - instructions="Answer questions in one concise paragraph.", - ) - agent = OpenAIAssistantAgent(client=client, definition=definition) - thread: AssistantAgentThread | None = None - response = await agent.get_response("What is the capital of Denmark?", thread=thread) - thread = response.thread - print("[SK]", response.message.content) - if thread is not None: - print("[SK][thread-id]", thread.id) - - -async def run_agent_framework() -> None: - from agent_framework.openai import OpenAIAssistantsClient - assistants_client = OpenAIAssistantsClient() - # AF wraps the assistant lifecycle with an async context manager. - async with Agent( - client=assistants_client, - ) as assistant_agent: - session = assistant_agent.create_session() - reply = await assistant_agent.run("What is the capital of Denmark?", session=session) - print("[AF]", reply.text) - follow_up = await assistant_agent.run( - "How many residents live there?", - session=session, - ) - print("[AF][follow-up]", follow_up.text) - - -async def main() -> None: - await run_semantic_kernel() - await run_agent_framework() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/semantic-kernel-migration/openai_assistant/02_openai_assistant_with_code_interpreter.py b/python/samples/semantic-kernel-migration/openai_assistant/02_openai_assistant_with_code_interpreter.py deleted file mode 100644 index f968f7851e..0000000000 --- a/python/samples/semantic-kernel-migration/openai_assistant/02_openai_assistant_with_code_interpreter.py +++ /dev/null @@ -1,74 +0,0 @@ -# /// script - -# requires-python = ">=3.10" -# dependencies = [ -# "semantic-kernel", -# ] -# /// -# Run with any PEP 723 compatible runner, e.g.: -# uv run samples/semantic-kernel-migration/openai_assistant/02_openai_assistant_with_code_interpreter.py - -# Copyright (c) Microsoft. All rights reserved. -"""Enable the code interpreter tool for OpenAI Assistants in SK and AF.""" - -import asyncio - -from agent_framework import Agent -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - - -async def run_semantic_kernel() -> None: - from semantic_kernel.agents import OpenAIAssistantAgent - from semantic_kernel.connectors.ai.open_ai import OpenAISettings - - client = OpenAIAssistantAgent.create_client() - - code_interpreter_tool, code_interpreter_tool_resources = OpenAIAssistantAgent.configure_code_interpreter_tool() - - # Enable the hosted code interpreter tool on the assistant definition. - definition = await client.beta.assistants.create( - model=OpenAISettings().chat_model_id, - name="CodeRunner", - instructions="Run the provided request as code and return the result.", - tools=code_interpreter_tool, - tool_resources=code_interpreter_tool_resources, - ) - agent = OpenAIAssistantAgent(client=client, definition=definition) - response = await agent.get_response( - "Use Python to calculate the mean of [41, 42, 45] and explain the steps.", - ) - print(f"[SK]: {response}") - - -async def run_agent_framework() -> None: - from agent_framework.openai import OpenAIAssistantsClient - - assistants_client = OpenAIAssistantsClient() - - # Create code interpreter tool using static method - code_interpreter_tool = OpenAIAssistantsClient.get_code_interpreter_tool() - - # AF exposes the same tool configuration via create_agent. - async with Agent(client=assistants_client, - name="CodeRunner", - instructions="Use the code interpreter when calculations are required.", - model="gpt-4.1", - tools=[code_interpreter_tool], - ) as assistant_agent: - response = await assistant_agent.run( - "Use Python to calculate the mean of [41, 42, 45] and explain the steps.", - tool_choice="auto", - ) - print(f"[AF]: {response.text}") - - -async def main() -> None: - await run_semantic_kernel() - await run_agent_framework() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/semantic-kernel-migration/openai_assistant/03_openai_assistant_function_tool.py b/python/samples/semantic-kernel-migration/openai_assistant/03_openai_assistant_function_tool.py deleted file mode 100644 index 36d6fea208..0000000000 --- a/python/samples/semantic-kernel-migration/openai_assistant/03_openai_assistant_function_tool.py +++ /dev/null @@ -1,103 +0,0 @@ -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "semantic-kernel", -# ] -# /// -# Run with any PEP 723 compatible runner, e.g.: -# uv run samples/semantic-kernel-migration/openai_assistant/03_openai_assistant_function_tool.py - -# Copyright (c) Microsoft. All rights reserved. -"""Implement a function tool for OpenAI Assistants in SK and AF.""" - -import asyncio -import os -from typing import Any - -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - -ASSISTANT_MODEL = os.environ.get("OPENAI_ASSISTANT_MODEL", "gpt-4o-mini") - - -async def fake_weather_lookup(city: str, day: str) -> dict[str, Any]: - """Pretend to call a weather service.""" - - return { - "city": city, - "day": day, - "forecast": "Sunny with scattered clouds", - "high_c": 22, - "low_c": 14, - } - - -async def run_semantic_kernel() -> None: - from semantic_kernel.agents import AssistantAgentThread, OpenAIAssistantAgent - from semantic_kernel.functions import kernel_function - - class WeatherPlugin: - @kernel_function(name="get_forecast", description="Look up the forecast for a city and day.") - async def fake_weather_lookup(self, city: str, day: str) -> dict[str, Any]: - """Pretend to call a weather service.""" - return { - "city": city, - "day": day, - "forecast": "Sunny with scattered clouds", - "high_c": 22, - "low_c": 14, - } - - client = OpenAIAssistantAgent.create_client() - # Tool schema is registered on the assistant definition. - definition = await client.beta.assistants.create( - model=ASSISTANT_MODEL, - name="WeatherHelper", - instructions="Call get_forecast to fetch weather details.", - ) - agent = OpenAIAssistantAgent(client=client, definition=definition, plugins=[WeatherPlugin()]) - - thread: AssistantAgentThread | None = None - response = await agent.get_response( - "What will the weather be like in Seattle tomorrow?", - thread=thread, - ) - thread = response.thread - print("[SK][initial]", response.message.content) - - -async def run_agent_framework() -> None: - from agent_framework import Agent, tool - from agent_framework.openai import OpenAIAssistantsClient - - @tool( - name="get_forecast", - description="Look up the forecast for a city and day.", - ) - async def get_forecast(city: str, day: str) -> dict[str, Any]: - return await fake_weather_lookup(city, day) - - assistants_client = OpenAIAssistantsClient() - # AF converts the decorated function into an assistant-compatible tool. - async with Agent(client=assistants_client, - name="WeatherHelper", - instructions="Call get_forecast to fetch weather details.", - model=ASSISTANT_MODEL, - tools=[get_forecast], - ) as assistant_agent: - reply = await assistant_agent.run( - "What will the weather be like in Seattle tomorrow?", - tool_choice="auto", - ) - print("[AF]", reply.text) - - -async def main() -> None: - await run_semantic_kernel() - await run_agent_framework() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py b/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py index 54994d7f1f..036d793c3e 100644 --- a/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py +++ b/python/samples/semantic-kernel-migration/openai_responses/01_basic_responses_agent.py @@ -36,11 +36,11 @@ async def run_semantic_kernel() -> None: async def run_agent_framework() -> None: from agent_framework import Agent - from agent_framework.openai import OpenAIResponsesClient + from agent_framework.openai import OpenAIChatClient - # AF Agent can swap in an OpenAIResponsesClient directly. + # AF Agent can swap in an OpenAIChatClient directly. chat_agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions="Answer in one concise sentence.", name="Expert", ) diff --git a/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py b/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py index d2855a7810..4145d74e31 100644 --- a/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py +++ b/python/samples/semantic-kernel-migration/openai_responses/02_responses_agent_with_tool.py @@ -43,14 +43,14 @@ async def run_semantic_kernel() -> None: async def run_agent_framework() -> None: from agent_framework import Agent, tool - from agent_framework.openai import OpenAIResponsesClient + from agent_framework.openai import OpenAIChatClient @tool(name="add", description="Add two numbers") async def add(a: float, b: float) -> float: return a + b chat_agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions="Use the add tool when math is required.", name="MathExpert", # AF registers the async function as a tool at construction. diff --git a/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py b/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py index a4328ce05f..e1caec599c 100644 --- a/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py +++ b/python/samples/semantic-kernel-migration/openai_responses/03_responses_agent_structured_output.py @@ -46,10 +46,10 @@ async def run_semantic_kernel() -> None: async def run_agent_framework() -> None: from agent_framework import Agent - from agent_framework.openai import OpenAIResponsesClient + from agent_framework.openai import OpenAIChatClient chat_agent = Agent( - client=OpenAIResponsesClient(), + client=OpenAIChatClient(), instructions="Return launch briefs as structured JSON.", name="ProductMarketer", ) diff --git a/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py b/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py index ed0a4b1495..fb60da9f3d 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py +++ b/python/samples/semantic-kernel-migration/orchestrations/concurrent_basic.py @@ -16,7 +16,7 @@ from collections.abc import Sequence from typing import cast from agent_framework import Agent, Message -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.orchestrations import ConcurrentBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -89,7 +89,7 @@ def _print_semantic_kernel_outputs(outputs: Sequence[ChatMessageContent]) -> Non async def run_agent_framework_example(prompt: str) -> Sequence[list[Message]]: - client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = OpenAIChatCompletionClient(credential=AzureCliCredential()) physics = Agent(client=client, instructions=("You are an expert in physics. Answer questions from a physics perspective."), diff --git a/python/samples/semantic-kernel-migration/orchestrations/magentic.py b/python/samples/semantic-kernel-migration/orchestrations/magentic.py index 2594d15f89..314ce04271 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/magentic.py +++ b/python/samples/semantic-kernel-migration/orchestrations/magentic.py @@ -15,7 +15,7 @@ import asyncio from collections.abc import Sequence from agent_framework import Agent -from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient +from agent_framework.openai import OpenAIChatClient from agent_framework.orchestrations import MagenticBuilder from dotenv import load_dotenv from semantic_kernel.agents import ( @@ -141,8 +141,8 @@ async def run_agent_framework_example(prompt: str) -> str | None: ) # Create code interpreter tool using static method - coder_client = OpenAIResponsesClient() - code_interpreter_tool = OpenAIResponsesClient.get_code_interpreter_tool() + coder_client = OpenAIChatClient() + code_interpreter_tool = OpenAIChatClient.get_code_interpreter_tool() coder = Agent( name="CoderAgent", diff --git a/python/samples/semantic-kernel-migration/orchestrations/sequential.py b/python/samples/semantic-kernel-migration/orchestrations/sequential.py index fd9794fdfe..51a6eb78cc 100644 --- a/python/samples/semantic-kernel-migration/orchestrations/sequential.py +++ b/python/samples/semantic-kernel-migration/orchestrations/sequential.py @@ -16,7 +16,7 @@ from collections.abc import Sequence from typing import cast from agent_framework import Agent, Message -from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.openai import OpenAIChatCompletionClient from agent_framework.orchestrations import SequentialBuilder from azure.identity import AzureCliCredential from dotenv import load_dotenv @@ -76,7 +76,7 @@ async def sk_agent_response_callback( async def run_agent_framework_example(prompt: str) -> list[Message]: - client = AzureOpenAIChatClient(credential=AzureCliCredential()) + client = OpenAIChatCompletionClient(credential=AzureCliCredential()) writer = Agent(client=client, instructions=("You are a concise copywriter. Provide a single, punchy marketing sentence based on the prompt."), diff --git a/python/uv.lock b/python/uv.lock index 590a78c791..23d3297009 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -671,14 +671,12 @@ source = { editable = "packages/openai" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, { name = "openai", specifier = ">=1.99.0,<3" }, - { name = "packaging", specifier = ">=24.1,<25" }, ] [[package]]