mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into dev/dotnet_workflow/remove_timeout
This commit is contained in:
@@ -126,8 +126,6 @@ jobs:
|
|||||||
packages/openai/tests/openai/test_openai_chat_completion_client_azure.py
|
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_chat_client_azure.py
|
||||||
packages/openai/tests/openai/test_openai_embedding_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
|
-m integration
|
||||||
-n logical --dist worksteal
|
-n logical --dist worksteal
|
||||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||||
@@ -288,7 +286,6 @@ jobs:
|
|||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
run: >
|
run: >
|
||||||
uv run pytest --import-mode=importlib
|
uv run pytest --import-mode=importlib
|
||||||
packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py
|
|
||||||
packages/foundry/tests
|
packages/foundry/tests
|
||||||
-m integration
|
-m integration
|
||||||
-n logical --dist worksteal
|
-n logical --dist worksteal
|
||||||
|
|||||||
@@ -62,9 +62,7 @@ jobs:
|
|||||||
azure:
|
azure:
|
||||||
- 'python/packages/openai/**'
|
- 'python/packages/openai/**'
|
||||||
- 'python/packages/core/agent_framework/azure/**'
|
- 'python/packages/core/agent_framework/azure/**'
|
||||||
- 'python/packages/azure-ai/agent_framework_azure_ai/_deprecated_azure_openai.py'
|
- 'python/samples/**/providers/azure/**'
|
||||||
- 'python/packages/azure-ai/tests/azure_openai/**'
|
|
||||||
- 'python/samples/**/providers/azure/openai_chat_completion_client_azure*.py'
|
|
||||||
misc:
|
misc:
|
||||||
- 'python/packages/anthropic/**'
|
- 'python/packages/anthropic/**'
|
||||||
- 'python/packages/ollama/**'
|
- '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_completion_client_azure.py
|
||||||
packages/openai/tests/openai/test_openai_chat_client_azure.py
|
packages/openai/tests/openai/test_openai_chat_client_azure.py
|
||||||
packages/openai/tests/openai/test_openai_embedding_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
|
-m integration
|
||||||
-n logical --dist worksteal
|
-n logical --dist worksteal
|
||||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||||
@@ -430,7 +426,6 @@ jobs:
|
|||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
run: >
|
run: >
|
||||||
uv run pytest --import-mode=importlib
|
uv run pytest --import-mode=importlib
|
||||||
packages/azure-ai/tests/azure_openai/test_azure_responses_client_foundry.py
|
|
||||||
packages/foundry/tests
|
packages/foundry/tests
|
||||||
-m integration
|
-m integration
|
||||||
-n logical --dist worksteal
|
-n logical --dist worksteal
|
||||||
|
|||||||
@@ -23,10 +23,8 @@ jobs:
|
|||||||
environment: integration
|
environment: integration
|
||||||
env:
|
env:
|
||||||
# Required configuration for get-started samples
|
# Required configuration for get-started samples
|
||||||
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
|
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||||
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
|
||||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: python
|
working-directory: python
|
||||||
@@ -43,10 +41,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Create .env for samples
|
- name: Create .env for samples
|
||||||
run: |
|
run: |
|
||||||
echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env
|
echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env
|
||||||
echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .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
|
|
||||||
|
|
||||||
- name: Run sample validation
|
- name: Run sample validation
|
||||||
run: |
|
run: |
|
||||||
@@ -64,16 +60,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: integration
|
environment: integration
|
||||||
env:
|
env:
|
||||||
# Azure AI configuration
|
# Foundry configuration
|
||||||
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
|
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
|
||||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
|
||||||
# Azure OpenAI configuration
|
# Azure OpenAI configuration
|
||||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
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 }}
|
||||||
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME || vars.AZUREOPENAI__EMBEDDINGDEPLOYMENTNAME }}
|
||||||
AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }}
|
|
||||||
# OpenAI configuration
|
# OpenAI configuration
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||||
@@ -101,11 +94,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env
|
echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env
|
||||||
echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .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_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 "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env
|
|
||||||
echo "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME=$AZURE_OPENAI_EMBEDDING_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_API_KEY=$OPENAI_API_KEY" >> .env
|
||||||
echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env
|
echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env
|
||||||
@@ -169,10 +159,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: integration
|
environment: integration
|
||||||
env:
|
env:
|
||||||
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
|
|
||||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
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 }}
|
||||||
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
AZURE_OPENAI_API_VERSION: ${{ vars.AZURE_OPENAI_API_VERSION || '' }}
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: python
|
working-directory: python
|
||||||
@@ -189,10 +178,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Create .env for samples
|
- name: Create .env for samples
|
||||||
run: |
|
run: |
|
||||||
echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env
|
|
||||||
echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_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_DEPLOYMENT_NAME=$AZURE_OPENAI_DEPLOYMENT_NAME" >> .env
|
||||||
echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env
|
echo "AZURE_OPENAI_API_VERSION=$AZURE_OPENAI_API_VERSION" >> .env
|
||||||
|
|
||||||
- name: Run sample validation
|
- name: Run sample validation
|
||||||
run: |
|
run: |
|
||||||
@@ -337,11 +325,14 @@ jobs:
|
|||||||
|
|
||||||
validate-02-agents-foundry:
|
validate-02-agents-foundry:
|
||||||
name: Validate 02-agents/providers/foundry
|
name: Validate 02-agents/providers/foundry
|
||||||
|
if: false # Temporarily disabled - provider folder also contains the local Foundry sample
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: integration
|
environment: integration
|
||||||
env:
|
env:
|
||||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||||
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
|
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||||
|
FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME || '' }}
|
||||||
|
FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION || '' }}
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: python
|
working-directory: python
|
||||||
@@ -360,6 +351,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env
|
echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env
|
||||||
echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .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
|
- name: Run sample validation
|
||||||
run: |
|
run: |
|
||||||
@@ -448,15 +441,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: integration
|
environment: integration
|
||||||
env:
|
env:
|
||||||
# Azure AI configuration
|
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||||
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
|
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||||
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 }}
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: python
|
working-directory: python
|
||||||
@@ -475,11 +461,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env
|
echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env
|
||||||
echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .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
|
- name: Run sample validation
|
||||||
run: |
|
run: |
|
||||||
@@ -498,12 +479,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: integration
|
environment: integration
|
||||||
env:
|
env:
|
||||||
# Azure AI configuration
|
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||||
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
|
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||||
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 }}
|
|
||||||
# A2A configuration
|
# A2A configuration
|
||||||
A2A_AGENT_HOST: http://localhost:5001/
|
A2A_AGENT_HOST: http://localhost:5001/
|
||||||
defaults:
|
defaults:
|
||||||
@@ -537,19 +514,18 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: integration
|
environment: integration
|
||||||
env:
|
env:
|
||||||
# Azure AI configuration
|
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||||
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
|
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
|
||||||
# Azure OpenAI configuration
|
# Azure OpenAI configuration
|
||||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
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 }}
|
||||||
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
|
||||||
# Azure AI Search (for evaluation samples)
|
# Azure AI Search (for evaluation samples)
|
||||||
AZURE_SEARCH_ENDPOINT: ${{ secrets.AZURE_SEARCH_ENDPOINT }}
|
AZURE_SEARCH_ENDPOINT: ${{ secrets.AZURE_SEARCH_ENDPOINT }}
|
||||||
AZURE_SEARCH_API_KEY: ${{ secrets.AZURE_SEARCH_API_KEY }}
|
AZURE_SEARCH_API_KEY: ${{ secrets.AZURE_SEARCH_API_KEY }}
|
||||||
AZURE_SEARCH_INDEX_NAME: ${{ secrets.AZURE_SEARCH_INDEX_NAME }}
|
AZURE_SEARCH_INDEX_NAME: ${{ secrets.AZURE_SEARCH_INDEX_NAME }}
|
||||||
# Evaluation sample
|
# 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:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: python
|
working-directory: python
|
||||||
@@ -580,12 +556,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: integration
|
environment: integration
|
||||||
env:
|
env:
|
||||||
# Azure AI configuration
|
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||||
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
|
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
|
||||||
# Azure OpenAI configuration
|
# Azure OpenAI configuration
|
||||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
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 configuration
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||||
@@ -607,10 +582,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Create .env for samples
|
- name: Create .env for samples
|
||||||
run: |
|
run: |
|
||||||
echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env
|
echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env
|
||||||
echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env
|
echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env
|
||||||
echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_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_DEPLOYMENT_NAME=$AZURE_OPENAI_DEPLOYMENT_NAME" >> .env
|
||||||
echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env
|
echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env
|
||||||
echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env
|
echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env
|
||||||
echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env
|
echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env
|
||||||
@@ -631,13 +606,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: integration
|
environment: integration
|
||||||
env:
|
env:
|
||||||
# Azure AI configuration
|
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT || vars.AZURE_AI_PROJECT_ENDPOINT }}
|
||||||
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
|
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL || vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
|
||||||
# Azure OpenAI configuration
|
# Azure OpenAI configuration
|
||||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
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 }}
|
||||||
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
|
||||||
# OpenAI configuration
|
# OpenAI configuration
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||||
@@ -664,11 +637,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Create .env for samples
|
- name: Create .env for samples
|
||||||
run: |
|
run: |
|
||||||
echo "AZURE_AI_PROJECT_ENDPOINT=$AZURE_AI_PROJECT_ENDPOINT" >> .env
|
echo "FOUNDRY_PROJECT_ENDPOINT=$FOUNDRY_PROJECT_ENDPOINT" >> .env
|
||||||
echo "AZURE_AI_MODEL_DEPLOYMENT_NAME=$AZURE_AI_MODEL_DEPLOYMENT_NAME" >> .env
|
echo "FOUNDRY_MODEL=$FOUNDRY_MODEL" >> .env
|
||||||
echo "AZURE_OPENAI_ENDPOINT=$AZURE_OPENAI_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_DEPLOYMENT_NAME=$AZURE_OPENAI_DEPLOYMENT_NAME" >> .env
|
||||||
echo "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME=$AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME" >> .env
|
|
||||||
echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env
|
echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> .env
|
||||||
echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env
|
echo "OPENAI_CHAT_MODEL_ID=$OPENAI_CHAT_MODEL_ID" >> .env
|
||||||
echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env
|
echo "OPENAI_RESPONSES_MODEL_ID=$OPENAI_RESPONSES_MODEL_ID" >> .env
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Welcome to Microsoft Agent Framework!
|
# Welcome to Microsoft Agent Framework!
|
||||||
|
|
||||||
[](https://discord.gg/b5zjErwbQM)
|
[](https://discord.gg/b5zjErwbQM)
|
||||||
[](https://learn.microsoft.com/en-us/agent-framework/)
|
[](https://learn.microsoft.com/en-us/agent-framework/)
|
||||||
[](https://pypi.org/project/agent-framework/)
|
[](https://pypi.org/project/agent-framework/)
|
||||||
[](https://www.nuget.org/profiles/MicrosoftAgentFramework/)
|
[](https://www.nuget.org/profiles/MicrosoftAgentFramework/)
|
||||||
@@ -137,24 +137,21 @@ var agent = new OpenAIClient("<apikey>")
|
|||||||
Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework."));
|
Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework."));
|
||||||
```
|
```
|
||||||
|
|
||||||
Create a simple Agent, using Azure OpenAI Responses with token based auth, that writes a haiku about the Microsoft Agent Framework
|
Create a simple Agent, using Microsoft Foundry with token-based auth, that writes a haiku about the Microsoft Agent Framework
|
||||||
|
|
||||||
```c#
|
```c#
|
||||||
// dotnet add package Microsoft.Agents.AI.OpenAI --prerelease
|
// dotnet add package Microsoft.Agents.AI.AzureAI --prerelease
|
||||||
// dotnet add package Azure.Identity
|
// dotnet add package Azure.Identity
|
||||||
// Use `az login` to authenticate with Azure CLI
|
// Use `az login` to authenticate with Azure CLI
|
||||||
using System.ClientModel.Primitives;
|
using Azure.AI.Projects;
|
||||||
using Azure.Identity;
|
using Azure.Identity;
|
||||||
using Microsoft.Agents.AI;
|
using Microsoft.Agents.AI;
|
||||||
using OpenAI;
|
|
||||||
using OpenAI.Responses;
|
|
||||||
|
|
||||||
// Replace <resource> and gpt-4o-mini with your Azure OpenAI resource name and deployment name.
|
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||||
var agent = new OpenAIClient(
|
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||||
new BearerTokenPolicy(new AzureCliCredential(), "https://ai.azure.com/.default"),
|
|
||||||
new OpenAIClientOptions() { Endpoint = new Uri("https://<resource>.openai.azure.com/openai/v1") })
|
var agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||||
.GetResponsesClient("gpt-4o-mini")
|
.AsAIAgent(model: deploymentName, name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully.");
|
||||||
.AsAIAgent(name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully.");
|
|
||||||
|
|
||||||
Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework."));
|
Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework."));
|
||||||
```
|
```
|
||||||
@@ -163,15 +160,43 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
|
|||||||
|
|
||||||
### Python
|
### Python
|
||||||
|
|
||||||
- [Getting Started with Agents](./python/samples/01-get-started): progressive tutorial from hello-world to hosting
|
- [Getting Started](./python/samples/01-get-started): progressive tutorial from hello-world to hosting
|
||||||
- [Agent Concepts](./python/samples/02-agents): deep-dive samples by topic (tools, middleware, providers, etc.)
|
- [Agent Concepts](./python/samples/02-agents): deep-dive samples by topic (tools, middleware, providers, etc.)
|
||||||
- [Getting Started with Workflows](./python/samples/03-workflows): workflow creation and integration with agents
|
- [Workflows](./python/samples/03-workflows): workflow creation and integration with agents
|
||||||
|
- [Hosting](./python/samples/04-hosting): A2A, Azure Functions, Durable Task hosting
|
||||||
|
- [End-to-End](./python/samples/05-end-to-end): full applications, evaluation, and demos
|
||||||
|
|
||||||
### .NET
|
### .NET
|
||||||
|
|
||||||
- [Getting Started with Agents](./dotnet/samples/02-agents/Agents): basic agent creation and tool usage
|
- [Getting Started](./dotnet/samples/01-get-started): progressive tutorial from hello agent to hosting
|
||||||
- [Agent Provider Samples](./dotnet/samples/02-agents/AgentProviders): samples showing different agent providers
|
- [Agent Concepts](./dotnet/samples/02-agents/Agents): basic agent creation and tool usage
|
||||||
- [Workflow Samples](./dotnet/samples/03-workflows): advanced multi-agent patterns and workflow orchestration
|
- [Agent Providers](./dotnet/samples/02-agents/AgentProviders): samples showing different agent providers
|
||||||
|
- [Workflows](./dotnet/samples/03-workflows): advanced multi-agent patterns and workflow orchestration
|
||||||
|
- [Hosting](./dotnet/samples/04-hosting): A2A, Durable Agents, Durable Workflows
|
||||||
|
- [End-to-End](./dotnet/samples/05-end-to-end): full applications and demos
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
| Problem | Cause | Fix |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| Authentication errors when using Azure credentials | Not signed in to Azure CLI | Run `az login` before starting your app |
|
||||||
|
| API key errors | Wrong or missing API key | Verify the key and ensure it's for the correct resource/provider |
|
||||||
|
|
||||||
|
> **Tip:** `DefaultAzureCredential` is convenient for development but in production, consider using a specific credential (e.g., `ManagedIdentityCredential`) to avoid latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
The samples typically read configuration from environment variables. Common required variables:
|
||||||
|
|
||||||
|
| Variable | Used by | Purpose |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI samples | Your Azure OpenAI resource URL |
|
||||||
|
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI samples | Model deployment name (e.g. `gpt-4o-mini`) |
|
||||||
|
| `AZURE_AI_PROJECT_ENDPOINT` | Microsoft Foundry samples | Your Microsoft Foundry project endpoint |
|
||||||
|
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Microsoft Foundry samples | Model deployment name |
|
||||||
|
| `OPENAI_API_KEY` | OpenAI (non-Azure) samples | Your OpenAI platform API key |
|
||||||
|
|
||||||
## Contributor Resources
|
## Contributor Resources
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ The persistence timing and `FunctionResultContent` trimming behaviors are interr
|
|||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
- Option 1: Per-run persistence with opt-in FRC (FunctionResultContent) trimming
|
- Option 1: Per-run persistence with opt-in FRC (FunctionResultContent) trimming
|
||||||
- Option 2: Opt-in per-service-call persistence (via `SimulateServiceStoredChatHistory`)
|
- Option 2: Opt-in per-service-call persistence (via `RequirePerServiceCallChatHistoryPersistence`)
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
@@ -57,12 +57,12 @@ Keep the current default behavior of persisting chat history only at the end of
|
|||||||
- Bad, because if the process crashes mid-loop, all intermediate progress from the current run is lost, not satisfying driver C.
|
- Bad, because if the process crashes mid-loop, all intermediate progress from the current run is lost, not satisfying driver C.
|
||||||
- Bad, because this option alone does not provide a way for users to opt into per-service-call persistence, not satisfying driver E.
|
- Bad, because this option alone does not provide a way for users to opt into per-service-call persistence, not satisfying driver E.
|
||||||
|
|
||||||
### Option 2: Opt-in per-service-call persistence (via `SimulateServiceStoredChatHistory`)
|
### Option 2: Opt-in per-service-call persistence (via `RequirePerServiceCallChatHistoryPersistence`)
|
||||||
|
|
||||||
Introduce an optional SimulateServiceStoredChatHistory setting to persist chat history after each individual service call within the FIC loop, matching the AI service's behavior. Trailing `FunctionResultContent` trimming is unnecessary with this approach (it is naturally handled).
|
Introduce an optional RequirePerServiceCallChatHistoryPersistence setting to persist chat history after each individual service call within the FIC loop, matching the AI service's behavior. Trailing `FunctionResultContent` trimming is unnecessary with this approach (it is naturally handled).
|
||||||
|
|
||||||
Settings:
|
Settings:
|
||||||
- `SimulateServiceStoredChatHistory` = `true`
|
- `RequirePerServiceCallChatHistoryPersistence` = `true`
|
||||||
|
|
||||||
- Good, because the stored history matches the service's behavior when opting in for both timing and content, fully satisfying driver A.
|
- Good, because the stored history matches the service's behavior when opting in for both timing and content, fully satisfying driver A.
|
||||||
- Good, because intermediate progress is preserved if the process is interrupted, satisfying driver C.
|
- Good, because intermediate progress is preserved if the process is interrupted, satisfying driver C.
|
||||||
@@ -73,36 +73,49 @@ Settings:
|
|||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
Chosen option: **Option 2: Opt-in per-service-call persistence (via `SimulateServiceStoredChatHistory`)**. The existing per-run persistence behavior is retained as-is, requiring no changes from users. Per-service-call persistence is available as an opt-in feature via the `SimulateServiceStoredChatHistory` setting. This satisfies drivers B (atomicity) and D (simplicity) for the common case, while fully satisfying driver A (consistency) for users who opt into simulated service-stored behavior. Users who need per-service-call persistence for recoverability (driver C) can enable it explicitly.
|
Chosen option: **Option 2: Opt-in per-service-call persistence (via `RequirePerServiceCallChatHistoryPersistence`)**. The existing per-run persistence behavior is retained as-is, requiring no changes from users. Per-service-call persistence is available as an opt-in feature via the `RequirePerServiceCallChatHistoryPersistence` setting. This satisfies drivers B (atomicity) and D (simplicity) for the common case, while fully satisfying driver A (consistency) for users who opt into simulated service-stored behavior. Users who need per-service-call persistence for recoverability (driver C) can enable it explicitly.
|
||||||
|
|
||||||
### Configuration Matrix
|
### Configuration Matrix
|
||||||
|
|
||||||
The behavior depends on the combination of `UseProvidedChatClientAsIs` and `SimulateServiceStoredChatHistory`:
|
The behavior depends on the combination of `UseProvidedChatClientAsIs` and `RequirePerServiceCallChatHistoryPersistence`:
|
||||||
|
|
||||||
| `UseProvidedChatClientAsIs` | `SimulateServiceStoredChatHistory` | Behavior |
|
| `UseProvidedChatClientAsIs` | `RequirePerServiceCallChatHistoryPersistence` | Behavior |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `false` (default) | `false` (default) | **Per-run persistence.** Messages are persisted at the end of the full agent run via the `ChatHistoryProvider`. |
|
| `false` (default) | `false` (default) | **Per-run persistence.** Messages are persisted at the end of the full agent run via the `ChatHistoryProvider`. |
|
||||||
| `false` | `true` | **Per-service-call persistence (simulated).** A `ServiceStoredSimulatingChatClient` middleware is automatically injected into the chat client pipeline between `FunctionInvokingChatClient` and the leaf `IChatClient`. Messages are persisted after each service call. A sentinel `ConversationId` causes FIC to treat the conversation as service-managed. |
|
| `false` | `true` | **Per-service-call persistence (simulated).** A `PerServiceCallChatHistoryPersistingChatClient` middleware is automatically injected into the chat client pipeline between `FunctionInvokingChatClient` and the leaf `IChatClient`. Messages are persisted after each service call. A sentinel `ConversationId` causes FIC to treat the conversation as service-managed. |
|
||||||
| `true` | `false` | **Per-run persistence.** No middleware is injected because the user has provided a custom chat client stack. Messages are persisted at the end of the run. |
|
| `true` | `false` | **Per-run persistence.** No middleware is injected because the user has provided a custom chat client stack. Messages are persisted at the end of the run. |
|
||||||
| `true` | `true` | **User responsibility.** The system checks whether the custom chat client stack includes a `ServiceStoredSimulatingChatClient`. If not, a warning is emitted — the user is expected to have added their own per-service-call persistence mechanism. End-of-run persistence is skipped. |
|
| `true` | `true` | **User responsibility.** The system checks whether the custom chat client stack includes a `PerServiceCallChatHistoryPersistingChatClient`. If not, a warning is emitted — the user is expected to have added their own per-service-call persistence mechanism. End-of-run persistence is skipped. |
|
||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
- Good, because per-run persistence is atomic by default — chat history is only updated when the full run succeeds, satisfying driver B.
|
- Good, because per-run persistence is atomic by default — chat history is only updated when the full run succeeds, satisfying driver B.
|
||||||
- Good, because the default mental model is simple: one run = one history update, satisfying driver D.
|
- Good, because the default mental model is simple: one run = one history update, satisfying driver D.
|
||||||
- Good, because users who opt into `SimulateServiceStoredChatHistory` get stored history that matches the service's behavior for both timing and content, fully satisfying driver A.
|
- Good, because users who opt into `RequirePerServiceCallChatHistoryPersistence` get stored history that matches the service's behavior for both timing and content, fully satisfying driver A.
|
||||||
- Good, because per-service-call persistence preserves intermediate progress if the process is interrupted, satisfying driver C when opted in.
|
- Good, because per-service-call persistence preserves intermediate progress if the process is interrupted, satisfying driver C when opted in.
|
||||||
- Good, because no separate `FunctionResultContent` trimming logic is needed when per-service-call persistence is active — it is naturally handled.
|
- Good, because no separate `FunctionResultContent` trimming logic is needed when per-service-call persistence is active — it is naturally handled.
|
||||||
- Good, because conflict detection (configurable via `ThrowOnChatHistoryProviderConflict`, `WarnOnChatHistoryProviderConflict`, `ClearOnChatHistoryProviderConflict`) prevents misconfiguration when a service returns a `ConversationId` alongside a configured `ChatHistoryProvider`.
|
- Good, because conflict detection (configurable via `ThrowOnChatHistoryProviderConflict`, `WarnOnChatHistoryProviderConflict`, `ClearOnChatHistoryProviderConflict`) prevents misconfiguration when a service returns a `ConversationId` alongside a configured `ChatHistoryProvider`.
|
||||||
- Bad, because per-service-call persistence (when opted in) may leave chat history in an incomplete state if the run fails mid-loop (e.g., `FunctionCallContent` stored without corresponding `FunctionResultContent`), requiring manual recovery in rare cases.
|
- Bad, because per-service-call persistence (when opted in) may leave chat history in an incomplete state if the run fails mid-loop (e.g., `FunctionCallContent` stored without corresponding `FunctionResultContent`), requiring manual recovery in rare cases.
|
||||||
- Neutral, because users who want per-service-call consistency can opt in via `SimulateServiceStoredChatHistory = true`, satisfying driver E.
|
- Neutral, because users who want per-service-call consistency can opt in via `RequirePerServiceCallChatHistoryPersistence = true`, satisfying driver E.
|
||||||
- Neutral, because increased write frequency from per-service-call persistence may impact performance for some storage backends; this can be mitigated with a caching decorator.
|
- Neutral, because increased write frequency from per-service-call persistence may impact performance for some storage backends; this can be mitigated with a caching decorator.
|
||||||
|
|
||||||
### Implementation Notes
|
### Implementation Notes
|
||||||
|
|
||||||
#### Conversation ID Consistency
|
#### Conversation ID Consistency
|
||||||
|
|
||||||
We should introduce a separate `ConversationIdPersistingChatClient`, middleware which allows us to
|
When `RequirePerServiceCallChatHistoryPersistence` is enabled, the `PerServiceCallChatHistoryPersistingChatClient`
|
||||||
persist response `ConversationIds` during the FICC loop. This could be used with or without
|
decorator also updates `session.ConversationId` after each service call. This handles two scenarios:
|
||||||
`ServiceStoredSimulatingChatClient`.
|
|
||||||
|
1. **Framework-managed chat history** — the decorator sets a sentinel `ConversationId` on the response
|
||||||
|
so that `FunctionInvokingChatClient` treats the conversation as service-managed (clearing accumulated
|
||||||
|
history between iterations and not injecting duplicate `FunctionCallContent` during approval processing).
|
||||||
|
|
||||||
|
2. **Service-stored chat history** — when the service returns a real `ConversationId`, the decorator
|
||||||
|
updates `session.ConversationId` immediately after each service call, rather than deferring the update
|
||||||
|
to the end of the run. This ensures intermediate ConversationId changes are captured even if the
|
||||||
|
process is interrupted mid-loop.
|
||||||
|
|
||||||
|
For some service-stored scenarios (e.g., the Conversations API with the Responses API), there is only
|
||||||
|
one thread with one ID, so every service call returns the same ConversationId and this per-call update
|
||||||
|
makes no practical difference. Enabling `RequirePerServiceCallChatHistoryPersistence` ensures consistent
|
||||||
|
per-service-call behavior across all service types regardless of how they manage ConversationIds.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
// This sample demonstrates how the ChatClientAgent persists chat history after each individual
|
// This sample demonstrates how the ChatClientAgent persists chat history after each individual
|
||||||
// call to the AI service, using the SimulateServiceStoredChatHistory option.
|
// call to the AI service, using the RequirePerServiceCallChatHistoryPersistence option.
|
||||||
// When an agent uses tools, FunctionInvokingChatClient may loop multiple times
|
// When an agent uses tools, FunctionInvokingChatClient may loop multiple times
|
||||||
// (service call → tool execution → service call), and intermediate messages (tool calls and
|
// (service call → tool execution → service call), and intermediate messages (tool calls and
|
||||||
// results) are persisted after each service call. This allows you to inspect or recover them
|
// results) are persisted after each service call. This allows you to inspect or recover them
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
// yet finalized (e.g., tool calls without results) being persisted, which may be undesirable in some cases.
|
// yet finalized (e.g., tool calls without results) being persisted, which may be undesirable in some cases.
|
||||||
//
|
//
|
||||||
// To use end-of-run persistence instead (atomic run semantics), remove the
|
// To use end-of-run persistence instead (atomic run semantics), remove the
|
||||||
// SimulateServiceStoredChatHistory = true setting (or set it to false). End-of-run
|
// RequirePerServiceCallChatHistoryPersistence = true setting (or set it to false). End-of-run
|
||||||
// persistence is the default behavior.
|
// persistence is the default behavior.
|
||||||
//
|
//
|
||||||
// The sample runs two multi-turn conversations: one using non-streaming (RunAsync) and one
|
// The sample runs two multi-turn conversations: one using non-streaming (RunAsync) and one
|
||||||
@@ -54,7 +54,7 @@ static string GetTime([Description("The city name.")] string city) =>
|
|||||||
_ => $"{city}: time data not available."
|
_ => $"{city}: time data not available."
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create the agent — per-service-call persistence is enabled via SimulateServiceStoredChatHistory.
|
// Create the agent — per-service-call persistence is enabled via RequirePerServiceCallChatHistoryPersistence.
|
||||||
// The in-memory ChatHistoryProvider is used by default when the service does not require service stored chat
|
// The in-memory ChatHistoryProvider is used by default when the service does not require service stored chat
|
||||||
// history, so for those cases, we can inspect the chat history via session.TryGetInMemoryChatHistory().
|
// history, so for those cases, we can inspect the chat history via session.TryGetInMemoryChatHistory().
|
||||||
IChatClient chatClient = string.Equals(store, "TRUE", StringComparison.OrdinalIgnoreCase) ?
|
IChatClient chatClient = string.Equals(store, "TRUE", StringComparison.OrdinalIgnoreCase) ?
|
||||||
@@ -64,7 +64,7 @@ AIAgent agent = chatClient.AsAIAgent(
|
|||||||
new ChatClientAgentOptions
|
new ChatClientAgentOptions
|
||||||
{
|
{
|
||||||
Name = "WeatherAssistant",
|
Name = "WeatherAssistant",
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
ChatOptions = new()
|
ChatOptions = new()
|
||||||
{
|
{
|
||||||
Instructions = "You are a helpful assistant. When asked about multiple cities, call the appropriate tool for each city.",
|
Instructions = "You are a helpful assistant. When asked about multiple cities, call the appropriate tool for each city.",
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
# In-Function-Loop Checkpointing
|
# In-Function-Loop Checkpointing
|
||||||
|
|
||||||
This sample demonstrates how `ChatClientAgent` can persist chat history after each individual call to the AI service using the `SimulateServiceStoredChatHistory` option. This per-service-call persistence ensures intermediate progress is saved during the function invocation loop.
|
This sample demonstrates how `ChatClientAgent` can persist chat history after each individual call to the AI service using the `RequirePerServiceCallChatHistoryPersistence` option. This per-service-call persistence ensures intermediate progress is saved during the function invocation loop.
|
||||||
|
|
||||||
## What This Sample Shows
|
## What This Sample Shows
|
||||||
|
|
||||||
When an agent uses tools, the `FunctionInvokingChatClient` loops multiple times (service call → tool execution → service call → …). By enabling `SimulateServiceStoredChatHistory = true`, chat history is persisted after each service call via the `ServiceStoredSimulatingChatClient` decorator:
|
When an agent uses tools, the `FunctionInvokingChatClient` loops multiple times (service call → tool execution → service call → …). By enabling `RequirePerServiceCallChatHistoryPersistence = true`, chat history is persisted after each service call via the `PerServiceCallChatHistoryPersistingChatClient` decorator:
|
||||||
|
|
||||||
- A `ServiceStoredSimulatingChatClient` decorator is inserted into the chat client pipeline
|
- A `PerServiceCallChatHistoryPersistingChatClient` decorator is inserted into the chat client pipeline
|
||||||
- Before each service call, the decorator loads history from the `ChatHistoryProvider` and prepends it to the request
|
- Before each service call, the decorator loads history from the `ChatHistoryProvider` and prepends it to the request
|
||||||
- After each service call, the decorator notifies the `ChatHistoryProvider` (and any `AIContextProvider` instances) with the new messages
|
- After each service call, the decorator notifies the `ChatHistoryProvider` (and any `AIContextProvider` instances) with the new messages
|
||||||
- Only **new** messages are sent to providers on each notification — messages that were already persisted in an earlier call within the same run are deduplicated automatically
|
- Only **new** messages are sent to providers on each notification — messages that were already persisted in an earlier call within the same run are deduplicated automatically
|
||||||
|
|
||||||
By default (without `SimulateServiceStoredChatHistory`), chat history is persisted at the end of the full agent run instead. To use per-service-call persistence, set `SimulateServiceStoredChatHistory = true` on `ChatClientAgentOptions`.
|
By default (without `RequirePerServiceCallChatHistoryPersistence`), chat history is persisted at the end of the full agent run instead. To use per-service-call persistence, set `RequirePerServiceCallChatHistoryPersistence = true` on `ChatClientAgentOptions`.
|
||||||
|
|
||||||
With `SimulateServiceStoredChatHistory` = true, the behavior matches that of chat history stored in the underlying AI service exactly.
|
With `RequirePerServiceCallChatHistoryPersistence` = true, the behavior matches that of chat history stored in the underlying AI service exactly.
|
||||||
|
|
||||||
Per-service-call persistence is useful for:
|
Per-service-call persistence is useful for:
|
||||||
- **Crash recovery** — if the process is interrupted mid-loop, the intermediate tool calls and results are already persisted
|
- **Crash recovery** — if the process is interrupted mid-loop, the intermediate tool calls and results are already persisted
|
||||||
@@ -29,7 +29,7 @@ The sample asks the agent about the weather and time in three cities. The model
|
|||||||
```
|
```
|
||||||
ChatClientAgent
|
ChatClientAgent
|
||||||
└─ FunctionInvokingChatClient (handles tool call loop)
|
└─ FunctionInvokingChatClient (handles tool call loop)
|
||||||
└─ ServiceStoredSimulatingChatClient (persists after each service call)
|
└─ PerServiceCallChatHistoryPersistingChatClient (persists after each service call)
|
||||||
└─ Leaf IChatClient (Azure OpenAI)
|
└─ Leaf IChatClient (Azure OpenAI)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
The agent framework samples are designed to help you get started with building AI-powered agents
|
The agent framework samples are designed to help you get started with building AI-powered agents
|
||||||
from various providers.
|
from various providers.
|
||||||
|
|
||||||
The Agent Framework supports building agents using various infererence and inference-style services.
|
The Agent Framework supports building agents using various inference and inference-style services.
|
||||||
All these are supported using the single `ChatClientAgent` class.
|
All these are supported using the single `ChatClientAgent` class.
|
||||||
|
|
||||||
The Agent Framework also supports creating proxy agents, that allow accessing remote agents as if they
|
The Agent Framework also supports creating proxy agents, that allow accessing remote agents as if they
|
||||||
|
|||||||
@@ -139,8 +139,8 @@ public sealed partial class ChatClientAgent : AIAgent
|
|||||||
|
|
||||||
this._logger = (loggerFactory ?? chatClient.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance).CreateLogger<ChatClientAgent>();
|
this._logger = (loggerFactory ?? chatClient.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance).CreateLogger<ChatClientAgent>();
|
||||||
|
|
||||||
// Warn if using a custom chat client stack with simulated service stored persistence but no ServiceStoredSimulatingChatClient.
|
// Warn if using a custom chat client stack with simulated service stored persistence but no PerServiceCallChatHistoryPersistingChatClient.
|
||||||
this.WarnOnMissingServiceStoredSimulatingClient();
|
this.WarnOnMissingPerServiceCallChatHistoryPersistingChatClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -454,7 +454,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
|||||||
/// Notifies the <see cref="ChatHistoryProvider"/> and all <see cref="AIContextProviders"/> of successfully completed messages.
|
/// Notifies the <see cref="ChatHistoryProvider"/> and all <see cref="AIContextProviders"/> of successfully completed messages.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// This method is also called by <see cref="ServiceStoredSimulatingChatClient"/> to persist messages per-service-call.
|
/// This method is also called by <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to persist messages per-service-call.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal async Task NotifyProvidersOfNewMessagesAsync(
|
internal async Task NotifyProvidersOfNewMessagesAsync(
|
||||||
ChatClientAgentSession session,
|
ChatClientAgentSession session,
|
||||||
@@ -486,7 +486,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
|||||||
/// Notifies the <see cref="ChatHistoryProvider"/> and all <see cref="AIContextProviders"/> of a failure during a service call.
|
/// Notifies the <see cref="ChatHistoryProvider"/> and all <see cref="AIContextProviders"/> of a failure during a service call.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// This method is also called by <see cref="ServiceStoredSimulatingChatClient"/> to report failures per-service-call.
|
/// This method is also called by <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to report failures per-service-call.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal async Task NotifyProvidersOfFailureAsync(
|
internal async Task NotifyProvidersOfFailureAsync(
|
||||||
ChatClientAgentSession session,
|
ChatClientAgentSession session,
|
||||||
@@ -701,7 +701,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
|||||||
throw new InvalidOperationException("A session must be provided when continuing a background response with a continuation token.");
|
throw new InvalidOperationException("A session must be provided when continuing a background response with a continuation token.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((continuationToken is not null || chatOptions?.AllowBackgroundResponses is true) && this.SimulatesServiceStoredChatHistory && this._logger.IsEnabled(LogLevel.Warning))
|
if ((continuationToken is not null || chatOptions?.AllowBackgroundResponses is true) && this.RequiresPerServiceCallChatHistoryPersistence && this._logger.IsEnabled(LogLevel.Warning))
|
||||||
{
|
{
|
||||||
var warningAgentName = this.GetLoggingAgentName();
|
var warningAgentName = this.GetLoggingAgentName();
|
||||||
this._logger.LogAgentChatClientBackgroundResponseFallback(this.Id, warningAgentName);
|
this._logger.LogAgentChatClientBackgroundResponseFallback(this.Id, warningAgentName);
|
||||||
@@ -740,10 +740,10 @@ public sealed partial class ChatClientAgent : AIAgent
|
|||||||
IEnumerable<ChatMessage> inputMessagesForChatClient = inputMessages;
|
IEnumerable<ChatMessage> inputMessagesForChatClient = inputMessages;
|
||||||
|
|
||||||
// Populate the session messages only if we are not continuing an existing response as it's not allowed.
|
// Populate the session messages only if we are not continuing an existing response as it's not allowed.
|
||||||
// When SimulateServiceStoredChatHistory is active, the ServiceStoredSimulatingChatClient
|
// When RequirePerServiceCallChatHistoryPersistence is active, the PerServiceCallChatHistoryPersistingChatClient
|
||||||
// owns the chat history lifecycle — it loads history before each service call. The agent
|
// owns the chat history lifecycle — it loads history before each service call. The agent
|
||||||
// must not load history itself, as that would result in duplicate messages.
|
// must not load history itself, as that would result in duplicate messages.
|
||||||
if (chatOptions?.ContinuationToken is null && !this.SimulatesServiceStoredChatHistory)
|
if (chatOptions?.ContinuationToken is null && !this.RequiresPerServiceCallChatHistoryPersistence)
|
||||||
{
|
{
|
||||||
// Add any existing messages from the session to the messages to be sent to the chat client.
|
// Add any existing messages from the session to the messages to be sent to the chat client.
|
||||||
// The ChatHistoryProvider returns the merged result (history + input messages).
|
// The ChatHistoryProvider returns the merged result (history + input messages).
|
||||||
@@ -837,14 +837,14 @@ public sealed partial class ChatClientAgent : AIAgent
|
|||||||
/// Updates the session conversation ID at the end of an agent run.
|
/// Updates the session conversation ID at the end of an agent run.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// When a <see cref="ServiceStoredSimulatingChatClient"/> handles per-service-call
|
/// When a <see cref="PerServiceCallChatHistoryPersistingChatClient"/> handles per-service-call
|
||||||
/// conversation ID updates, this end-of-run update is skipped. When the decorator is
|
/// conversation ID updates, this end-of-run update is skipped. When the decorator is
|
||||||
/// absent, the update is performed here. When <paramref name="forceUpdate"/> is <see langword="true"/>
|
/// absent, the update is performed here. When <paramref name="forceUpdate"/> is <see langword="true"/>
|
||||||
/// (continuation token scenarios), the update is always performed.
|
/// (continuation token scenarios), the update is always performed.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void UpdateSessionConversationIdAtEndOfRun(ChatClientAgentSession session, string? responseConversationId, CancellationToken cancellationToken, bool forceUpdate = false)
|
private void UpdateSessionConversationIdAtEndOfRun(ChatClientAgentSession session, string? responseConversationId, CancellationToken cancellationToken, bool forceUpdate = false)
|
||||||
{
|
{
|
||||||
if (!forceUpdate && this.SimulatesServiceStoredChatHistory)
|
if (!forceUpdate && this.RequiresPerServiceCallChatHistoryPersistence)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -856,7 +856,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
|||||||
/// Notifies providers of successfully completed messages at the end of an agent run.
|
/// Notifies providers of successfully completed messages at the end of an agent run.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// When a <see cref="ServiceStoredSimulatingChatClient"/> handles per-service-call
|
/// When a <see cref="PerServiceCallChatHistoryPersistingChatClient"/> handles per-service-call
|
||||||
/// notification, this end-of-run notification is skipped. When no decorator is present,
|
/// notification, this end-of-run notification is skipped. When no decorator is present,
|
||||||
/// all messages are persisted.
|
/// all messages are persisted.
|
||||||
/// When <paramref name="forceNotify"/> is <see langword="true"/> (continuation token or
|
/// When <paramref name="forceNotify"/> is <see langword="true"/> (continuation token or
|
||||||
@@ -871,7 +871,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
|||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
bool forceNotify = false)
|
bool forceNotify = false)
|
||||||
{
|
{
|
||||||
if (!forceNotify && this.SimulatesServiceStoredChatHistory)
|
if (!forceNotify && this.RequiresPerServiceCallChatHistoryPersistence)
|
||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -883,7 +883,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
|||||||
/// Notifies providers of a failure at the end of an agent run.
|
/// Notifies providers of a failure at the end of an agent run.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// When a <see cref="ServiceStoredSimulatingChatClient"/> handles per-service-call
|
/// When a <see cref="PerServiceCallChatHistoryPersistingChatClient"/> handles per-service-call
|
||||||
/// notification (including failure), this end-of-run notification is skipped to avoid
|
/// notification (including failure), this end-of-run notification is skipped to avoid
|
||||||
/// duplicate notification. In all other cases, failure is reported at the end of the run.
|
/// duplicate notification. In all other cases, failure is reported at the end of the run.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
@@ -894,7 +894,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
|||||||
ChatOptions? chatOptions,
|
ChatOptions? chatOptions,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (this.SimulatesServiceStoredChatHistory)
|
if (this.RequiresPerServiceCallChatHistoryPersistence)
|
||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -905,14 +905,14 @@ public sealed partial class ChatClientAgent : AIAgent
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a value indicating whether the agent is configured to simulate service-stored chat history.
|
/// Gets a value indicating whether the agent is configured to simulate service-stored chat history.
|
||||||
/// When <see langword="true"/>, end-of-run persistence and history loading are skipped because a
|
/// When <see langword="true"/>, end-of-run persistence and history loading are skipped because a
|
||||||
/// per-service-call decorator (such as <see cref="ServiceStoredSimulatingChatClient"/> or a
|
/// per-service-call decorator (such as <see cref="PerServiceCallChatHistoryPersistingChatClient"/> or a
|
||||||
/// user-supplied equivalent) is expected to handle the history lifecycle.
|
/// user-supplied equivalent) is expected to handle the history lifecycle.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private bool SimulatesServiceStoredChatHistory
|
private bool RequiresPerServiceCallChatHistoryPersistence
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
return this._agentOptions?.SimulateServiceStoredChatHistory is true;
|
return this._agentOptions?.RequirePerServiceCallChatHistoryPersistence is true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -923,7 +923,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
|||||||
/// The base class sets <see cref="AIAgent.CurrentRunContext"/> with the raw session parameter
|
/// The base class sets <see cref="AIAgent.CurrentRunContext"/> with the raw session parameter
|
||||||
/// (which may be null) and restores it after each yield in streaming scenarios. After
|
/// (which may be null) and restores it after each yield in streaming scenarios. After
|
||||||
/// <see cref="PrepareSessionAndMessagesAsync"/> resolves or creates a session, we update the
|
/// <see cref="PrepareSessionAndMessagesAsync"/> resolves or creates a session, we update the
|
||||||
/// context so the <see cref="ServiceStoredSimulatingChatClient"/> decorator always has a valid session.
|
/// context so the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> decorator always has a valid session.
|
||||||
/// The original agent from the context is preserved to maintain the top-of-stack agent in
|
/// The original agent from the context is preserved to maintain the top-of-stack agent in
|
||||||
/// decorated agent scenarios.
|
/// decorated agent scenarios.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
@@ -939,19 +939,19 @@ public sealed partial class ChatClientAgent : AIAgent
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks for potential misconfiguration when using a custom chat client stack and logs warnings.
|
/// Checks for potential misconfiguration when using a custom chat client stack and logs warnings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void WarnOnMissingServiceStoredSimulatingClient()
|
private void WarnOnMissingPerServiceCallChatHistoryPersistingChatClient()
|
||||||
{
|
{
|
||||||
if (this._agentOptions?.UseProvidedChatClientAsIs is not true)
|
if (this._agentOptions?.UseProvidedChatClientAsIs is not true)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._agentOptions?.SimulateServiceStoredChatHistory is not true)
|
if (this._agentOptions?.RequirePerServiceCallChatHistoryPersistence is not true)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var persistingClient = this.ChatClient.GetService<ServiceStoredSimulatingChatClient>();
|
var persistingClient = this.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
|
||||||
if (persistingClient is null && this._logger.IsEnabled(LogLevel.Warning))
|
if (persistingClient is null && this._logger.IsEnabled(LogLevel.Warning))
|
||||||
{
|
{
|
||||||
var loggingAgentName = this.GetLoggingAgentName();
|
var loggingAgentName = this.GetLoggingAgentName();
|
||||||
@@ -998,7 +998,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// This method is used by both the agent (during <see cref="PrepareSessionAndMessagesAsync"/>) and by
|
/// This method is used by both the agent (during <see cref="PrepareSessionAndMessagesAsync"/>) and by
|
||||||
/// <see cref="ServiceStoredSimulatingChatClient"/> to load history before each service call.
|
/// <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to load history before each service call.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal async Task<IEnumerable<ChatMessage>> LoadChatHistoryAsync(
|
internal async Task<IEnumerable<ChatMessage>> LoadChatHistoryAsync(
|
||||||
ChatClientAgentSession session,
|
ChatClientAgentSession session,
|
||||||
|
|||||||
@@ -72,12 +72,12 @@ internal static partial class ChatClientAgentLogMessages
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Logs a warning when <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>
|
/// Logs a warning when <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>
|
||||||
/// and <see cref="ChatClientAgentOptions.SimulateServiceStoredChatHistory"/> is <see langword="true"/>,
|
/// and <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> is <see langword="true"/>,
|
||||||
/// but no <see cref="ServiceStoredSimulatingChatClient"/> is found in the custom chat client stack.
|
/// but no <see cref="PerServiceCallChatHistoryPersistingChatClient"/> is found in the custom chat client stack.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[LoggerMessage(
|
[LoggerMessage(
|
||||||
Level = LogLevel.Warning,
|
Level = LogLevel.Warning,
|
||||||
Message = "Agent {AgentId}/{AgentName}: SimulateServiceStoredChatHistory is enabled with a custom chat client stack (UseProvidedChatClientAsIs), but no ServiceStoredSimulatingChatClient was found in the pipeline. Chat history will not be persisted by ChatClientAgent. Consider adding a ServiceStoredSimulatingChatClient to the pipeline using the UseServiceStoredChatHistorySimulation extension method if you have not added your own persistence mechanism.")]
|
Message = "Agent {AgentId}/{AgentName}: RequirePerServiceCallChatHistoryPersistence is enabled with a custom chat client stack (UseProvidedChatClientAsIs), but no PerServiceCallChatHistoryPersistingChatClient was found in the pipeline. Chat history will not be persisted by ChatClientAgent. Consider adding a PerServiceCallChatHistoryPersistingChatClient to the pipeline using the UsePerServiceCallChatHistoryPersistence extension method if you have not added your own persistence mechanism.")]
|
||||||
public static partial void LogAgentChatClientMissingPersistingClient(
|
public static partial void LogAgentChatClientMissingPersistingClient(
|
||||||
this ILogger logger,
|
this ILogger logger,
|
||||||
string agentId,
|
string agentId,
|
||||||
@@ -92,7 +92,7 @@ internal static partial class ChatClientAgentLogMessages
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[LoggerMessage(
|
[LoggerMessage(
|
||||||
Level = LogLevel.Warning,
|
Level = LogLevel.Warning,
|
||||||
Message = "Agent {AgentId}/{AgentName}: SimulateServiceStoredChatHistory is enabled but we have to fall back to end-of-run persistence because the run involves background responses.")]
|
Message = "Agent {AgentId}/{AgentName}: RequirePerServiceCallChatHistoryPersistence is enabled but we have to fall back to end-of-run persistence because the run involves background responses.")]
|
||||||
public static partial void LogAgentChatClientBackgroundResponseFallback(
|
public static partial void LogAgentChatClientBackgroundResponseFallback(
|
||||||
this ILogger logger,
|
this ILogger logger,
|
||||||
string agentId,
|
string agentId,
|
||||||
|
|||||||
@@ -92,38 +92,56 @@ public sealed class ChatClientAgentOptions
|
|||||||
public bool ThrowOnChatHistoryProviderConflict { get; set; } = true;
|
public bool ThrowOnChatHistoryProviderConflict { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether the <see cref="ChatClientAgent"/> should simulate
|
/// Gets or sets a value indicating whether the <see cref="ChatClientAgent"/> should persist
|
||||||
/// service-stored chat history behavior using its configured <see cref="ChatHistoryProvider"/>.
|
/// chat history after each individual service call within the <see cref="FunctionInvokingChatClient"/>
|
||||||
|
/// loop, rather than at the end of the full agent run.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// When set to <see langword="true"/>, a <see cref="ServiceStoredSimulatingChatClient"/> decorator is
|
/// When set to <see langword="true"/>, a <see cref="PerServiceCallChatHistoryPersistingChatClient"/>
|
||||||
/// injected between the <see cref="FunctionInvokingChatClient"/> and the leaf <see cref="IChatClient"/>
|
/// decorator becomes active in the chat client pipeline. It handles two complementary scenarios:
|
||||||
/// in the chat client pipeline. This decorator takes full ownership of the chat history lifecycle:
|
|
||||||
/// it loads history from the <see cref="ChatHistoryProvider"/> before each service call and persists
|
|
||||||
/// new messages after each service call. It also returns a sentinel <see cref="ChatOptions.ConversationId"/>
|
|
||||||
/// on the response, causing the <see cref="FunctionInvokingChatClient"/> to treat the conversation
|
|
||||||
/// as service-managed — clearing accumulated history and not injecting duplicate
|
|
||||||
/// <see cref="FunctionCallContent"/> during approval-response processing.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// This mode aligns the behavior of framework-managed chat history with service-stored chat history,
|
|
||||||
/// ensuring consistency in how messages are stored and loaded, including during function calling loops
|
|
||||||
/// and tool-call termination scenarios.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>
|
||||||
|
/// <term>Framework-managed chat history</term>
|
||||||
|
/// <description>
|
||||||
|
/// The decorator loads history from the <see cref="ChatHistoryProvider"/> before each service call
|
||||||
|
/// and persists new request and response messages after each call. It returns a sentinel
|
||||||
|
/// <see cref="ChatOptions.ConversationId"/> on the response, causing the
|
||||||
|
/// <see cref="FunctionInvokingChatClient"/> to treat the conversation as service-managed — clearing
|
||||||
|
/// accumulated history between iterations and not injecting duplicate <see cref="FunctionCallContent"/>
|
||||||
|
/// during approval-response processing.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <term>AI Service-stored chat history</term>
|
||||||
|
/// <description>
|
||||||
|
/// When the service manages its own chat history (returning a real <see cref="ChatOptions.ConversationId"/>),
|
||||||
|
/// the decorator updates <see cref="ChatClientAgentSession.ConversationId"/> after each service call so
|
||||||
|
/// that intermediate ConversationId changes are captured immediately. For some services (e.g., the
|
||||||
|
/// Conversations API with the Responses API), there is only one thread with one ID, so every service
|
||||||
|
/// call updates it anyway and updating the <see cref="ChatClientAgentSession.ConversationId"/> has little effect
|
||||||
|
/// since it's the same ID. For other services (e.g., Responses API with Response IDs), a new ID is generated
|
||||||
|
/// with each service call, so updating the <see cref="ChatClientAgentSession.ConversationId"/> ensures that the
|
||||||
|
/// latest ID is always captured, even mid-run.
|
||||||
|
/// Enabling this option ensures consistent per-service-call behavior across all service types.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// </list>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// When set to <see langword="false"/> (the default), the <see cref="ChatClientAgent"/> handles
|
/// When set to <see langword="false"/> (the default), the <see cref="ChatClientAgent"/> handles
|
||||||
/// chat history persistence at the end of the full agent run via the <see cref="ChatHistoryProvider"/>
|
/// chat history persistence at the end of the full agent run via the <see cref="ChatHistoryProvider"/> if using
|
||||||
/// pipeline.
|
/// framework-managed chat history. For AI service-stored chat history, the <see cref="ChatClientAgentSession.ConversationId"/>
|
||||||
|
/// updates happen only at the end of the run.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// When setting the <see cref="UseProvidedChatClientAsIs"/> setting to <see langword="true"/> and
|
/// When setting the <see cref="UseProvidedChatClientAsIs"/> setting to <see langword="true"/> and
|
||||||
/// <see cref="SimulateServiceStoredChatHistory"/> to <see langword="true"/>, ensure that your custom chat client stack includes a
|
/// <see cref="RequirePerServiceCallChatHistoryPersistence"/> to <see langword="true"/>, ensure that your custom chat client stack includes a
|
||||||
/// <see cref="ServiceStoredSimulatingChatClient"/> to enable per-service-call persistence.
|
/// <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to enable per-service-call persistence.
|
||||||
/// If no <see cref="ServiceStoredSimulatingChatClient"/> is provided, and you are not storing chat history via other means,
|
/// If no <see cref="PerServiceCallChatHistoryPersistingChatClient"/> is provided, and you are not storing chat history via other means,
|
||||||
/// no chat history may be stored.
|
/// no chat history may be stored.
|
||||||
/// When using a custom chat client stack, you can add a <see cref="ServiceStoredSimulatingChatClient"/>
|
/// When using a custom chat client stack, you can add a <see cref="PerServiceCallChatHistoryPersistingChatClient"/>
|
||||||
/// manually via the <see cref="ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation"/>
|
/// manually via the <see cref="ChatClientBuilderExtensions.UsePerServiceCallChatHistoryPersistence"/>
|
||||||
/// extension method.
|
/// extension method.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
@@ -131,7 +149,7 @@ public sealed class ChatClientAgentOptions
|
|||||||
/// Default is <see langword="false"/>.
|
/// Default is <see langword="false"/>.
|
||||||
/// </value>
|
/// </value>
|
||||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||||
public bool SimulateServiceStoredChatHistory { get; set; }
|
public bool RequirePerServiceCallChatHistoryPersistence { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
|
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
|
||||||
@@ -149,6 +167,6 @@ public sealed class ChatClientAgentOptions
|
|||||||
ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict,
|
ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict,
|
||||||
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
|
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
|
||||||
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
|
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
|
||||||
SimulateServiceStoredChatHistory = this.SimulateServiceStoredChatHistory,
|
RequirePerServiceCallChatHistoryPersistence = this.RequirePerServiceCallChatHistoryPersistence,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,21 +86,21 @@ public static class ChatClientBuilderExtensions
|
|||||||
services: services);
|
services: services);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds a <see cref="ServiceStoredSimulatingChatClient"/> to the chat client pipeline.
|
/// Adds a <see cref="PerServiceCallChatHistoryPersistingChatClient"/> to the chat client pipeline.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This decorator should be positioned between the <see cref="FunctionInvokingChatClient"/> and the leaf
|
/// This decorator should be positioned between the <see cref="FunctionInvokingChatClient"/> and the leaf
|
||||||
/// <see cref="IChatClient"/> in the pipeline. It simulates service-stored chat history behavior by
|
/// <see cref="IChatClient"/> in the pipeline. It persists chat history after each individual service call
|
||||||
/// loading history before each service call, persisting after each call, and returning a sentinel
|
/// and updates the session <see cref="ChatOptions.ConversationId"/> per call for both framework-managed
|
||||||
/// <see cref="ChatOptions.ConversationId"/> on the response.
|
/// and service-stored chat history scenarios.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This extension method is intended for use with custom chat client stacks when
|
/// This extension method is intended for use with custom chat client stacks when
|
||||||
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>.
|
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>.
|
||||||
/// When <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="false"/> (the default),
|
/// When <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="false"/> (the default),
|
||||||
/// the <see cref="ChatClientAgent"/> automatically injects this decorator when
|
/// the <see cref="ChatClientAgent"/> automatically includes this decorator in the pipeline and activates it when
|
||||||
/// <see cref="ChatClientAgentOptions.SimulateServiceStoredChatHistory"/> is <see langword="true"/>.
|
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> is <see langword="true"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This decorator only works within the context of a running <see cref="ChatClientAgent"/> and will throw an
|
/// This decorator only works within the context of a running <see cref="ChatClientAgent"/> and will throw an
|
||||||
@@ -110,8 +110,8 @@ public static class ChatClientBuilderExtensions
|
|||||||
/// <param name="builder">The <see cref="ChatClientBuilder"/> to add the decorator to.</param>
|
/// <param name="builder">The <see cref="ChatClientBuilder"/> to add the decorator to.</param>
|
||||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||||
public static ChatClientBuilder UseServiceStoredChatHistorySimulation(this ChatClientBuilder builder)
|
public static ChatClientBuilder UsePerServiceCallChatHistoryPersistence(this ChatClientBuilder builder)
|
||||||
{
|
{
|
||||||
return builder.Use(innerClient => new ServiceStoredSimulatingChatClient(innerClient));
|
return builder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,16 +63,16 @@ public static class ChatClientExtensions
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceStoredSimulatingChatClient is only injected when SimulateServiceStoredChatHistory is enabled.
|
// PerServiceCallChatHistoryPersistingChatClient is only injected when RequirePerServiceCallChatHistoryPersistence is enabled.
|
||||||
// It is registered after FunctionInvokingChatClient so that it sits between FIC and the leaf client.
|
// It is registered after FunctionInvokingChatClient so that it sits between FIC and the leaf client.
|
||||||
// ChatClientBuilder.Build applies factories in reverse order, making the first Use() call outermost.
|
// ChatClientBuilder.Build applies factories in reverse order, making the first Use() call outermost.
|
||||||
// By adding our decorator second, the resulting pipeline is:
|
// By adding our decorator second, the resulting pipeline is:
|
||||||
// FunctionInvokingChatClient → ServiceStoredSimulatingChatClient → leaf IChatClient
|
// FunctionInvokingChatClient → PerServiceCallChatHistoryPersistingChatClient → leaf IChatClient
|
||||||
// This allows the decorator to simulate service-stored chat history by loading history before
|
// This allows the decorator to simulate service-stored chat history by loading history before
|
||||||
// each service call, persisting after each call, and returning a sentinel ConversationId.
|
// each service call, persisting after each call, and returning a sentinel ConversationId.
|
||||||
if (options?.SimulateServiceStoredChatHistory is true)
|
if (options?.RequirePerServiceCallChatHistoryPersistence is true)
|
||||||
{
|
{
|
||||||
chatBuilder.Use(innerClient => new ServiceStoredSimulatingChatClient(innerClient));
|
chatBuilder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
|
||||||
}
|
}
|
||||||
|
|
||||||
var agentChatClient = chatBuilder.Build(services);
|
var agentChatClient = chatBuilder.Build(services);
|
||||||
|
|||||||
+32
-16
@@ -11,23 +11,39 @@ using Microsoft.Extensions.AI;
|
|||||||
namespace Microsoft.Agents.AI;
|
namespace Microsoft.Agents.AI;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A delegating chat client that simulates service-stored chat history behavior using
|
/// A delegating chat client that persists chat history and updates session state after each
|
||||||
/// framework-managed <see cref="ChatHistoryProvider"/> instances.
|
/// individual service call within the <see cref="FunctionInvokingChatClient"/> loop.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This decorator is intended to operate between the <see cref="FunctionInvokingChatClient"/> and the leaf
|
/// This decorator is intended to operate between the <see cref="FunctionInvokingChatClient"/> and the leaf
|
||||||
/// <see cref="IChatClient"/> in a <see cref="ChatClientAgent"/> pipeline.
|
/// <see cref="IChatClient"/> in a <see cref="ChatClientAgent"/> pipeline. It is activated when
|
||||||
|
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> is <see langword="true"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Before each service call, it loads chat history from the agent's <see cref="ChatHistoryProvider"/>
|
/// When active, it handles two complementary scenarios:
|
||||||
/// and prepends it to the request messages. After each successful service call, it persists
|
|
||||||
/// new request and response messages to the provider. It also returns a sentinel
|
|
||||||
/// <see cref="ChatOptions.ConversationId"/> on the response so that the
|
|
||||||
/// <see cref="FunctionInvokingChatClient"/> treats the conversation as service-managed —
|
|
||||||
/// clearing accumulated history between iterations and not injecting duplicate
|
|
||||||
/// <see cref="FunctionCallContent"/> during approval-response processing.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>
|
||||||
|
/// <term>Framework-managed chat history</term>
|
||||||
|
/// <description>
|
||||||
|
/// Before each service call, the decorator loads history from the agent's <see cref="ChatHistoryProvider"/>
|
||||||
|
/// and prepends it to the request messages. After each successful call, it persists new messages to
|
||||||
|
/// the provider and returns a sentinel <see cref="ChatOptions.ConversationId"/> so that
|
||||||
|
/// <see cref="FunctionInvokingChatClient"/> treats the conversation as service-managed — clearing
|
||||||
|
/// accumulated history between iterations and not injecting duplicate <see cref="FunctionCallContent"/>
|
||||||
|
/// during approval-response processing.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <term>Service-stored chat history</term>
|
||||||
|
/// <description>
|
||||||
|
/// When the underlying service manages its own chat history (real <see cref="ChatOptions.ConversationId"/>),
|
||||||
|
/// the decorator updates <see cref="ChatClientAgentSession.ConversationId"/> after each service call so
|
||||||
|
/// that intermediate ConversationId changes are captured immediately rather than only at the end of the run.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// </list>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This chat client must be used within the context of a running <see cref="ChatClientAgent"/>. It retrieves the
|
/// This chat client must be used within the context of a running <see cref="ChatClientAgent"/>. It retrieves the
|
||||||
/// current agent and session from <see cref="AIAgent.CurrentRunContext"/>, which is set automatically when an agent's
|
/// current agent and session from <see cref="AIAgent.CurrentRunContext"/>, which is set automatically when an agent's
|
||||||
@@ -38,7 +54,7 @@ namespace Microsoft.Agents.AI;
|
|||||||
/// available or if the agent is not a <see cref="ChatClientAgent"/>.
|
/// available or if the agent is not a <see cref="ChatClientAgent"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed class ServiceStoredSimulatingChatClient : DelegatingChatClient
|
internal sealed class PerServiceCallChatHistoryPersistingChatClient : DelegatingChatClient
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A sentinel value returned on <see cref="ChatResponse.ConversationId"/> to signal
|
/// A sentinel value returned on <see cref="ChatResponse.ConversationId"/> to signal
|
||||||
@@ -59,10 +75,10 @@ internal sealed class ServiceStoredSimulatingChatClient : DelegatingChatClient
|
|||||||
internal const string LocalHistoryConversationId = "_agent_local_chat_history";
|
internal const string LocalHistoryConversationId = "_agent_local_chat_history";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="ServiceStoredSimulatingChatClient"/> class.
|
/// Initializes a new instance of the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="innerClient">The underlying chat client that will handle the core operations.</param>
|
/// <param name="innerClient">The underlying chat client that will handle the core operations.</param>
|
||||||
public ServiceStoredSimulatingChatClient(IChatClient innerClient)
|
public PerServiceCallChatHistoryPersistingChatClient(IChatClient innerClient)
|
||||||
: base(innerClient)
|
: base(innerClient)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -237,18 +253,18 @@ internal sealed class ServiceStoredSimulatingChatClient : DelegatingChatClient
|
|||||||
{
|
{
|
||||||
var runContext = AIAgent.CurrentRunContext
|
var runContext = AIAgent.CurrentRunContext
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"{nameof(ServiceStoredSimulatingChatClient)} can only be used within the context of a running AIAgent. " +
|
$"{nameof(PerServiceCallChatHistoryPersistingChatClient)} can only be used within the context of a running AIAgent. " +
|
||||||
"Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call.");
|
"Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call.");
|
||||||
|
|
||||||
var chatClientAgent = runContext.Agent.GetService<ChatClientAgent>()
|
var chatClientAgent = runContext.Agent.GetService<ChatClientAgent>()
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"{nameof(ServiceStoredSimulatingChatClient)} can only be used with a {nameof(ChatClientAgent)}. " +
|
$"{nameof(PerServiceCallChatHistoryPersistingChatClient)} can only be used with a {nameof(ChatClientAgent)}. " +
|
||||||
$"The current agent is of type '{runContext.Agent.GetType().Name}'.");
|
$"The current agent is of type '{runContext.Agent.GetType().Name}'.");
|
||||||
|
|
||||||
if (runContext.Session is not ChatClientAgentSession chatClientAgentSession)
|
if (runContext.Session is not ChatClientAgentSession chatClientAgentSession)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"{nameof(ServiceStoredSimulatingChatClient)} requires a {nameof(ChatClientAgentSession)}. " +
|
$"{nameof(PerServiceCallChatHistoryPersistingChatClient)} requires a {nameof(ChatClientAgentSession)}. " +
|
||||||
$"The current session is of type '{runContext.Session?.GetType().Name ?? "null"}'.");
|
$"The current session is of type '{runContext.Session?.GetType().Name ?? "null"}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ namespace Microsoft.Agents.AI.UnitTests;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shared test helper for <see cref="ChatClientAgent"/> integration tests that verify
|
/// Shared test helper for <see cref="ChatClientAgent"/> integration tests that verify
|
||||||
/// end-to-end behavior with <see cref="ServiceStoredSimulatingChatClient"/> and
|
/// end-to-end behavior with <see cref="PerServiceCallChatHistoryPersistingChatClient"/> and
|
||||||
/// <see cref="FunctionInvokingChatClient"/>.
|
/// <see cref="FunctionInvokingChatClient"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class ChatClientAgentTestHelper
|
internal static class ChatClientAgentTestHelper
|
||||||
|
|||||||
+3
-3
@@ -9,7 +9,7 @@ namespace Microsoft.Agents.AI.UnitTests;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Contains unit tests that verify the end-to-end approval flow behavior of the
|
/// Contains unit tests that verify the end-to-end approval flow behavior of the
|
||||||
/// <see cref="ChatClientAgent"/> class with <see cref="ServiceStoredSimulatingChatClient"/>,
|
/// <see cref="ChatClientAgent"/> class with <see cref="PerServiceCallChatHistoryPersistingChatClient"/>,
|
||||||
/// ensuring that chat history is correctly persisted across multi-turn approval interactions.
|
/// ensuring that chat history is correctly persisted across multi-turn approval interactions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ChatClientAgent_ApprovalsTests
|
public class ChatClientAgent_ApprovalsTests
|
||||||
@@ -48,7 +48,7 @@ public class ChatClientAgent_ApprovalsTests
|
|||||||
agentOptions: new()
|
agentOptions: new()
|
||||||
{
|
{
|
||||||
ChatOptions = new() { Tools = [approvalTool] },
|
ChatOptions = new() { Tools = [approvalTool] },
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
},
|
},
|
||||||
callIndex: callIndex,
|
callIndex: callIndex,
|
||||||
capturedInputs: capturedInputs);
|
capturedInputs: capturedInputs);
|
||||||
@@ -260,7 +260,7 @@ public class ChatClientAgent_ApprovalsTests
|
|||||||
agentOptions: new()
|
agentOptions: new()
|
||||||
{
|
{
|
||||||
ChatOptions = new() { Tools = [approvalTool] },
|
ChatOptions = new() { Tools = [approvalTool] },
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
},
|
},
|
||||||
callIndex: callIndex,
|
callIndex: callIndex,
|
||||||
capturedInputs: capturedInputs);
|
capturedInputs: capturedInputs);
|
||||||
|
|||||||
+2
-2
@@ -520,7 +520,7 @@ public class ChatClientAgent_ChatHistoryManagementTests
|
|||||||
agentOptions: new()
|
agentOptions: new()
|
||||||
{
|
{
|
||||||
ChatOptions = new() { Instructions = "Be helpful" },
|
ChatOptions = new() { Instructions = "Be helpful" },
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
},
|
},
|
||||||
expectedServiceCallCount: 1,
|
expectedServiceCallCount: 1,
|
||||||
expectedHistory:
|
expectedHistory:
|
||||||
@@ -554,7 +554,7 @@ public class ChatClientAgent_ChatHistoryManagementTests
|
|||||||
agentOptions: new()
|
agentOptions: new()
|
||||||
{
|
{
|
||||||
ChatOptions = new() { Tools = [tool] },
|
ChatOptions = new() { Tools = [tool] },
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
},
|
},
|
||||||
expectedServiceCallCount: 2,
|
expectedServiceCallCount: 2,
|
||||||
expectedHistory:
|
expectedHistory:
|
||||||
|
|||||||
+44
-44
@@ -13,15 +13,15 @@ using Moq.Protected;
|
|||||||
namespace Microsoft.Agents.AI.UnitTests;
|
namespace Microsoft.Agents.AI.UnitTests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Contains unit tests for the <see cref="ServiceStoredSimulatingChatClient"/> decorator,
|
/// Contains unit tests for the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> decorator,
|
||||||
/// verifying that it persists messages via the <see cref="ChatHistoryProvider"/> after each
|
/// verifying that it persists messages via the <see cref="ChatHistoryProvider"/> after each
|
||||||
/// individual service call by default, or marks messages for end-of-run persistence when the
|
/// individual service call by default, or marks messages for end-of-run persistence when the
|
||||||
/// <see cref="ChatClientAgentOptions.SimulateServiceStoredChatHistory"/> option is enabled.
|
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> option is enabled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ServiceStoredSimulatingChatClientTests
|
public class PerServiceCallChatHistoryPersistingChatClientTests
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that by default (SimulateServiceStoredChatHistory is false),
|
/// Verifies that by default (RequirePerServiceCallChatHistoryPersistence is false),
|
||||||
/// the ChatHistoryProvider receives messages after a successful non-streaming call.
|
/// the ChatHistoryProvider receives messages after a successful non-streaming call.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -50,7 +50,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -97,7 +97,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -145,7 +145,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -163,7 +163,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that the decorator is NOT injected by default (SimulateServiceStoredChatHistory is false).
|
/// Verifies that the decorator is NOT injected by default (RequirePerServiceCallChatHistoryPersistence is false).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ChatClient_DoesNotContainDecorator_ByDefault()
|
public void ChatClient_DoesNotContainDecorator_ByDefault()
|
||||||
@@ -175,15 +175,15 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
ChatClientAgent agent = new(mockService.Object, options: new());
|
ChatClientAgent agent = new(mockService.Object, options: new());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
var decorator = agent.ChatClient.GetService<ServiceStoredSimulatingChatClient>();
|
var decorator = agent.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
|
||||||
Assert.Null(decorator);
|
Assert.Null(decorator);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that the decorator is injected when SimulateServiceStoredChatHistory is true.
|
/// Verifies that the decorator is injected when RequirePerServiceCallChatHistoryPersistence is true.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ChatClient_ContainsDecorator_WhenSimulateServiceStoredChatHistory()
|
public void ChatClient_ContainsDecorator_WhenRequirePerServiceCallChatHistoryPersistence()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
Mock<IChatClient> mockService = new();
|
Mock<IChatClient> mockService = new();
|
||||||
@@ -191,11 +191,11 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
// Act
|
// Act
|
||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
var decorator = agent.ChatClient.GetService<ServiceStoredSimulatingChatClient>();
|
var decorator = agent.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
|
||||||
Assert.NotNull(decorator);
|
Assert.NotNull(decorator);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,27 +215,27 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
var decorator = agent.ChatClient.GetService<ServiceStoredSimulatingChatClient>();
|
var decorator = agent.ChatClient.GetService<PerServiceCallChatHistoryPersistingChatClient>();
|
||||||
Assert.Null(decorator);
|
Assert.Null(decorator);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that the SimulateServiceStoredChatHistory option is included in Clone().
|
/// Verifies that the RequirePerServiceCallChatHistoryPersistence option is included in Clone().
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ChatClientAgentOptions_Clone_IncludesSimulateServiceStoredChatHistory()
|
public void ChatClientAgentOptions_Clone_IncludesRequirePerServiceCallChatHistoryPersistence()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var options = new ChatClientAgentOptions
|
var options = new ChatClientAgentOptions
|
||||||
{
|
{
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var cloned = options.Clone();
|
var cloned = options.Clone();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.True(cloned.SimulateServiceStoredChatHistory);
|
Assert.True(cloned.RequirePerServiceCallChatHistoryPersistence);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -289,7 +289,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
{
|
{
|
||||||
ChatOptions = new() { Tools = [tool] },
|
ChatOptions = new() { Tools = [tool] },
|
||||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
}, services: new ServiceCollection().BuildServiceProvider());
|
}, services: new ServiceCollection().BuildServiceProvider());
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -358,7 +358,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -407,7 +407,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
AIContextProviders = [mockContextProvider.Object],
|
AIContextProviders = [mockContextProvider.Object],
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -454,7 +454,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
AIContextProviders = [mockContextProvider.Object],
|
AIContextProviders = [mockContextProvider.Object],
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -513,7 +513,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
{
|
{
|
||||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||||
AIContextProviders = [mockContextProvider.Object],
|
AIContextProviders = [mockContextProvider.Object],
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -587,7 +587,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
{
|
{
|
||||||
ChatOptions = new() { Tools = [tool] },
|
ChatOptions = new() { Tools = [tool] },
|
||||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
}, services: new ServiceCollection().BuildServiceProvider());
|
}, services: new ServiceCollection().BuildServiceProvider());
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -652,7 +652,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
{
|
{
|
||||||
ChatOptions = new() { Tools = [tool] },
|
ChatOptions = new() { Tools = [tool] },
|
||||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
}, services: new ServiceCollection().BuildServiceProvider());
|
}, services: new ServiceCollection().BuildServiceProvider());
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -720,8 +720,8 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that when per-service-call persistence is active and no real conversation ID exists,
|
/// Verifies that when per-service-call persistence is active and no real conversation ID exists,
|
||||||
/// <see cref="ChatClientAgent"/> sets the <see cref="ServiceStoredSimulatingChatClient.LocalHistoryConversationId"/>
|
/// <see cref="ChatClientAgent"/> sets the <see cref="PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId"/>
|
||||||
/// sentinel on the chat options and <see cref="ServiceStoredSimulatingChatClient"/> strips it before
|
/// sentinel on the chat options and <see cref="PerServiceCallChatHistoryPersistingChatClient"/> strips it before
|
||||||
/// forwarding to the inner client.
|
/// forwarding to the inner client.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -741,7 +741,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
ChatOptions = new() { Instructions = "test" },
|
ChatOptions = new() { Instructions = "test" },
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -773,7 +773,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
ChatOptions = new() { Instructions = "test" },
|
ChatOptions = new() { Instructions = "test" },
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -808,7 +808,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
|
|
||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a session with a real conversation ID.
|
// Create a session with a real conversation ID.
|
||||||
@@ -842,7 +842,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
ChatOptions = new() { Instructions = "test" },
|
ChatOptions = new() { Instructions = "test" },
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -862,7 +862,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
/// skip provider resolution in the agent (the decorator handles it).
|
/// skip provider resolution in the agent (the decorator handles it).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_SetsSentinelOnSession_WhenSimulateServiceStoredChatHistoryActiveAsync()
|
public async Task RunAsync_SetsSentinelOnSession_WhenRequirePerServiceCallChatHistoryPersistenceActiveAsync()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
Mock<IChatClient> mockService = new();
|
Mock<IChatClient> mockService = new();
|
||||||
@@ -875,7 +875,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
|
|
||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -883,7 +883,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||||
|
|
||||||
// Assert — session should have the sentinel conversation ID
|
// Assert — session should have the sentinel conversation ID
|
||||||
Assert.Equal(ServiceStoredSimulatingChatClient.LocalHistoryConversationId, session!.ConversationId);
|
Assert.Equal(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -924,7 +924,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act & Assert — conflict detection should throw
|
// Act & Assert — conflict detection should throw
|
||||||
@@ -969,7 +969,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
|
|
||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
AIContextProviders = [mockContextProvider.Object],
|
AIContextProviders = [mockContextProvider.Object],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1025,7 +1025,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
|
|
||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
AIContextProviders = [mockContextProvider.Object],
|
AIContextProviders = [mockContextProvider.Object],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1077,7 +1077,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
|
|
||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
AIContextProviders = [mockContextProvider.Object],
|
AIContextProviders = [mockContextProvider.Object],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1137,7 +1137,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
// No ChatHistoryProvider — so conflict detection won't throw.
|
// No ChatHistoryProvider — so conflict detection won't throw.
|
||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
AIContextProviders = [mockContextProvider.Object],
|
AIContextProviders = [mockContextProvider.Object],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1192,7 +1192,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
// No ChatHistoryProvider — so conflict detection won't throw.
|
// No ChatHistoryProvider — so conflict detection won't throw.
|
||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
AIContextProviders = [mockContextProvider.Object],
|
AIContextProviders = [mockContextProvider.Object],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1253,7 +1253,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
ChatHistoryProvider = mockChatHistoryProvider.Object,
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -1270,7 +1270,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
Assert.Equal("test", messageList[0].Text);
|
Assert.Equal("test", messageList[0].Text);
|
||||||
|
|
||||||
// Assert — session should NOT have the sentinel (agent handles ConversationId at end-of-run)
|
// Assert — session should NOT have the sentinel (agent handles ConversationId at end-of-run)
|
||||||
Assert.NotEqual(ServiceStoredSimulatingChatClient.LocalHistoryConversationId, session!.ConversationId);
|
Assert.NotEqual(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1291,7 +1291,7 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
|
|
||||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||||
{
|
{
|
||||||
SimulateServiceStoredChatHistory = true,
|
RequirePerServiceCallChatHistoryPersistence = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -1309,6 +1309,6 @@ public class ServiceStoredSimulatingChatClientTests
|
|||||||
Assert.NotEmpty(updates);
|
Assert.NotEmpty(updates);
|
||||||
|
|
||||||
// Assert — session should NOT have the sentinel
|
// Assert — session should NOT have the sentinel
|
||||||
Assert.NotEqual(ServiceStoredSimulatingChatClient.LocalHistoryConversationId, session!.ConversationId);
|
Assert.NotEqual(PerServiceCallChatHistoryPersistingChatClient.LocalHistoryConversationId, session!.ConversationId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -76,7 +76,7 @@ from agent_framework.observability import enable_instrumentation
|
|||||||
|
|
||||||
# Connectors (lazy-loaded)
|
# Connectors (lazy-loaded)
|
||||||
from agent_framework.openai import OpenAIChatClient
|
from agent_framework.openai import OpenAIChatClient
|
||||||
from agent_framework.azure import AzureOpenAIChatClient
|
from agent_framework.foundry import FoundryChatClient
|
||||||
```
|
```
|
||||||
|
|
||||||
## Public API and Exports
|
## Public API and Exports
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
name: python-feature-lifecycle
|
||||||
|
description: >
|
||||||
|
Guidance for package and feature lifecycle in the Agent Framework Python
|
||||||
|
codebase, including stage meanings, feature-stage decorators, feature enums,
|
||||||
|
and how to move APIs from one stage to the next.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Python Feature Lifecycle
|
||||||
|
|
||||||
|
## Two lifecycle levels
|
||||||
|
|
||||||
|
Agent Framework uses lifecycle at two different levels:
|
||||||
|
|
||||||
|
1. **Package lifecycle** — the maturity of the package as a whole
|
||||||
|
2. **Feature lifecycle** — the maturity of a specific API or feature inside that package
|
||||||
|
|
||||||
|
These are related, but they are **not the same thing**.
|
||||||
|
|
||||||
|
- The **package stage is the default** for everything in the package.
|
||||||
|
- **Feature-stage decorators are only for exceptions** when a feature is behind the package's default stage.
|
||||||
|
- Do **not** decorate every class or function just because the package is experimental or release candidate.
|
||||||
|
|
||||||
|
### Important default
|
||||||
|
|
||||||
|
If a package is still in **beta / experimental preview**, all public APIs in that package are experimental by default.
|
||||||
|
|
||||||
|
- Do **not** add `@experimental(...)` everywhere in that package.
|
||||||
|
- The package stage already communicates that default.
|
||||||
|
|
||||||
|
Once a package moves forward, you can keep individual features behind:
|
||||||
|
|
||||||
|
- If a package moves to **release candidate**, a feature may remain **experimental**
|
||||||
|
- If a package moves to **released / GA**, a feature may remain **experimental** or **release candidate**
|
||||||
|
|
||||||
|
That is the main use case for feature-stage decorators.
|
||||||
|
|
||||||
|
## The four stages
|
||||||
|
|
||||||
|
### 1. Experimental
|
||||||
|
|
||||||
|
Use for features that are still unstable and may change or be removed without notice.
|
||||||
|
|
||||||
|
Feature-level code pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ._feature_stage import ExperimentalFeature, experimental
|
||||||
|
|
||||||
|
|
||||||
|
@experimental(feature_id=ExperimentalFeature.MY_FEATURE)
|
||||||
|
class MyFeature:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- Adds an experimental warning block to the docstring
|
||||||
|
- Records feature metadata on the decorated object
|
||||||
|
- Emits a runtime warning the first time the feature is used (once per feature by default)
|
||||||
|
|
||||||
|
Enum setup:
|
||||||
|
|
||||||
|
- Add an all-caps member to `ExperimentalFeature`
|
||||||
|
- Reuse the same feature ID across all APIs that belong to the same conceptual feature
|
||||||
|
|
||||||
|
### 2. Release candidate
|
||||||
|
|
||||||
|
Use for features that are nearly stable but may still receive small refinements before GA.
|
||||||
|
|
||||||
|
Feature-level code pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ._feature_stage import ReleaseCandidateFeature, release_candidate
|
||||||
|
|
||||||
|
|
||||||
|
@release_candidate(feature_id=ReleaseCandidateFeature.MY_FEATURE)
|
||||||
|
class MyFeature:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- Adds a release-candidate note to the docstring
|
||||||
|
- Records feature metadata on the decorated object
|
||||||
|
- Does **not** emit the experimental warning
|
||||||
|
|
||||||
|
Enum setup:
|
||||||
|
|
||||||
|
- Add an all-caps member to `ReleaseCandidateFeature`
|
||||||
|
|
||||||
|
### 3. Released
|
||||||
|
|
||||||
|
Use for stable GA APIs.
|
||||||
|
|
||||||
|
Code pattern:
|
||||||
|
|
||||||
|
- **No feature-stage decorator**
|
||||||
|
- **No entry** in `ExperimentalFeature`
|
||||||
|
- **No entry** in `ReleaseCandidateFeature`
|
||||||
|
|
||||||
|
If a feature is fully released, remove any stage-specific feature annotation.
|
||||||
|
|
||||||
|
### 4. Deprecated
|
||||||
|
|
||||||
|
Use for APIs that still exist but should not be used for new code.
|
||||||
|
|
||||||
|
Code pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@deprecated("MyOldFeature is deprecated. Use MyNewFeature instead.")
|
||||||
|
class MyOldFeature:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- Uses the repository's version-conditional deprecation import pattern
|
||||||
|
- Should describe what to use instead
|
||||||
|
|
||||||
|
Deprecated APIs should not also carry feature-stage decorators.
|
||||||
|
|
||||||
|
## Expected decorators by stage
|
||||||
|
|
||||||
|
| Feature stage | Expected annotation |
|
||||||
|
| --- | --- |
|
||||||
|
| Experimental | `@experimental(feature_id=ExperimentalFeature.X)` |
|
||||||
|
| Release candidate | `@release_candidate(feature_id=ReleaseCandidateFeature.X)` |
|
||||||
|
| Released | No feature-stage decorator |
|
||||||
|
| Deprecated | `@deprecated("...")` |
|
||||||
|
|
||||||
|
## Feature enums
|
||||||
|
|
||||||
|
The feature enums are the inventory of currently staged features:
|
||||||
|
|
||||||
|
- `ExperimentalFeature`
|
||||||
|
- `ReleaseCandidateFeature`
|
||||||
|
|
||||||
|
Guidance:
|
||||||
|
|
||||||
|
- Use one enum member per conceptual feature, not per class
|
||||||
|
- Ideally, an ADR already defines the overall feature boundary and therefore the feature ID that staged APIs for that feature should reuse
|
||||||
|
- Keep feature IDs all caps
|
||||||
|
- Reuse the same member across related APIs for the same feature
|
||||||
|
- Remove enum members when the feature no longer belongs to that stage
|
||||||
|
- Treat these enums as **current-stage inventories**, not as a stable consumer introspection API
|
||||||
|
|
||||||
|
Minimal consumer guidance:
|
||||||
|
|
||||||
|
- Treat `__feature_stage__` and `__feature_id__` as optional staged metadata, not as stable contracts
|
||||||
|
- Use `getattr(obj, "__feature_stage__", None)` and `getattr(obj, "__feature_id__", None)` rather than direct attribute access
|
||||||
|
- Treat missing metadata as "no explicit feature-stage annotation"
|
||||||
|
- For warning filters while a feature is staged, match the literal feature ID string
|
||||||
|
- Do **not** rely on `ExperimentalFeature.X`, `ReleaseCandidateFeature.X`, or the continued presence of `__feature_id__` after a feature moves stages or is released
|
||||||
|
|
||||||
|
For consumers, the enums are also re-exported from `agent_framework`.
|
||||||
|
|
||||||
|
For internal implementation code inside `agent_framework`, continue to import the enums and decorators from `._feature_stage`.
|
||||||
|
|
||||||
|
## Package stage vs feature stage
|
||||||
|
|
||||||
|
Use the following rules:
|
||||||
|
|
||||||
|
### Package is experimental / beta
|
||||||
|
|
||||||
|
- All public APIs are experimental by default
|
||||||
|
- Do **not** add feature-stage decorators just to restate that
|
||||||
|
- Only introduce feature-level annotations later if the package advances first
|
||||||
|
|
||||||
|
### Package is release candidate
|
||||||
|
|
||||||
|
- All public APIs are RC by default
|
||||||
|
- Do **not** decorate everything
|
||||||
|
- Add `@experimental(...)` only for features that are intentionally still behind the package
|
||||||
|
|
||||||
|
### Package is released / GA
|
||||||
|
|
||||||
|
- All public APIs are released by default
|
||||||
|
- Add `@experimental(...)` or `@release_candidate(...)` only for features still being held back
|
||||||
|
|
||||||
|
## Moving a feature from one stage to the next
|
||||||
|
|
||||||
|
### Experimental -> Release candidate
|
||||||
|
|
||||||
|
1. Move the feature ID from `ExperimentalFeature` to `ReleaseCandidateFeature`
|
||||||
|
2. Replace `@experimental(...)` with `@release_candidate(...)`
|
||||||
|
3. Update any tests or docs that mention the old stage
|
||||||
|
|
||||||
|
### Experimental -> Released
|
||||||
|
|
||||||
|
1. Remove `@experimental(...)`
|
||||||
|
2. Remove the feature from `ExperimentalFeature`
|
||||||
|
3. Do not add a replacement feature-stage decorator
|
||||||
|
|
||||||
|
### Release candidate -> Released
|
||||||
|
|
||||||
|
1. Remove `@release_candidate(...)`
|
||||||
|
2. Remove the feature from `ReleaseCandidateFeature`
|
||||||
|
3. Leave the API undecorated
|
||||||
|
|
||||||
|
### Any stage -> Deprecated
|
||||||
|
|
||||||
|
1. Remove any feature-stage decorator
|
||||||
|
2. Remove the feature from the stage enum
|
||||||
|
3. Add `@deprecated("...")`
|
||||||
|
4. Update docs/tests to reflect the replacement path
|
||||||
|
|
||||||
|
## Promotion guidance
|
||||||
|
|
||||||
|
Features do **not** have to pass through every stage.
|
||||||
|
|
||||||
|
- It is usually a good idea to move features in order when that reflects reality
|
||||||
|
- But it is completely acceptable to go **experimental -> released**
|
||||||
|
- Do **not** force a feature through release candidate if there is no real RC period
|
||||||
|
|
||||||
|
Likewise, when a package advances, do not automatically move every feature with it.
|
||||||
|
|
||||||
|
- Promote features based on actual readiness
|
||||||
|
- Keep lagging features explicitly marked only when they are behind the package default
|
||||||
|
|
||||||
|
## Practical rules of thumb
|
||||||
|
|
||||||
|
- **Package default first, feature exceptions second**
|
||||||
|
- **Do not decorate everything in preview packages**
|
||||||
|
- **Do not double-annotate members of an already-staged class**
|
||||||
|
- **Use enums only for currently staged features**
|
||||||
|
- **Do not treat stage enums as a compatibility contract**
|
||||||
|
- **Treat `__feature_stage__` and `__feature_id__` as optional metadata; use `getattr`**
|
||||||
|
- **Remove stage annotations once a feature is released or deprecated**
|
||||||
@@ -134,7 +134,7 @@ Recommended dependency workflow during connector implementation:
|
|||||||
pip install agent-framework-core # Core only
|
pip install agent-framework-core # Core only
|
||||||
pip install agent-framework-core[all] # Core + all connectors
|
pip install agent-framework-core[all] # Core + all connectors
|
||||||
pip install agent-framework # Same as core[all]
|
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
|
## Maintaining Documentation
|
||||||
@@ -143,3 +143,11 @@ When changing a package, check if its `AGENTS.md` needs updates:
|
|||||||
- Adding/removing/renaming public classes or functions
|
- Adding/removing/renaming public classes or functions
|
||||||
- Changing the package's purpose or architecture
|
- Changing the package's purpose or architecture
|
||||||
- Modifying import paths or usage patterns
|
- 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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Instructions for AI coding agents working in the Python codebase.
|
|||||||
- `python-development` — coding standards, type annotations, docstrings, logging, performance
|
- `python-development` — coding standards, type annotations, docstrings, logging, performance
|
||||||
- `python-testing` — test structure, fixtures, async mode, running tests
|
- `python-testing` — test structure, fixtures, async mode, running tests
|
||||||
- `python-code-quality` — linting, formatting, type checking, prek hooks, CI workflow
|
- `python-code-quality` — linting, formatting, type checking, prek hooks, CI workflow
|
||||||
|
- `python-feature-lifecycle` — package vs feature lifecycle stages, decorators, enums, and promotion guidance
|
||||||
- `python-package-management` — monorepo structure, lazy loading, versioning, new packages
|
- `python-package-management` — monorepo structure, lazy loading, versioning, new packages
|
||||||
- `python-samples` — sample file structure, PEP 723, documentation guidelines
|
- `python-samples` — sample file structure, PEP 723, documentation guidelines
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ The package follows a flat import structure:
|
|||||||
- **Connectors**: Import from `agent_framework.<vendor/platform>`
|
- **Connectors**: Import from `agent_framework.<vendor/platform>`
|
||||||
```python
|
```python
|
||||||
from agent_framework.openai import OpenAIChatClient
|
from agent_framework.openai import OpenAIChatClient
|
||||||
from agent_framework.azure import AzureOpenAIChatClient
|
from agent_framework.foundry import FoundryChatClient
|
||||||
```
|
```
|
||||||
|
|
||||||
## Exception Hierarchy
|
## 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.
|
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 '_').
|
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:
|
They should contain:
|
||||||
|
|
||||||
- Single line explaining what the function does, ending with a period.
|
- Single line explaining what the function does, ending with a period.
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ You can then run the following commands manually:
|
|||||||
# Install Python 3.10, 3.11, 3.12, and 3.13
|
# Install Python 3.10, 3.11, 3.12, and 3.13
|
||||||
uv python install 3.10 3.11 3.12 3.13
|
uv python install 3.10 3.11 3.12 3.13
|
||||||
# Create a virtual environment with Python 3.10 (you can change this to 3.11, 3.12 or 3.13)
|
# Create a virtual environment with Python 3.10 (you can change this to 3.11, 3.12 or 3.13)
|
||||||
$PYTHON_VERSION = "3.10"
|
PYTHON_VERSION="3.10"
|
||||||
uv venv --python $PYTHON_VERSION
|
uv venv --python $PYTHON_VERSION
|
||||||
# Install AF and all dependencies
|
# Install AF and all dependencies
|
||||||
uv sync --dev
|
uv sync --dev
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ OPENAI_MODEL=...
|
|||||||
...
|
...
|
||||||
AZURE_OPENAI_API_KEY=...
|
AZURE_OPENAI_API_KEY=...
|
||||||
AZURE_OPENAI_ENDPOINT=...
|
AZURE_OPENAI_ENDPOINT=...
|
||||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=...
|
AZURE_OPENAI_DEPLOYMENT_NAME=...
|
||||||
...
|
...
|
||||||
FOUNDRY_PROJECT_ENDPOINT=...
|
FOUNDRY_PROJECT_ENDPOINT=...
|
||||||
FOUNDRY_MODEL=...
|
FOUNDRY_MODEL=...
|
||||||
|
|||||||
@@ -15,16 +15,16 @@ pip install agent-framework-ag-ui
|
|||||||
```python
|
```python
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from agent_framework import Agent
|
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 agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
|
||||||
|
|
||||||
# Create your agent
|
# Create your agent
|
||||||
agent = Agent(
|
agent = Agent(
|
||||||
name="my_agent",
|
name="my_agent",
|
||||||
instructions="You are a helpful assistant.",
|
instructions="You are a helpful assistant.",
|
||||||
client=AzureOpenAIChatClient(
|
client=OpenAIChatCompletionClient(
|
||||||
endpoint="https://your-resource.openai.azure.com/",
|
azure_endpoint="https://your-resource.openai.azure.com/",
|
||||||
deployment_name="gpt-4o-mini",
|
model="gpt-4o-mini",
|
||||||
api_key="your-api-key",
|
api_key="your-api-key",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ All example agents are factory functions that accept any `SupportsChatGetRespons
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi import FastAPI
|
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.openai import OpenAIChatClient
|
||||||
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
|
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
|
||||||
from agent_framework_ag_ui_examples.agents import simple_agent, weather_agent
|
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()
|
app = FastAPI()
|
||||||
|
|
||||||
# Option 1: Use Azure OpenAI
|
# 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")
|
add_agent_framework_fastapi_endpoint(app, simple_agent(azure_client), "/chat")
|
||||||
|
|
||||||
# Option 2: Use OpenAI
|
# 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")
|
add_agent_framework_fastapi_endpoint(app, weather_agent(openai_client), "/weather")
|
||||||
|
|
||||||
# Run with: uvicorn main:app --reload
|
# Run with: uvicorn main:app --reload
|
||||||
@@ -39,14 +39,14 @@ add_agent_framework_fastapi_endpoint(app, weather_agent(openai_client), "/weathe
|
|||||||
```python
|
```python
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from agent_framework import Agent
|
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 agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
|
||||||
|
|
||||||
# Create your agent
|
# Create your agent
|
||||||
agent = Agent(
|
agent = Agent(
|
||||||
name="my_agent",
|
name="my_agent",
|
||||||
instructions="You are a helpful assistant.",
|
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
|
# Create FastAPI app and add AG-UI endpoint
|
||||||
@@ -90,7 +90,7 @@ Complete examples for all AG-UI features are available:
|
|||||||
### Using Example Agents
|
### Using Example Agents
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from agent_framework.azure import AzureOpenAIChatClient
|
from agent_framework.openai import OpenAIChatCompletionClient
|
||||||
from agent_framework.openai import OpenAIChatClient
|
from agent_framework.openai import OpenAIChatClient
|
||||||
from agent_framework_ag_ui_examples.agents import (
|
from agent_framework_ag_ui_examples.agents import (
|
||||||
simple_agent,
|
simple_agent,
|
||||||
@@ -99,8 +99,8 @@ from agent_framework_ag_ui_examples.agents import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create a chat client (use any SupportsChatGetResponse implementation)
|
# Create a chat client (use any SupportsChatGetResponse implementation)
|
||||||
azure_client = AzureOpenAIChatClient(model_id="gpt-4")
|
azure_client = OpenAIChatCompletionClient(model="gpt-4")
|
||||||
openai_client = OpenAIChatClient(model_id="gpt-4o")
|
openai_client = OpenAIChatClient(model="gpt-4o")
|
||||||
|
|
||||||
# Create agent instances by calling the factory functions
|
# Create agent instances by calling the factory functions
|
||||||
agent1 = simple_agent(azure_client)
|
agent1 = simple_agent(azure_client)
|
||||||
@@ -137,7 +137,7 @@ The server exposes endpoints at:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi import FastAPI
|
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 import add_agent_framework_fastapi_endpoint
|
||||||
from agent_framework_ag_ui_examples.agents import (
|
from agent_framework_ag_ui_examples.agents import (
|
||||||
simple_agent,
|
simple_agent,
|
||||||
@@ -153,7 +153,7 @@ from agent_framework_ag_ui_examples.agents import (
|
|||||||
app = FastAPI(title="AG-UI Examples")
|
app = FastAPI(title="AG-UI Examples")
|
||||||
|
|
||||||
# Create a chat client (shared across all agents, or create individual ones)
|
# 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 all example endpoints
|
||||||
add_agent_framework_fastapi_endpoint(app, simple_agent(client), "/agentic_chat")
|
add_agent_framework_fastapi_endpoint(app, simple_agent(client), "/agentic_chat")
|
||||||
@@ -223,8 +223,8 @@ def my_custom_agent(client: SupportsChatGetResponse) -> AgentFrameworkAgent:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Use it
|
# Use it
|
||||||
from agent_framework.azure import AzureOpenAIChatClient
|
from agent_framework.openai import OpenAIChatCompletionClient
|
||||||
client = AzureOpenAIChatClient()
|
client = OpenAIChatCompletionClient()
|
||||||
agent = my_custom_agent(client)
|
agent = my_custom_agent(client)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -234,13 +234,13 @@ State is injected as system messages and updated via predictive state updates:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from agent_framework import Agent
|
from agent_framework import Agent
|
||||||
from agent_framework.azure import AzureOpenAIChatClient
|
from agent_framework.openai import OpenAIChatCompletionClient
|
||||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||||
|
|
||||||
# Create your agent
|
# Create your agent
|
||||||
agent = Agent(
|
agent = Agent(
|
||||||
name="recipe_agent",
|
name="recipe_agent",
|
||||||
client=AzureOpenAIChatClient(model_id="gpt-4o"),
|
client=OpenAIChatCompletionClient(model="gpt-4o"),
|
||||||
)
|
)
|
||||||
|
|
||||||
state_schema = {
|
state_schema = {
|
||||||
@@ -271,13 +271,13 @@ Predictive state updates automatically stream tool arguments as optimistic state
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from agent_framework import Agent
|
from agent_framework import Agent
|
||||||
from agent_framework.azure import AzureOpenAIChatClient
|
from agent_framework.openai import OpenAIChatCompletionClient
|
||||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||||
|
|
||||||
# Create your agent
|
# Create your agent
|
||||||
agent = Agent(
|
agent = Agent(
|
||||||
name="document_writer",
|
name="document_writer",
|
||||||
client=AzureOpenAIChatClient(model_id="gpt-4o"),
|
client=OpenAIChatCompletionClient(model="gpt-4o"),
|
||||||
)
|
)
|
||||||
|
|
||||||
predict_state_config = {
|
predict_state_config = {
|
||||||
|
|||||||
+2
-2
@@ -6,7 +6,7 @@ from typing import Any, cast
|
|||||||
|
|
||||||
from agent_framework._clients import SupportsChatGetResponse
|
from agent_framework._clients import SupportsChatGetResponse
|
||||||
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
|
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 import FastAPI
|
||||||
|
|
||||||
from ...agents.weather_agent import weather_agent
|
from ...agents.weather_agent import weather_agent
|
||||||
@@ -19,7 +19,7 @@ def register_backend_tool_rendering(app: FastAPI) -> None:
|
|||||||
app: The FastAPI application.
|
app: The FastAPI application.
|
||||||
"""
|
"""
|
||||||
# Create a chat client and call the factory function
|
# Create a chat client and call the factory function
|
||||||
client = cast(SupportsChatGetResponse[Any], AzureOpenAIChatClient())
|
client = cast(SupportsChatGetResponse[Any], OpenAIChatCompletionClient())
|
||||||
|
|
||||||
add_agent_framework_fastapi_endpoint(
|
add_agent_framework_fastapi_endpoint(
|
||||||
app,
|
app,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import uvicorn
|
|||||||
from agent_framework import ChatOptions
|
from agent_framework import ChatOptions
|
||||||
from agent_framework._clients import SupportsChatGetResponse
|
from agent_framework._clients import SupportsChatGetResponse
|
||||||
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
|
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 import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ client: SupportsChatGetResponse[ChatOptions] = cast(
|
|||||||
SupportsChatGetResponse[ChatOptions],
|
SupportsChatGetResponse[ChatOptions],
|
||||||
AnthropicClient()
|
AnthropicClient()
|
||||||
if AnthropicClient is not None and os.getenv("CHAT_CLIENT", "").lower() == "anthropic"
|
if AnthropicClient is not None and os.getenv("CHAT_CLIENT", "").lower() == "anthropic"
|
||||||
else AzureOpenAIChatClient(),
|
else OpenAIChatCompletionClient(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Agentic Chat - basic chat agent
|
# Agentic Chat - basic chat agent
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ Create a file named `server.py`:
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from agent_framework import Agent
|
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 agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
@@ -205,9 +205,9 @@ if not api_key:
|
|||||||
agent = Agent(
|
agent = Agent(
|
||||||
name="AGUIAssistant",
|
name="AGUIAssistant",
|
||||||
instructions="You are a helpful assistant.",
|
instructions="You are a helpful assistant.",
|
||||||
client=AzureOpenAIChatClient(
|
client=OpenAIChatCompletionClient(
|
||||||
endpoint=endpoint,
|
azure_endpoint=endpoint,
|
||||||
deployment_name=deployment_name,
|
model=deployment_name,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -230,7 +230,7 @@ if __name__ == "__main__":
|
|||||||
- **`Agent`**: The agent that will handle incoming requests
|
- **`Agent`**: The agent that will handle incoming requests
|
||||||
- **FastAPI Integration**: Uses FastAPI's native async support for streaming responses
|
- **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
|
- **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:
|
**Alternative (simpler)**: Use environment variables only:
|
||||||
|
|
||||||
@@ -239,7 +239,7 @@ if __name__ == "__main__":
|
|||||||
agent = Agent(
|
agent = Agent(
|
||||||
name="AGUIAssistant",
|
name="AGUIAssistant",
|
||||||
instructions="You are a helpful assistant.",
|
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
|
```bash
|
||||||
export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
|
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
|
# Optional: Set API key if not using DefaultAzureCredential
|
||||||
# export AZURE_OPENAI_API_KEY="your-api-key"
|
# export AZURE_OPENAI_API_KEY="your-api-key"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import os
|
|||||||
|
|
||||||
from agent_framework import Agent, tool
|
from agent_framework import Agent, tool
|
||||||
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
|
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 dotenv import load_dotenv
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Security
|
from fastapi import Depends, FastAPI, HTTPException, Security
|
||||||
from fastapi.security import APIKeyHeader
|
from fastapi.security import APIKeyHeader
|
||||||
@@ -26,12 +26,12 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Read required configuration
|
# Read required configuration
|
||||||
endpoint = os.environ.get("AZURE_OPENAI_ENDPOINT")
|
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:
|
if not endpoint:
|
||||||
raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required")
|
raise ValueError("AZURE_OPENAI_ENDPOINT environment variable is required")
|
||||||
if not deployment_name:
|
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(
|
agent = Agent(
|
||||||
name="AGUIAssistant",
|
name="AGUIAssistant",
|
||||||
instructions="You are a helpful assistant. Use get_weather for weather and get_time_zone for time zones.",
|
instructions="You are a helpful assistant. Use get_weather for weather and get_time_zone for time zones.",
|
||||||
client=AzureOpenAIChatClient(
|
client=OpenAIChatCompletionClient(
|
||||||
endpoint=endpoint,
|
azure_endpoint=endpoint,
|
||||||
deployment_name=deployment_name,
|
model=deployment_name,
|
||||||
),
|
),
|
||||||
tools=[get_time_zone], # ONLY server-side tools
|
tools=[get_time_zone], # ONLY server-side tools
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,32 +1,30 @@
|
|||||||
# Azure AI Package (agent-framework-azure-ai)
|
# 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
|
## Main Classes
|
||||||
|
|
||||||
- **`AzureAIAgentClient`** - Chat client for Azure AI Agents (persistent agents with threads)
|
- **`AzureAIInferenceEmbeddingClient`** - Full-featured Azure AI inference embeddings client
|
||||||
- **`AzureAIClient`** - Client for Azure AI Foundry project-based agents
|
- **`RawAzureAIInferenceEmbeddingClient`** - Raw embeddings client without middleware layers
|
||||||
- **`AzureAIAgentsProvider`** - Provider for listing/managing Azure AI agents
|
- **`AzureAIInferenceEmbeddingOptions`** / **`AzureAIInferenceEmbeddingSettings`** - Embedding options and settings
|
||||||
- **`AzureAIProjectAgentProvider`** - Provider for project-scoped agent management
|
- **`AzureAISettings`** - Shared Azure AI project settings TypedDict
|
||||||
- **`AzureAISettings`** - Pydantic settings for Azure AI configuration
|
- **`AzureCredentialTypes`** / **`AzureTokenProvider`** - Shared Azure authentication helpers
|
||||||
- **`AzureAIAgentOptions`** / **`AzureAIProjectAgentOptions`** - Options TypedDicts
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from agent_framework.azure import AzureAIAgentClient
|
from agent_framework_azure_ai import AzureAIInferenceEmbeddingClient
|
||||||
|
|
||||||
client = AzureAIAgentClient(
|
client = AzureAIInferenceEmbeddingClient(
|
||||||
endpoint="https://your-project.services.ai.azure.com",
|
endpoint="https://<resource>.inference.ai.azure.com",
|
||||||
agent_id="your-agent-id",
|
api_key="...",
|
||||||
|
model_id="text-embedding-3-large",
|
||||||
)
|
)
|
||||||
response = await client.get_response("Hello")
|
result = await client.get_embeddings(["Hello"])
|
||||||
```
|
```
|
||||||
|
|
||||||
## Import Path
|
## Import Path
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from agent_framework.azure import AzureAIAgentClient, AzureAIClient
|
from agent_framework_azure_ai import AzureAIInferenceEmbeddingClient
|
||||||
# or directly:
|
|
||||||
from agent_framework_azure_ai import AzureAIAgentClient
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,21 +2,6 @@
|
|||||||
|
|
||||||
import importlib.metadata
|
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 (
|
from ._embedding_client import (
|
||||||
AzureAIInferenceEmbeddingClient,
|
AzureAIInferenceEmbeddingClient,
|
||||||
AzureAIInferenceEmbeddingOptions,
|
AzureAIInferenceEmbeddingOptions,
|
||||||
@@ -24,7 +9,6 @@ from ._embedding_client import (
|
|||||||
RawAzureAIInferenceEmbeddingClient,
|
RawAzureAIInferenceEmbeddingClient,
|
||||||
)
|
)
|
||||||
from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider
|
from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider
|
||||||
from ._project_provider import AzureAIProjectAgentProvider # pyright: ignore[reportDeprecated]
|
|
||||||
from ._shared import AzureAISettings
|
from ._shared import AzureAISettings
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -33,29 +17,12 @@ except importlib.metadata.PackageNotFoundError:
|
|||||||
__version__ = "0.0.0"
|
__version__ = "0.0.0"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AzureAIAgentClient",
|
|
||||||
"AzureAIAgentOptions",
|
|
||||||
"AzureAIAgentsProvider",
|
|
||||||
"AzureAIClient",
|
|
||||||
"AzureAIInferenceEmbeddingClient",
|
"AzureAIInferenceEmbeddingClient",
|
||||||
"AzureAIInferenceEmbeddingOptions",
|
"AzureAIInferenceEmbeddingOptions",
|
||||||
"AzureAIInferenceEmbeddingSettings",
|
"AzureAIInferenceEmbeddingSettings",
|
||||||
"AzureAIProjectAgentOptions",
|
|
||||||
"AzureAIProjectAgentProvider",
|
|
||||||
"AzureAISettings",
|
"AzureAISettings",
|
||||||
"AzureCredentialTypes",
|
"AzureCredentialTypes",
|
||||||
"AzureOpenAIAssistantsClient",
|
|
||||||
"AzureOpenAIAssistantsOptions",
|
|
||||||
"AzureOpenAIChatClient",
|
|
||||||
"AzureOpenAIChatOptions",
|
|
||||||
"AzureOpenAIConfigMixin",
|
|
||||||
"AzureOpenAIEmbeddingClient",
|
|
||||||
"AzureOpenAIResponsesClient",
|
|
||||||
"AzureOpenAIResponsesOptions",
|
|
||||||
"AzureOpenAISettings",
|
|
||||||
"AzureTokenProvider",
|
"AzureTokenProvider",
|
||||||
"AzureUserSecurityContext",
|
|
||||||
"RawAzureAIClient",
|
|
||||||
"RawAzureAIInferenceEmbeddingClient",
|
"RawAzureAIInferenceEmbeddingClient",
|
||||||
"__version__",
|
"__version__",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -2,45 +2,13 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import sys
|
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):
|
if sys.version_info >= (3, 11):
|
||||||
from typing import TypedDict # pragma: no cover
|
from typing import TypedDict # pragma: no cover
|
||||||
else:
|
else:
|
||||||
from typing_extensions import TypedDict # type: ignore # pragma: no cover
|
from typing_extensions import TypedDict # type: ignore # pragma: no cover
|
||||||
|
|
||||||
logger = logging.getLogger("agent_framework.azure")
|
|
||||||
|
|
||||||
|
|
||||||
class AzureAISettings(TypedDict, total=False):
|
class AzureAISettings(TypedDict, total=False):
|
||||||
"""Azure AI Project settings.
|
"""Azure AI Project settings.
|
||||||
@@ -78,518 +46,3 @@ class AzureAISettings(TypedDict, total=False):
|
|||||||
|
|
||||||
project_endpoint: str | None
|
project_endpoint: str | None
|
||||||
model_deployment_name: 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.")
|
|
||||||
|
|||||||
@@ -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 []
|
|
||||||
@@ -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)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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", "<unset>"
|
|
||||||
)
|
|
||||||
api_version = os.getenv("AZURE_OPENAI_API_VERSION", "<unset>")
|
|
||||||
endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "<unset>")
|
|
||||||
debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}"
|
|
||||||
if hasattr(exc, "add_note"):
|
|
||||||
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
|
|
||||||
@@ -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", "<unset>"
|
|
||||||
)
|
|
||||||
api_version = os.getenv("AZURE_OPENAI_API_VERSION", "<unset>")
|
|
||||||
endpoint = os.getenv("AZURE_OPENAI_ENDPOINT", "<unset>")
|
|
||||||
debug_message = f"Azure OpenAI debug: endpoint={endpoint}, model={model}, api_version={api_version}"
|
|
||||||
if hasattr(exc, "add_note"):
|
|
||||||
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}"
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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()
|
|
||||||
|
|
||||||
# Mock get_openai_client method
|
|
||||||
mock_client.get_openai_client = 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
|
|
||||||
@@ -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"]}}
|
|
||||||
@@ -4,7 +4,7 @@ This folder contains samples for `agent-framework-azure-cosmos`.
|
|||||||
|
|
||||||
| File | Description |
|
| 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
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from agent_framework.azure import AzureOpenAIResponsesClient
|
from agent_framework.foundry import FoundryChatClient
|
||||||
from azure.identity.aio import AzureCliCredential
|
from azure.identity.aio import AzureCliCredential
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
@@ -17,13 +17,13 @@ load_dotenv()
|
|||||||
This sample demonstrates CosmosHistoryProvider as an agent context provider.
|
This sample demonstrates CosmosHistoryProvider as an agent context provider.
|
||||||
|
|
||||||
Key components:
|
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
|
- CosmosHistoryProvider configured for Cosmos DB-backed message history
|
||||||
- Provider-configured container name with session_id as partition key
|
- Provider-configured container name with session_id as partition key
|
||||||
|
|
||||||
Environment variables:
|
Environment variables:
|
||||||
AZURE_AI_PROJECT_ENDPOINT
|
FOUNDRY_PROJECT_ENDPOINT
|
||||||
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME
|
FOUNDRY_MODEL
|
||||||
AZURE_COSMOS_ENDPOINT
|
AZURE_COSMOS_ENDPOINT
|
||||||
AZURE_COSMOS_DATABASE_NAME
|
AZURE_COSMOS_DATABASE_NAME
|
||||||
AZURE_COSMOS_CONTAINER_NAME
|
AZURE_COSMOS_CONTAINER_NAME
|
||||||
@@ -34,8 +34,8 @@ Optional:
|
|||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
"""Run the Cosmos history provider sample with an Agent."""
|
"""Run the Cosmos history provider sample with an Agent."""
|
||||||
project_endpoint = os.getenv("AZURE_AI_PROJECT_ENDPOINT")
|
project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT")
|
||||||
deployment_name = os.getenv("AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME")
|
deployment_name = os.getenv("FOUNDRY_MODEL")
|
||||||
cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT")
|
cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT")
|
||||||
cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME")
|
cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME")
|
||||||
cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME")
|
cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME")
|
||||||
@@ -49,16 +49,16 @@ async def main() -> None:
|
|||||||
or not cosmos_container_name
|
or not cosmos_container_name
|
||||||
):
|
):
|
||||||
print(
|
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."
|
"AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME."
|
||||||
)
|
)
|
||||||
return
|
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:
|
async with AzureCliCredential() as credential:
|
||||||
client = AzureOpenAIResponsesClient(
|
client = FoundryChatClient(
|
||||||
project_endpoint=project_endpoint,
|
project_endpoint=project_endpoint,
|
||||||
deployment_name=deployment_name,
|
model=deployment_name,
|
||||||
credential=credential,
|
credential=credential,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -124,16 +124,17 @@ class AgentFunctionApp(DFAppBase):
|
|||||||
|
|
||||||
.. code-block:: python
|
.. 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
|
# Create agents with unique names
|
||||||
weather_agent = AzureOpenAIChatClient(...).as_agent(
|
weather_agent = OpenAIChatCompletionClient(...).as_agent(
|
||||||
name="WeatherAgent",
|
name="WeatherAgent",
|
||||||
instructions="You are a helpful weather agent.",
|
instructions="You are a helpful weather agent.",
|
||||||
tools=[get_weather],
|
tools=[get_weather],
|
||||||
)
|
)
|
||||||
|
|
||||||
math_agent = AzureOpenAIChatClient(...).as_agent(
|
math_agent = OpenAIChatCompletionClient(...).as_agent(
|
||||||
name="MathAgent",
|
name="MathAgent",
|
||||||
instructions="You are a helpful math assistant.",
|
instructions="You are a helpful math assistant.",
|
||||||
tools=[calculate],
|
tools=[calculate],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Azure OpenAI Configuration
|
# Azure OpenAI Configuration
|
||||||
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
|
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
|
FUNCTIONS_WORKER_RUNTIME=python
|
||||||
|
|
||||||
# Azure Functions Configuration
|
# Azure Functions Configuration
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ from fastapi import FastAPI, Request
|
|||||||
from fastapi.responses import Response, StreamingResponse
|
from fastapi.responses import Response, StreamingResponse
|
||||||
|
|
||||||
from agent_framework import Agent
|
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 agent_framework.chatkit import simple_to_agent_input, stream_agent_response
|
||||||
|
|
||||||
from chatkit.server import ChatKitServer
|
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
|
# Define your agent with tools
|
||||||
agent = Agent(
|
agent = Agent(
|
||||||
client=AzureOpenAIChatClient(credential=AzureCliCredential()),
|
client=OpenAIChatCompletionClient(credential=AzureCliCredential()),
|
||||||
instructions="You are a helpful assistant.",
|
instructions="You are a helpful assistant.",
|
||||||
tools=[], # Add your tools here
|
tools=[], # Add your tools here
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -82,13 +82,12 @@ agent_framework/
|
|||||||
|
|
||||||
### OpenAI (`openai/`)
|
### OpenAI (`openai/`)
|
||||||
|
|
||||||
- **`OpenAIChatClient`** - Chat client for OpenAI API
|
- **`OpenAIChatClient`** - Chat client for the OpenAI Responses API
|
||||||
- **`OpenAIResponsesClient`** - Client for OpenAI Responses API
|
- **`OpenAIChatCompletionClient`** - Chat client for the OpenAI Chat Completions API
|
||||||
|
|
||||||
### Azure OpenAI (`azure/`)
|
### Foundry (`foundry/`)
|
||||||
|
|
||||||
- **`AzureOpenAIChatClient`** - Chat client for Azure OpenAI
|
- **`FoundryChatClient`** - Chat client for Azure AI Foundry project endpoints
|
||||||
- **`AzureOpenAIResponsesClient`** - Client for Azure OpenAI Responses API
|
|
||||||
|
|
||||||
## Key Patterns
|
## Key Patterns
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Highlights
|
|||||||
- Flexible Agent Framework: build, orchestrate, and deploy AI agents and multi-agent systems
|
- Flexible Agent Framework: build, orchestrate, and deploy AI agents and multi-agent systems
|
||||||
- Multi-Agent Orchestration: Group chat, sequential, concurrent, and handoff patterns
|
- Multi-Agent Orchestration: Group chat, sequential, concurrent, and handoff patterns
|
||||||
- Plugin Ecosystem: Extend with native functions, OpenAPI, Model Context Protocol (MCP), and more
|
- 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
|
- Runtime Support: In-process and distributed agent execution
|
||||||
- Multimodal: Text, vision, and function calling
|
- Multimodal: Text, vision, and function calling
|
||||||
- Cross-Platform: .NET and Python implementations
|
- Cross-Platform: .NET and Python implementations
|
||||||
@@ -16,6 +16,8 @@ Highlights
|
|||||||
pip install agent-framework-core --pre
|
pip install agent-framework-core --pre
|
||||||
# Optional: Add Azure AI Foundry integration
|
# Optional: Add Azure AI Foundry integration
|
||||||
pip install agent-framework-foundry --pre
|
pip install agent-framework-foundry --pre
|
||||||
|
# Optional: Add OpenAI integration
|
||||||
|
pip install agent-framework-openai --pre
|
||||||
```
|
```
|
||||||
|
|
||||||
Supported Platforms:
|
Supported Platforms:
|
||||||
@@ -25,35 +27,33 @@ Supported Platforms:
|
|||||||
|
|
||||||
## 1. Setup API Keys
|
## 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
|
```bash
|
||||||
|
FOUNDRY_PROJECT_ENDPOINT=...
|
||||||
|
FOUNDRY_MODEL=...
|
||||||
|
...
|
||||||
OPENAI_API_KEY=sk-...
|
OPENAI_API_KEY=sk-...
|
||||||
OPENAI_CHAT_MODEL=...
|
OPENAI_CHAT_MODEL=...
|
||||||
OPENAI_RESPONSES_MODEL=...
|
OPENAI_RESPONSES_MODEL=...
|
||||||
...
|
...
|
||||||
AZURE_OPENAI_API_KEY=...
|
AZURE_OPENAI_API_KEY=...
|
||||||
AZURE_OPENAI_ENDPOINT=...
|
AZURE_OPENAI_ENDPOINT=...
|
||||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=...
|
AZURE_OPENAI_DEPLOYMENT_NAME=...
|
||||||
...
|
|
||||||
FOUNDRY_PROJECT_ENDPOINT=...
|
|
||||||
FOUNDRY_MODEL=...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also override environment variables by explicitly passing configuration parameters to the chat client constructor:
|
You can also override environment variables by explicitly passing configuration parameters to the chat client constructor:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from agent_framework.azure import AzureOpenAIChatClient
|
from agent_framework.openai import OpenAIChatClient
|
||||||
|
|
||||||
client = AzureOpenAIChatClient(
|
client = OpenAIChatClient(
|
||||||
api_key="",
|
api_key="",
|
||||||
endpoint="",
|
model="",
|
||||||
deployment_name="",
|
|
||||||
api_version="",
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## 2. Create a Simple Agent
|
||||||
|
|
||||||
@@ -64,22 +64,19 @@ import asyncio
|
|||||||
from agent_framework import Agent
|
from agent_framework import Agent
|
||||||
from agent_framework.openai import OpenAIChatClient
|
from agent_framework.openai import OpenAIChatClient
|
||||||
|
|
||||||
async def main():
|
agent = Agent(
|
||||||
agent = Agent(
|
client=OpenAIChatClient(),
|
||||||
client=OpenAIChatClient(),
|
instructions="""
|
||||||
instructions="""
|
1) A robot may not injure a human being...
|
||||||
1) A robot may not injure a human being...
|
2) A robot must obey orders given it by human beings...
|
||||||
2) A robot must obey orders given it by human beings...
|
3) A robot must protect its own existence...
|
||||||
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")
|
result = asyncio.run(agent.run("Summarize the Three Laws of Robotics"))
|
||||||
print(result)
|
print(result)
|
||||||
|
|
||||||
asyncio.run(main())
|
|
||||||
# Output: Protect humans, obey, self-preserve, prioritized.
|
# Output: Protect humans, obey, self-preserve, prioritized.
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -95,12 +92,10 @@ from agent_framework import Message, Role
|
|||||||
async def main():
|
async def main():
|
||||||
client = OpenAIChatClient()
|
client = OpenAIChatClient()
|
||||||
|
|
||||||
messages = [
|
response = await client.get_response([
|
||||||
Message("system", ["You are a helpful assistant."]),
|
Message("system", ["You are a helpful assistant."]),
|
||||||
Message("user", ["Write a haiku about Agent Framework."])
|
Message("user", ["Write a haiku about Agent Framework."])
|
||||||
]
|
])
|
||||||
|
|
||||||
response = await client.get_response(messages)
|
|
||||||
print(response.messages[0].text)
|
print(response.messages[0].text)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@@ -122,13 +117,12 @@ Enhance your agent with custom tools and function calling:
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from random import randint
|
from random import randint
|
||||||
from pydantic import Field
|
|
||||||
from agent_framework import Agent
|
from agent_framework import Agent
|
||||||
from agent_framework.openai import OpenAIChatClient
|
from agent_framework.openai import OpenAIChatClient
|
||||||
|
|
||||||
|
|
||||||
def get_weather(
|
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:
|
) -> str:
|
||||||
"""Get the weather for a given location."""
|
"""Get the weather for a given location."""
|
||||||
conditions = ["sunny", "cloudy", "rainy", "stormy"]
|
conditions = ["sunny", "cloudy", "rainy", "stormy"]
|
||||||
@@ -161,7 +155,7 @@ async def main():
|
|||||||
asyncio.run(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
|
## 5. Multi-Agent Orchestration
|
||||||
|
|
||||||
@@ -213,14 +207,14 @@ if __name__ == "__main__":
|
|||||||
asyncio.run(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
|
## More Examples & Samples
|
||||||
|
|
||||||
- [Getting Started with Agents](../../samples/02-agents): Basic agent creation and tool usage
|
- [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](../../samples/02-agents/chat_client): Direct chat client usage patterns
|
- [Chat Client Examples](https://github.com/microsoft/agent-framework/tree/main/python/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
|
- [Foundry Integration](https://github.com/microsoft/agent-framework/tree/main/python/packages/foundry): Foundry integration
|
||||||
- [Workflows Samples](../../samples/03-workflows): Advanced multi-agent patterns
|
- [Workflows Samples](https://github.com/microsoft/agent-framework/tree/main/python/samples/03-workflows): Advanced multi-agent patterns
|
||||||
|
|
||||||
## Agent Framework Documentation
|
## Agent Framework Documentation
|
||||||
|
|
||||||
@@ -228,4 +222,4 @@ if __name__ == "__main__":
|
|||||||
- [Python Package Documentation](https://github.com/microsoft/agent-framework/tree/main/python)
|
- [Python Package Documentation](https://github.com/microsoft/agent-framework/tree/main/python)
|
||||||
- [.NET Package Documentation](https://github.com/microsoft/agent-framework/tree/main/dotnet)
|
- [.NET Package Documentation](https://github.com/microsoft/agent-framework/tree/main/dotnet)
|
||||||
- [Design Documents](https://github.com/microsoft/agent-framework/tree/main/docs/design)
|
- [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/)
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ from ._evaluation import (
|
|||||||
tool_called_check,
|
tool_called_check,
|
||||||
tool_calls_present,
|
tool_calls_present,
|
||||||
)
|
)
|
||||||
|
from ._feature_stage import ExperimentalFeature, ReleaseCandidateFeature
|
||||||
from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPWebsocketTool
|
from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPWebsocketTool
|
||||||
from ._middleware import (
|
from ._middleware import (
|
||||||
AgentContext,
|
AgentContext,
|
||||||
@@ -314,6 +315,7 @@ __all__ = [
|
|||||||
"Evaluator",
|
"Evaluator",
|
||||||
"Executor",
|
"Executor",
|
||||||
"ExpectedToolCall",
|
"ExpectedToolCall",
|
||||||
|
"ExperimentalFeature",
|
||||||
"FanInEdgeGroup",
|
"FanInEdgeGroup",
|
||||||
"FanOutEdgeGroup",
|
"FanOutEdgeGroup",
|
||||||
"FileCheckpointStorage",
|
"FileCheckpointStorage",
|
||||||
@@ -344,6 +346,7 @@ __all__ = [
|
|||||||
"OuterFinalT",
|
"OuterFinalT",
|
||||||
"OuterUpdateT",
|
"OuterUpdateT",
|
||||||
"RawAgent",
|
"RawAgent",
|
||||||
|
"ReleaseCandidateFeature",
|
||||||
"ResponseStream",
|
"ResponseStream",
|
||||||
"Role",
|
"Role",
|
||||||
"RoleLiteral",
|
"RoleLiteral",
|
||||||
|
|||||||
@@ -231,8 +231,7 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
|
|||||||
streaming and non-streaming responses.
|
streaming and non-streaming responses.
|
||||||
|
|
||||||
For full-featured clients with middleware, telemetry, and function invocation support,
|
For full-featured clients with middleware, telemetry, and function invocation support,
|
||||||
use the public client classes (e.g., ``OpenAIChatClient``, ``OpenAIResponsesClient``)
|
use public client classes such as ``OpenAIChatClient`` which compose these layers correctly.
|
||||||
which compose these layers correctly.
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
|
import textwrap
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
_GOOGLE_SECTION_HEADERS = (
|
_GOOGLE_SECTION_HEADERS = (
|
||||||
"Args:",
|
"Args:",
|
||||||
"Keyword Args:",
|
"Keyword Args:",
|
||||||
|
"Attributes:",
|
||||||
"Returns:",
|
"Returns:",
|
||||||
"Raises:",
|
"Raises:",
|
||||||
"Examples:",
|
"Examples:",
|
||||||
@@ -45,6 +47,29 @@ def _format_keyword_arg_lines(extra_keyword_args: Mapping[str, str]) -> list[str
|
|||||||
return formatted_lines
|
return formatted_lines
|
||||||
|
|
||||||
|
|
||||||
|
def insert_docstring_block(docstring: str | None, *, block: str) -> str | None:
|
||||||
|
"""Insert a preformatted block before the first Google-style section."""
|
||||||
|
cleaned_block = textwrap.dedent(block).strip()
|
||||||
|
if not cleaned_block:
|
||||||
|
return docstring
|
||||||
|
if not docstring:
|
||||||
|
return cleaned_block
|
||||||
|
|
||||||
|
lines = inspect.cleandoc(docstring).splitlines()
|
||||||
|
block_lines = cleaned_block.splitlines()
|
||||||
|
insert_index = _find_next_section_index(lines, 0)
|
||||||
|
|
||||||
|
insertion: list[str] = []
|
||||||
|
if insert_index > 0 and lines[insert_index - 1] != "":
|
||||||
|
insertion.append("")
|
||||||
|
insertion.extend(block_lines)
|
||||||
|
if insert_index < len(lines) and insertion[-1] != "":
|
||||||
|
insertion.append("")
|
||||||
|
|
||||||
|
lines[insert_index:insert_index] = insertion
|
||||||
|
return "\n".join(lines).rstrip()
|
||||||
|
|
||||||
|
|
||||||
def build_layered_docstring(
|
def build_layered_docstring(
|
||||||
source: Callable[..., Any],
|
source: Callable[..., Any],
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
# Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio.coroutines
|
||||||
|
import functools
|
||||||
|
import inspect
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
from collections.abc import Callable
|
||||||
|
from enum import Enum
|
||||||
|
from types import MethodType
|
||||||
|
from typing import Any, Literal, TypeVar, cast
|
||||||
|
|
||||||
|
from ._docstrings import insert_docstring_block
|
||||||
|
|
||||||
|
FeatureStageT = TypeVar("FeatureStageT", bound=Callable[..., Any])
|
||||||
|
|
||||||
|
FeatureStageName = Literal["experimental", "release_candidate"]
|
||||||
|
|
||||||
|
# Optional feature-stage metadata for warnings and best-effort introspection.
|
||||||
|
_FEATURE_ID_ATTR = "__feature_id__"
|
||||||
|
_FEATURE_STAGE_ATTR = "__feature_stage__"
|
||||||
|
_WARNED_FEATURES: set[tuple[type[Warning], str]] = set()
|
||||||
|
_EXPERIMENTAL_DOCSTRING = """\
|
||||||
|
.. warning:: Experimental
|
||||||
|
|
||||||
|
This API is experimental and subject to change or removal
|
||||||
|
in future versions without notice.
|
||||||
|
"""
|
||||||
|
_RELEASE_CANDIDATE_DOCSTRING = """\
|
||||||
|
.. note:: Release candidate
|
||||||
|
|
||||||
|
This API is in release-candidate stage and may receive
|
||||||
|
minor refinements before it is considered generally available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ExperimentalFeature(str, Enum):
|
||||||
|
"""Current experimental feature IDs.
|
||||||
|
|
||||||
|
This enum is a stage-scoped inventory, not a stable introspection surface.
|
||||||
|
Members may move or be removed as features advance. The `__feature_id__`
|
||||||
|
attribute is also optional stage metadata and may disappear when a feature
|
||||||
|
is released, so consumer code should use `getattr(...)` rather than relying
|
||||||
|
on enum membership or attribute presence over time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SKILLS = "SKILLS"
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseCandidateFeature(str, Enum):
|
||||||
|
"""Current release-candidate feature IDs.
|
||||||
|
|
||||||
|
This enum is a stage-scoped inventory, not a stable introspection surface.
|
||||||
|
Members may move or be removed as features advance. The `__feature_id__`
|
||||||
|
attribute is also optional stage metadata and may disappear when a feature
|
||||||
|
is released, so consumer code should use `getattr(...)` rather than relying
|
||||||
|
on enum membership or attribute presence over time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureStageWarning(FutureWarning):
|
||||||
|
"""Base warning category for staged APIs."""
|
||||||
|
|
||||||
|
|
||||||
|
class ExperimentalWarning(FeatureStageWarning):
|
||||||
|
"""Warning emitted when an experimental API is used."""
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_feature_id(feature_id: str | Enum) -> str:
|
||||||
|
return str(feature_id.value if isinstance(feature_id, Enum) else feature_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_object_name(obj: Any) -> str:
|
||||||
|
return str(getattr(obj, "__qualname__", getattr(obj, "__name__", type(obj).__name__)))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_descriptor_callable(obj: Any) -> Callable[..., Any]:
|
||||||
|
return cast(Callable[..., Any], obj.__func__)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_protocol_class(obj: Any) -> bool:
|
||||||
|
return isinstance(obj, type) and bool(getattr(obj, "_is_protocol", False))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_stage_warning_message(*, stage: FeatureStageName, feature_id: str, object_name: str) -> str:
|
||||||
|
if stage == "experimental":
|
||||||
|
return (
|
||||||
|
f"[{feature_id}] {object_name} is experimental and may change or be removed in future versions "
|
||||||
|
"without notice."
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"[{feature_id}] {object_name} is in release-candidate stage and may receive minor refinements before it is "
|
||||||
|
"considered generally available."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_feature_stage_metadata(obj: Any, *, stage: FeatureStageName, feature_id: str) -> None:
|
||||||
|
setattr(obj, _FEATURE_STAGE_ATTR, stage)
|
||||||
|
setattr(obj, _FEATURE_ID_ATTR, feature_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_on_feature_use(
|
||||||
|
*,
|
||||||
|
stage: FeatureStageName,
|
||||||
|
feature_id: str,
|
||||||
|
object_name: str,
|
||||||
|
category: type[Warning],
|
||||||
|
stacklevel: int,
|
||||||
|
) -> None:
|
||||||
|
warning_key = (category, feature_id)
|
||||||
|
if warning_key in _WARNED_FEATURES:
|
||||||
|
return
|
||||||
|
|
||||||
|
warnings.warn(
|
||||||
|
_build_stage_warning_message(stage=stage, feature_id=feature_id, object_name=object_name),
|
||||||
|
category=category,
|
||||||
|
stacklevel=stacklevel,
|
||||||
|
)
|
||||||
|
_WARNED_FEATURES.add(warning_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_runtime_warning(
|
||||||
|
obj: FeatureStageT,
|
||||||
|
*,
|
||||||
|
stage: FeatureStageName,
|
||||||
|
feature_id: str,
|
||||||
|
category: type[Warning],
|
||||||
|
) -> FeatureStageT:
|
||||||
|
object_name = _get_object_name(obj)
|
||||||
|
|
||||||
|
if isinstance(obj, type):
|
||||||
|
experimental_class = cast(type[Any], obj)
|
||||||
|
original_new: Any = experimental_class.__new__
|
||||||
|
|
||||||
|
@functools.wraps(original_new)
|
||||||
|
def __new__(cls: type[Any], /, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
if cls is experimental_class:
|
||||||
|
_warn_on_feature_use(
|
||||||
|
stage=stage,
|
||||||
|
feature_id=feature_id,
|
||||||
|
object_name=object_name,
|
||||||
|
category=category,
|
||||||
|
stacklevel=3,
|
||||||
|
)
|
||||||
|
if original_new is not object.__new__:
|
||||||
|
return original_new(cls, *args, **kwargs)
|
||||||
|
if cls.__init__ is object.__init__ and (args or kwargs):
|
||||||
|
raise TypeError(f"{cls.__name__}() takes no arguments")
|
||||||
|
return original_new(cls)
|
||||||
|
|
||||||
|
experimental_class.__new__ = staticmethod(__new__) # type: ignore[assignment]
|
||||||
|
|
||||||
|
original_init_subclass: Any = experimental_class.__init_subclass__
|
||||||
|
if isinstance(original_init_subclass, MethodType):
|
||||||
|
original_init_subclass_func = original_init_subclass.__func__
|
||||||
|
|
||||||
|
@functools.wraps(original_init_subclass_func)
|
||||||
|
def bound_init_subclass_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
_warn_on_feature_use(
|
||||||
|
stage=stage,
|
||||||
|
feature_id=feature_id,
|
||||||
|
object_name=object_name,
|
||||||
|
category=category,
|
||||||
|
stacklevel=3,
|
||||||
|
)
|
||||||
|
return original_init_subclass_func(*args, **kwargs)
|
||||||
|
|
||||||
|
experimental_class.__init_subclass__ = classmethod(bound_init_subclass_wrapper) # type: ignore[assignment]
|
||||||
|
else:
|
||||||
|
|
||||||
|
@functools.wraps(original_init_subclass)
|
||||||
|
def init_subclass_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
_warn_on_feature_use(
|
||||||
|
stage=stage,
|
||||||
|
feature_id=feature_id,
|
||||||
|
object_name=object_name,
|
||||||
|
category=category,
|
||||||
|
stacklevel=3,
|
||||||
|
)
|
||||||
|
return original_init_subclass(*args, **kwargs)
|
||||||
|
|
||||||
|
experimental_class.__init_subclass__ = init_subclass_wrapper # type: ignore[assignment]
|
||||||
|
|
||||||
|
return cast(FeatureStageT, experimental_class)
|
||||||
|
|
||||||
|
@functools.wraps(obj)
|
||||||
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
_warn_on_feature_use(
|
||||||
|
stage=stage,
|
||||||
|
feature_id=feature_id,
|
||||||
|
object_name=object_name,
|
||||||
|
category=category,
|
||||||
|
stacklevel=3,
|
||||||
|
)
|
||||||
|
return obj(*args, **kwargs)
|
||||||
|
|
||||||
|
if inspect.iscoroutinefunction(obj):
|
||||||
|
if sys.version_info >= (3, 12):
|
||||||
|
wrapper = inspect.markcoroutinefunction(wrapper)
|
||||||
|
else:
|
||||||
|
wrapper._is_coroutine = asyncio.coroutines._is_coroutine # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
return cast(FeatureStageT, wrapper)
|
||||||
|
|
||||||
|
|
||||||
|
def _feature_stage(
|
||||||
|
*,
|
||||||
|
stage: FeatureStageName,
|
||||||
|
feature_id: str | Enum,
|
||||||
|
docstring_block: str,
|
||||||
|
warning_category: type[Warning] | None,
|
||||||
|
) -> Callable[[FeatureStageT], FeatureStageT]:
|
||||||
|
normalized_feature_id = _normalize_feature_id(feature_id)
|
||||||
|
|
||||||
|
def decorator(obj: FeatureStageT) -> FeatureStageT:
|
||||||
|
descriptor_wrapper: Callable[[Any], Any] | None = None
|
||||||
|
target: Any = obj
|
||||||
|
|
||||||
|
if isinstance(obj, staticmethod):
|
||||||
|
descriptor_wrapper = staticmethod
|
||||||
|
target = _get_descriptor_callable(obj)
|
||||||
|
elif isinstance(obj, classmethod):
|
||||||
|
descriptor_wrapper = classmethod
|
||||||
|
target = _get_descriptor_callable(obj)
|
||||||
|
|
||||||
|
if not callable(target):
|
||||||
|
raise TypeError(f"{stage} decorator can only be applied to classes and callables, not {obj!r}.")
|
||||||
|
|
||||||
|
is_protocol_class = _is_protocol_class(target)
|
||||||
|
decorated: Any = target
|
||||||
|
if warning_category is not None and not is_protocol_class:
|
||||||
|
decorated = _add_runtime_warning(
|
||||||
|
target,
|
||||||
|
stage=stage,
|
||||||
|
feature_id=normalized_feature_id,
|
||||||
|
category=warning_category,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_docstring = insert_docstring_block(decorated.__doc__, block=docstring_block)
|
||||||
|
if updated_docstring is not None:
|
||||||
|
decorated.__doc__ = updated_docstring
|
||||||
|
|
||||||
|
# runtime_checkable Protocol classes treat added class attributes as protocol members
|
||||||
|
# on older Python versions, which breaks isinstance/issubclass checks.
|
||||||
|
if not is_protocol_class:
|
||||||
|
_set_feature_stage_metadata(decorated, stage=stage, feature_id=normalized_feature_id)
|
||||||
|
if descriptor_wrapper is not None:
|
||||||
|
return cast(FeatureStageT, descriptor_wrapper(decorated))
|
||||||
|
|
||||||
|
return cast(FeatureStageT, decorated)
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def experimental(*, feature_id: ExperimentalFeature) -> Callable[[FeatureStageT], FeatureStageT]:
|
||||||
|
"""Mark a class or callable as experimental."""
|
||||||
|
return _feature_stage(
|
||||||
|
stage="experimental",
|
||||||
|
feature_id=feature_id,
|
||||||
|
docstring_block=_EXPERIMENTAL_DOCSTRING,
|
||||||
|
warning_category=ExperimentalWarning,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def release_candidate(
|
||||||
|
*,
|
||||||
|
feature_id: ReleaseCandidateFeature,
|
||||||
|
) -> Callable[[FeatureStageT], FeatureStageT]:
|
||||||
|
"""Mark a class or callable as release-candidate."""
|
||||||
|
return _feature_stage(
|
||||||
|
stage="release_candidate",
|
||||||
|
feature_id=feature_id,
|
||||||
|
docstring_block=_RELEASE_CANDIDATE_DOCSTRING,
|
||||||
|
warning_category=None,
|
||||||
|
)
|
||||||
@@ -425,8 +425,8 @@ class SerializationMixin:
|
|||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
|
||||||
# OpenAI chat client requires an AsyncOpenAI client instance
|
# OpenAI chat client requires an AsyncOpenAI client instance.
|
||||||
# The client is marked as INJECTABLE = {"client"} in OpenAIBase
|
# The client dependency is excluded from serialization.
|
||||||
|
|
||||||
# Serialized data contains only the model configuration
|
# Serialized data contains only the model configuration
|
||||||
client_data = {
|
client_data = {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from html import escape as xml_escape
|
|||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar, Final, Protocol, runtime_checkable
|
from typing import TYPE_CHECKING, Any, ClassVar, Final, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from ._feature_stage import ExperimentalFeature, experimental
|
||||||
from ._sessions import BaseContextProvider
|
from ._sessions import BaseContextProvider
|
||||||
from ._tools import FunctionTool
|
from ._tools import FunctionTool
|
||||||
|
|
||||||
@@ -47,14 +48,10 @@ logger = logging.getLogger(__name__)
|
|||||||
# region Models
|
# region Models
|
||||||
|
|
||||||
|
|
||||||
|
@experimental(feature_id=ExperimentalFeature.SKILLS)
|
||||||
class SkillResource:
|
class SkillResource:
|
||||||
"""A named piece of supplementary content attached to a skill.
|
"""A named piece of supplementary content attached to a skill.
|
||||||
|
|
||||||
.. warning:: Experimental
|
|
||||||
|
|
||||||
This API is experimental and subject to change or removal
|
|
||||||
in future versions without notice.
|
|
||||||
|
|
||||||
A resource provides data that an agent can retrieve on demand. It holds
|
A resource provides data that an agent can retrieve on demand. It holds
|
||||||
either a static ``content`` string or a ``function`` that produces content
|
either a static ``content`` string or a ``function`` that produces content
|
||||||
dynamically (sync or async). Exactly one must be provided.
|
dynamically (sync or async). Exactly one must be provided.
|
||||||
@@ -117,14 +114,10 @@ class SkillResource:
|
|||||||
self._accepts_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
|
self._accepts_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
|
||||||
|
|
||||||
|
|
||||||
|
@experimental(feature_id=ExperimentalFeature.SKILLS)
|
||||||
class SkillScript:
|
class SkillScript:
|
||||||
"""An executable script attached to a skill.
|
"""An executable script attached to a skill.
|
||||||
|
|
||||||
.. warning:: Experimental
|
|
||||||
|
|
||||||
This API is experimental and subject to change or removal
|
|
||||||
in future versions without notice.
|
|
||||||
|
|
||||||
A script represents executable code that an agent can run. It holds
|
A script represents executable code that an agent can run. It holds
|
||||||
either an inline ``function`` callable (code-defined scripts) or
|
either an inline ``function`` callable (code-defined scripts) or
|
||||||
a ``path`` to a script file on disk (file-based scripts).
|
a ``path`` to a script file on disk (file-based scripts).
|
||||||
@@ -202,11 +195,6 @@ class SkillScript:
|
|||||||
def parameters_schema(self) -> dict[str, Any] | None:
|
def parameters_schema(self) -> dict[str, Any] | None:
|
||||||
"""JSON Schema describing the script's parameters.
|
"""JSON Schema describing the script's parameters.
|
||||||
|
|
||||||
.. warning:: Experimental
|
|
||||||
|
|
||||||
This API is experimental and subject to change or removal
|
|
||||||
in future versions without notice.
|
|
||||||
|
|
||||||
Lazily generated from the callable's signature on first access.
|
Lazily generated from the callable's signature on first access.
|
||||||
Returns ``None`` for file-based scripts or functions with no
|
Returns ``None`` for file-based scripts or functions with no
|
||||||
introspectable parameters.
|
introspectable parameters.
|
||||||
@@ -219,14 +207,10 @@ class SkillScript:
|
|||||||
return self._parameters_schema
|
return self._parameters_schema
|
||||||
|
|
||||||
|
|
||||||
|
@experimental(feature_id=ExperimentalFeature.SKILLS)
|
||||||
class Skill:
|
class Skill:
|
||||||
"""A skill definition with optional resources.
|
"""A skill definition with optional resources.
|
||||||
|
|
||||||
.. warning:: Experimental
|
|
||||||
|
|
||||||
This API is experimental and subject to change or removal
|
|
||||||
in future versions without notice.
|
|
||||||
|
|
||||||
A skill bundles a set of instructions (``content``) with metadata and
|
A skill bundles a set of instructions (``content``) with metadata and
|
||||||
zero or more :class:`SkillResource` and :class:`SkillScript` instances.
|
zero or more :class:`SkillResource` and :class:`SkillScript` instances.
|
||||||
Resources and scripts can be supplied at construction time or added later
|
Resources and scripts can be supplied at construction time or added later
|
||||||
@@ -432,14 +416,10 @@ class Skill:
|
|||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
|
@experimental(feature_id=ExperimentalFeature.SKILLS)
|
||||||
class SkillScriptRunner(Protocol):
|
class SkillScriptRunner(Protocol):
|
||||||
"""Protocol for skill script runners.
|
"""Protocol for skill script runners.
|
||||||
|
|
||||||
.. warning:: Experimental
|
|
||||||
|
|
||||||
This API is experimental and subject to change or removal
|
|
||||||
in future versions without notice.
|
|
||||||
|
|
||||||
A script runner determines how **file-based** skill scripts are
|
A script runner determines how **file-based** skill scripts are
|
||||||
run. Implementations decide the execution strategy
|
run. Implementations decide the execution strategy
|
||||||
(e.g., local subprocess, hosted code execution environment,
|
(e.g., local subprocess, hosted code execution environment,
|
||||||
@@ -538,14 +518,10 @@ SCRIPT_RUNNER_INSTRUCTIONS: Final[str] = (
|
|||||||
# region SkillsProvider
|
# region SkillsProvider
|
||||||
|
|
||||||
|
|
||||||
|
@experimental(feature_id=ExperimentalFeature.SKILLS)
|
||||||
class SkillsProvider(BaseContextProvider):
|
class SkillsProvider(BaseContextProvider):
|
||||||
"""Context provider that advertises skills and exposes skill tools.
|
"""Context provider that advertises skills and exposes skill tools.
|
||||||
|
|
||||||
.. warning:: Experimental
|
|
||||||
|
|
||||||
This API is experimental and subject to change or removal
|
|
||||||
in future versions without notice.
|
|
||||||
|
|
||||||
Supports both **file-based** skills (discovered from ``SKILL.md`` files)
|
Supports both **file-based** skills (discovered from ``SKILL.md`` files)
|
||||||
and **code-defined** skills (passed as :class:`Skill` instances).
|
and **code-defined** skills (passed as :class:`Skill` instances).
|
||||||
|
|
||||||
|
|||||||
@@ -251,21 +251,6 @@ class AgentExecutor(Executor):
|
|||||||
Returns:
|
Returns:
|
||||||
Dict containing serialized cache and session state
|
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()
|
serialized_session = self._session.to_dict()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -12,26 +12,11 @@ _IMPORTS: dict[str, tuple[str, str]] = {
|
|||||||
"AgentCallbackContext": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
"AgentCallbackContext": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||||
"AgentFunctionApp": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"),
|
"AgentFunctionApp": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"),
|
||||||
"AgentResponseCallbackProtocol": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
"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"),
|
"AzureAISearchContextProvider": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"),
|
||||||
"AzureAISearchSettings": ("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"),
|
"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"),
|
"AzureCredentialTypes": ("agent_framework_azure_ai", "agent-framework-azure-ai"),
|
||||||
"AzureTokenProvider": ("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"),
|
"DurableAIAgent": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||||
"DurableAIAgentClient": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
"DurableAIAgentClient": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||||
"DurableAIAgentOrchestrationContext": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
"DurableAIAgentOrchestrationContext": ("agent_framework_durabletask", "agent-framework-durabletask"),
|
||||||
|
|||||||
@@ -4,24 +4,9 @@
|
|||||||
# Install the relevant packages for full type support.
|
# Install the relevant packages for full type support.
|
||||||
|
|
||||||
from agent_framework_azure_ai import (
|
from agent_framework_azure_ai import (
|
||||||
AzureAIAgentClient,
|
|
||||||
AzureAIAgentsProvider,
|
|
||||||
AzureAIClient,
|
|
||||||
AzureAIProjectAgentOptions,
|
|
||||||
AzureAIProjectAgentProvider,
|
|
||||||
AzureAISettings,
|
AzureAISettings,
|
||||||
AzureCredentialTypes,
|
AzureCredentialTypes,
|
||||||
AzureOpenAIAssistantsClient,
|
|
||||||
AzureOpenAIAssistantsOptions,
|
|
||||||
AzureOpenAIChatClient,
|
|
||||||
AzureOpenAIChatOptions,
|
|
||||||
AzureOpenAIEmbeddingClient,
|
|
||||||
AzureOpenAIResponsesClient,
|
|
||||||
AzureOpenAIResponsesOptions,
|
|
||||||
AzureOpenAISettings,
|
|
||||||
AzureTokenProvider,
|
AzureTokenProvider,
|
||||||
AzureUserSecurityContext,
|
|
||||||
RawAzureAIClient,
|
|
||||||
)
|
)
|
||||||
from agent_framework_azure_ai_search import (
|
from agent_framework_azure_ai_search import (
|
||||||
AzureAISearchContextProvider,
|
AzureAISearchContextProvider,
|
||||||
@@ -41,28 +26,13 @@ __all__ = [
|
|||||||
"AgentCallbackContext",
|
"AgentCallbackContext",
|
||||||
"AgentFunctionApp",
|
"AgentFunctionApp",
|
||||||
"AgentResponseCallbackProtocol",
|
"AgentResponseCallbackProtocol",
|
||||||
"AzureAIAgentClient",
|
|
||||||
"AzureAIAgentsProvider",
|
|
||||||
"AzureAIClient",
|
|
||||||
"AzureAIProjectAgentOptions",
|
|
||||||
"AzureAIProjectAgentProvider",
|
|
||||||
"AzureAISearchContextProvider",
|
"AzureAISearchContextProvider",
|
||||||
"AzureAISearchSettings",
|
"AzureAISearchSettings",
|
||||||
"AzureAISettings",
|
"AzureAISettings",
|
||||||
"AzureCredentialTypes",
|
"AzureCredentialTypes",
|
||||||
"AzureOpenAIAssistantsClient",
|
|
||||||
"AzureOpenAIAssistantsOptions",
|
|
||||||
"AzureOpenAIChatClient",
|
|
||||||
"AzureOpenAIChatOptions",
|
|
||||||
"AzureOpenAIEmbeddingClient",
|
|
||||||
"AzureOpenAIResponsesClient",
|
|
||||||
"AzureOpenAIResponsesOptions",
|
|
||||||
"AzureOpenAISettings",
|
|
||||||
"AzureTokenProvider",
|
"AzureTokenProvider",
|
||||||
"AzureUserSecurityContext",
|
|
||||||
"DurableAIAgent",
|
"DurableAIAgent",
|
||||||
"DurableAIAgentClient",
|
"DurableAIAgentClient",
|
||||||
"DurableAIAgentOrchestrationContext",
|
"DurableAIAgentOrchestrationContext",
|
||||||
"DurableAIAgentWorker",
|
"DurableAIAgentWorker",
|
||||||
"RawAzureAIClient",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ Supported classes include:
|
|||||||
- OpenAIChatClient (Responses API)
|
- OpenAIChatClient (Responses API)
|
||||||
- OpenAIChatCompletionClient (Chat Completions API)
|
- OpenAIChatCompletionClient (Chat Completions API)
|
||||||
- OpenAIEmbeddingClient
|
- OpenAIEmbeddingClient
|
||||||
- OpenAIAssistantsClient (deprecated)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
@@ -28,13 +27,6 @@ _IMPORTS: dict[str, tuple[str, str]] = {
|
|||||||
"OpenAISettings": ("agent_framework_openai", "agent-framework-openai"),
|
"OpenAISettings": ("agent_framework_openai", "agent-framework-openai"),
|
||||||
"ContentFilterResultSeverity": ("agent_framework_openai", "agent-framework-openai"),
|
"ContentFilterResultSeverity": ("agent_framework_openai", "agent-framework-openai"),
|
||||||
"OpenAIContentFilterException": ("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"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,7 @@
|
|||||||
# Install agent-framework-openai for full type support.
|
# Install agent-framework-openai for full type support.
|
||||||
|
|
||||||
from agent_framework_openai import (
|
from agent_framework_openai import (
|
||||||
AssistantToolResources,
|
|
||||||
ContentFilterResultSeverity,
|
ContentFilterResultSeverity,
|
||||||
OpenAIAssistantProvider,
|
|
||||||
OpenAIAssistantsClient,
|
|
||||||
OpenAIAssistantsOptions,
|
|
||||||
OpenAIChatClient,
|
OpenAIChatClient,
|
||||||
OpenAIChatCompletionClient,
|
OpenAIChatCompletionClient,
|
||||||
OpenAIChatCompletionOptions,
|
OpenAIChatCompletionOptions,
|
||||||
@@ -17,20 +13,13 @@ from agent_framework_openai import (
|
|||||||
OpenAIContinuationToken,
|
OpenAIContinuationToken,
|
||||||
OpenAIEmbeddingClient,
|
OpenAIEmbeddingClient,
|
||||||
OpenAIEmbeddingOptions,
|
OpenAIEmbeddingOptions,
|
||||||
OpenAIResponsesClient,
|
|
||||||
OpenAIResponsesOptions,
|
|
||||||
OpenAISettings,
|
OpenAISettings,
|
||||||
RawOpenAIChatClient,
|
RawOpenAIChatClient,
|
||||||
RawOpenAIChatCompletionClient,
|
RawOpenAIChatCompletionClient,
|
||||||
RawOpenAIResponsesClient,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AssistantToolResources",
|
|
||||||
"ContentFilterResultSeverity",
|
"ContentFilterResultSeverity",
|
||||||
"OpenAIAssistantProvider",
|
|
||||||
"OpenAIAssistantsClient",
|
|
||||||
"OpenAIAssistantsOptions",
|
|
||||||
"OpenAIChatClient",
|
"OpenAIChatClient",
|
||||||
"OpenAIChatCompletionClient",
|
"OpenAIChatCompletionClient",
|
||||||
"OpenAIChatCompletionOptions",
|
"OpenAIChatCompletionOptions",
|
||||||
@@ -39,10 +28,7 @@ __all__ = [
|
|||||||
"OpenAIContinuationToken",
|
"OpenAIContinuationToken",
|
||||||
"OpenAIEmbeddingClient",
|
"OpenAIEmbeddingClient",
|
||||||
"OpenAIEmbeddingOptions",
|
"OpenAIEmbeddingOptions",
|
||||||
"OpenAIResponsesClient",
|
|
||||||
"OpenAIResponsesOptions",
|
|
||||||
"OpenAISettings",
|
"OpenAISettings",
|
||||||
"RawOpenAIChatClient",
|
"RawOpenAIChatClient",
|
||||||
"RawOpenAIChatCompletionClient",
|
"RawOpenAIChatCompletionClient",
|
||||||
"RawOpenAIResponsesClient",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
import warnings
|
||||||
from collections.abc import AsyncIterable, Awaitable, MutableSequence, Sequence
|
from collections.abc import AsyncIterable, Awaitable, MutableSequence, Sequence
|
||||||
from typing import Any, Generic
|
from typing import Any, Generic
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -10,7 +11,13 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from pytest import fixture
|
from pytest import fixture
|
||||||
|
|
||||||
from agent_framework import (
|
warnings.filterwarnings(
|
||||||
|
"ignore",
|
||||||
|
message=r"\[SKILLS\].*",
|
||||||
|
category=FutureWarning,
|
||||||
|
)
|
||||||
|
|
||||||
|
from agent_framework import ( # noqa: E402
|
||||||
AgentResponse,
|
AgentResponse,
|
||||||
AgentResponseUpdate,
|
AgentResponseUpdate,
|
||||||
AgentSession,
|
AgentSession,
|
||||||
@@ -26,8 +33,8 @@ from agent_framework import (
|
|||||||
SupportsAgentRun,
|
SupportsAgentRun,
|
||||||
tool,
|
tool,
|
||||||
)
|
)
|
||||||
from agent_framework._clients import OptionsCoT
|
from agent_framework._clients import OptionsCoT # noqa: E402
|
||||||
from agent_framework.observability import ChatTelemetryLayer
|
from agent_framework.observability import ChatTelemetryLayer # noqa: E402
|
||||||
|
|
||||||
if sys.version_info >= (3, 12):
|
if sys.version_info >= (3, 12):
|
||||||
from typing import override # type: ignore
|
from typing import override # type: ignore
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Copyright (c) Microsoft. All rights reserved.
|
# Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
from agent_framework._docstrings import apply_layered_docstring, build_layered_docstring
|
from agent_framework._docstrings import apply_layered_docstring, build_layered_docstring, insert_docstring_block
|
||||||
|
|
||||||
# -- Helpers: stub functions with various docstring shapes --
|
# -- Helpers: stub functions with various docstring shapes --
|
||||||
|
|
||||||
@@ -36,6 +36,14 @@ def _source_no_sections() -> None:
|
|||||||
"""A plain summary with no Google-style sections."""
|
"""A plain summary with no Google-style sections."""
|
||||||
|
|
||||||
|
|
||||||
|
def _source_with_attributes() -> None:
|
||||||
|
"""A documented object.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
value: A documented attribute.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _source_no_docstring() -> None:
|
def _source_no_docstring() -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -141,6 +149,67 @@ def test_build_preserves_multiple_extra_kwargs_order() -> None:
|
|||||||
assert alpha_idx < beta_idx < gamma_idx
|
assert alpha_idx < beta_idx < gamma_idx
|
||||||
|
|
||||||
|
|
||||||
|
# -- insert_docstring_block tests --
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_docstring_block_before_args_section() -> None:
|
||||||
|
result = insert_docstring_block(
|
||||||
|
_source_with_args_only.__doc__,
|
||||||
|
block="""\
|
||||||
|
.. warning:: Experimental
|
||||||
|
|
||||||
|
This API is experimental.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
lines = result.splitlines()
|
||||||
|
warning_index = next(i for i, line in enumerate(lines) if line == ".. warning:: Experimental")
|
||||||
|
args_index = next(i for i, line in enumerate(lines) if line == "Args:")
|
||||||
|
assert warning_index < args_index
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_docstring_block_before_attributes_section() -> None:
|
||||||
|
result = insert_docstring_block(
|
||||||
|
_source_with_attributes.__doc__,
|
||||||
|
block="""\
|
||||||
|
.. warning:: Experimental
|
||||||
|
|
||||||
|
This API is experimental.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
lines = result.splitlines()
|
||||||
|
warning_index = next(i for i, line in enumerate(lines) if line == ".. warning:: Experimental")
|
||||||
|
attributes_index = next(i for i, line in enumerate(lines) if line == "Attributes:")
|
||||||
|
assert warning_index < attributes_index
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_docstring_block_appends_when_no_sections() -> None:
|
||||||
|
result = insert_docstring_block(
|
||||||
|
_source_no_sections.__doc__,
|
||||||
|
block="""\
|
||||||
|
.. note:: Release candidate
|
||||||
|
|
||||||
|
This API is nearly final.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert result.endswith("This API is nearly final.")
|
||||||
|
assert ".. note:: Release candidate" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_docstring_block_returns_block_for_missing_docstring() -> None:
|
||||||
|
result = insert_docstring_block(
|
||||||
|
_source_no_docstring.__doc__,
|
||||||
|
block="""\
|
||||||
|
.. warning:: Experimental
|
||||||
|
|
||||||
|
This API is experimental.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert result == ".. warning:: Experimental\n\n This API is experimental."
|
||||||
|
|
||||||
|
|
||||||
# -- apply_layered_docstring tests --
|
# -- apply_layered_docstring tests --
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
# Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import warnings
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agent_framework import ExperimentalFeature as PublicExperimentalFeature
|
||||||
|
from agent_framework import ReleaseCandidateFeature as PublicReleaseCandidateFeature
|
||||||
|
from agent_framework._feature_stage import (
|
||||||
|
_WARNED_FEATURES,
|
||||||
|
ExperimentalWarning,
|
||||||
|
_feature_stage,
|
||||||
|
experimental,
|
||||||
|
release_candidate,
|
||||||
|
)
|
||||||
|
from agent_framework._feature_stage import (
|
||||||
|
ExperimentalFeature as InternalExperimentalFeature,
|
||||||
|
)
|
||||||
|
from agent_framework._feature_stage import (
|
||||||
|
ReleaseCandidateFeature as InternalReleaseCandidateFeature,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AlternateExperimentalFeature(str, Enum):
|
||||||
|
EXPERIMENTAL_FEATURE = "EXPERIMENTAL_FEATURE"
|
||||||
|
SHARED_FEATURE = "SHARED_EXPERIMENTAL_FEATURE"
|
||||||
|
ALTERNATE_FEATURE = "ALTERNATE_EXPERIMENTAL_FEATURE"
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidStageFeature(str, Enum):
|
||||||
|
LOWERCASE = "skills"
|
||||||
|
|
||||||
|
|
||||||
|
class NonStringFeature(Enum):
|
||||||
|
INTEGER = 1
|
||||||
|
|
||||||
|
|
||||||
|
class HelperReleaseCandidateFeature(str, Enum):
|
||||||
|
RC_FEATURE = "RC_FEATURE"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_feature_warning_state() -> None:
|
||||||
|
_WARNED_FEATURES.clear()
|
||||||
|
yield
|
||||||
|
_WARNED_FEATURES.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_feature_enums_are_exposed_from_root() -> None:
|
||||||
|
assert PublicExperimentalFeature is InternalExperimentalFeature
|
||||||
|
assert PublicReleaseCandidateFeature is InternalReleaseCandidateFeature
|
||||||
|
|
||||||
|
|
||||||
|
def test_experimental_decorator_accepts_feature_enum() -> None:
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
|
||||||
|
@experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type]
|
||||||
|
def skill_function() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert not caught
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
skill_function()
|
||||||
|
|
||||||
|
assert len(caught) == 1
|
||||||
|
assert f"[{AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value}]" in str(caught[0].message)
|
||||||
|
assert "skill_function" in str(caught[0].message)
|
||||||
|
assert skill_function.__feature_id__ == AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value
|
||||||
|
|
||||||
|
|
||||||
|
def test_experimental_function_warns_on_call_and_not_on_definition() -> None:
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
|
||||||
|
@experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type]
|
||||||
|
def my_function(value: int) -> int:
|
||||||
|
"""Double the input.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Value to double.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The doubled value.
|
||||||
|
"""
|
||||||
|
return value * 2
|
||||||
|
|
||||||
|
assert not caught
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
assert my_function(3) == 6
|
||||||
|
assert my_function(4) == 8
|
||||||
|
|
||||||
|
assert len(caught) == 1
|
||||||
|
assert f"[{AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value}]" in str(caught[0].message)
|
||||||
|
assert "my_function" in str(caught[0].message)
|
||||||
|
assert my_function.__feature_stage__ == "experimental"
|
||||||
|
assert my_function.__feature_id__ == AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value
|
||||||
|
assert my_function.__doc__ is not None
|
||||||
|
lines = my_function.__doc__.splitlines()
|
||||||
|
warning_index = next(i for i, line in enumerate(lines) if line == ".. warning:: Experimental")
|
||||||
|
args_index = next(i for i, line in enumerate(lines) if line == "Args:")
|
||||||
|
assert warning_index < args_index
|
||||||
|
|
||||||
|
|
||||||
|
def test_experimental_class_warns_on_instantiation_and_not_on_definition() -> None:
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
|
||||||
|
@experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type]
|
||||||
|
class ExperimentalClass:
|
||||||
|
"""An experimental class.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Value to store.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, value: int) -> None:
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
assert not caught
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
instantiation_line = inspect.currentframe().f_lineno + 1
|
||||||
|
instance = ExperimentalClass(4)
|
||||||
|
second_instance = ExperimentalClass(5)
|
||||||
|
|
||||||
|
assert len(caught) == 1
|
||||||
|
assert f"[{AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value}]" in str(caught[0].message)
|
||||||
|
assert "ExperimentalClass" in str(caught[0].message)
|
||||||
|
assert caught[0].filename == __file__
|
||||||
|
assert caught[0].lineno == instantiation_line
|
||||||
|
assert instance.value == 4
|
||||||
|
assert second_instance.value == 5
|
||||||
|
assert ExperimentalClass.__feature_stage__ == "experimental"
|
||||||
|
assert ExperimentalClass.__feature_id__ == AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value
|
||||||
|
|
||||||
|
|
||||||
|
def test_experimental_runtime_checkable_protocol_keeps_protocol_runtime_checks() -> None:
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
@experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type]
|
||||||
|
class ExampleProtocol(Protocol):
|
||||||
|
"""A protocol used for runtime checks.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __call__(self, value: int) -> int: ...
|
||||||
|
|
||||||
|
assert not caught
|
||||||
|
|
||||||
|
def implementation(value: int) -> int:
|
||||||
|
return value
|
||||||
|
|
||||||
|
assert isinstance(implementation, ExampleProtocol)
|
||||||
|
assert ExampleProtocol.__doc__ is not None
|
||||||
|
assert ".. warning:: Experimental" in ExampleProtocol.__doc__
|
||||||
|
assert getattr(ExampleProtocol, "__feature_stage__", None) is None
|
||||||
|
assert getattr(ExampleProtocol, "__feature_id__", None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_experimental_warning_is_emitted_once_per_feature() -> None:
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
|
||||||
|
@experimental(feature_id=AlternateExperimentalFeature.SHARED_FEATURE) # type: ignore[arg-type]
|
||||||
|
def first() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@experimental(feature_id=AlternateExperimentalFeature.SHARED_FEATURE) # type: ignore[arg-type]
|
||||||
|
class Second:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert not caught
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
first()
|
||||||
|
Second()
|
||||||
|
|
||||||
|
assert first is not None
|
||||||
|
assert Second is not None
|
||||||
|
assert len(caught) == 1
|
||||||
|
assert f"[{AlternateExperimentalFeature.SHARED_FEATURE.value}]" in str(caught[0].message)
|
||||||
|
assert "first" in str(caught[0].message)
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_candidate_internal_helper_adds_metadata_without_runtime_warning() -> None:
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
|
||||||
|
@_feature_stage(
|
||||||
|
stage="release_candidate",
|
||||||
|
feature_id=HelperReleaseCandidateFeature.RC_FEATURE,
|
||||||
|
docstring_block="""\
|
||||||
|
.. note:: Release candidate
|
||||||
|
|
||||||
|
This API is in release-candidate stage and may receive
|
||||||
|
minor refinements before it is considered generally available.
|
||||||
|
""",
|
||||||
|
warning_category=None,
|
||||||
|
)
|
||||||
|
class ReleaseCandidateClass:
|
||||||
|
"""A release-candidate class.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Value to store.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, value: int) -> None:
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
assert not caught
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
instance = ReleaseCandidateClass(5)
|
||||||
|
|
||||||
|
assert instance.value == 5
|
||||||
|
assert not caught
|
||||||
|
assert ReleaseCandidateClass.__feature_stage__ == "release_candidate"
|
||||||
|
assert ReleaseCandidateClass.__feature_id__ == HelperReleaseCandidateFeature.RC_FEATURE.value
|
||||||
|
assert ReleaseCandidateClass.__doc__ is not None
|
||||||
|
assert ".. note:: Release candidate" in ReleaseCandidateClass.__doc__
|
||||||
|
|
||||||
|
|
||||||
|
def test_experimental_property_warns_on_access_and_not_on_definition() -> None:
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
|
||||||
|
class Example:
|
||||||
|
@property
|
||||||
|
@experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type]
|
||||||
|
def value(self) -> int:
|
||||||
|
"""Return the value.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The stored value.
|
||||||
|
"""
|
||||||
|
return 1
|
||||||
|
|
||||||
|
assert not caught
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
assert Example().value == 1
|
||||||
|
assert Example().value == 1
|
||||||
|
|
||||||
|
assert len(caught) == 1
|
||||||
|
assert f"[{AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value}]" in str(caught[0].message)
|
||||||
|
assert "Example.value" in str(caught[0].message)
|
||||||
|
assert Example.value.__doc__ is not None
|
||||||
|
lines = Example.value.__doc__.splitlines()
|
||||||
|
warning_index = next(i for i, line in enumerate(lines) if line == ".. warning:: Experimental")
|
||||||
|
returns_index = next(i for i, line in enumerate(lines) if line == "Returns:")
|
||||||
|
assert warning_index < returns_index
|
||||||
|
|
||||||
|
|
||||||
|
def test_experimental_staticmethod_warns_when_decorator_wraps_descriptor() -> None:
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
|
||||||
|
class Example:
|
||||||
|
@experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type]
|
||||||
|
@staticmethod
|
||||||
|
def value() -> int:
|
||||||
|
"""Return the value.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The stored value.
|
||||||
|
"""
|
||||||
|
return 1
|
||||||
|
|
||||||
|
assert not caught
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
assert Example.value() == 1
|
||||||
|
assert Example.value() == 1
|
||||||
|
|
||||||
|
assert len(caught) == 1
|
||||||
|
assert f"[{AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value}]" in str(caught[0].message)
|
||||||
|
assert "Example.value" in str(caught[0].message)
|
||||||
|
assert Example.value.__feature_id__ == AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value
|
||||||
|
assert Example.value.__doc__ is not None
|
||||||
|
lines = Example.value.__doc__.splitlines()
|
||||||
|
warning_index = next(i for i, line in enumerate(lines) if line == ".. warning:: Experimental")
|
||||||
|
returns_index = next(i for i, line in enumerate(lines) if line == "Returns:")
|
||||||
|
assert warning_index < returns_index
|
||||||
|
|
||||||
|
|
||||||
|
def test_experimental_classmethod_warns_when_decorator_wraps_descriptor() -> None:
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
|
||||||
|
class Example:
|
||||||
|
@experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type]
|
||||||
|
@classmethod
|
||||||
|
def value(cls) -> int:
|
||||||
|
"""Return the value.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The stored value.
|
||||||
|
"""
|
||||||
|
return 1
|
||||||
|
|
||||||
|
assert not caught
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
assert Example.value() == 1
|
||||||
|
assert Example.value() == 1
|
||||||
|
|
||||||
|
assert len(caught) == 1
|
||||||
|
assert f"[{AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value}]" in str(caught[0].message)
|
||||||
|
assert "Example.value" in str(caught[0].message)
|
||||||
|
assert Example.value.__func__.__feature_id__ == AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value
|
||||||
|
assert Example.value.__doc__ is not None
|
||||||
|
lines = Example.value.__doc__.splitlines()
|
||||||
|
warning_index = next(i for i, line in enumerate(lines) if line == ".. warning:: Experimental")
|
||||||
|
returns_index = next(i for i, line in enumerate(lines) if line == "Returns:")
|
||||||
|
assert warning_index < returns_index
|
||||||
|
|
||||||
|
|
||||||
|
def test_feature_id_allows_lowercase_values() -> None:
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
|
||||||
|
@_feature_stage(
|
||||||
|
stage="experimental",
|
||||||
|
feature_id=InvalidStageFeature.LOWERCASE,
|
||||||
|
docstring_block=".. warning:: Experimental",
|
||||||
|
warning_category=ExperimentalWarning,
|
||||||
|
)
|
||||||
|
def lowercase_feature() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert not caught
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
lowercase_feature()
|
||||||
|
|
||||||
|
assert len(caught) == 1
|
||||||
|
assert "[skills]" in str(caught[0].message)
|
||||||
|
assert "lowercase_feature" in str(caught[0].message)
|
||||||
|
assert lowercase_feature.__feature_id__ == "skills"
|
||||||
|
|
||||||
|
|
||||||
|
def test_experimental_decorator_allows_string_feature_id_at_runtime() -> None:
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
|
||||||
|
@experimental(feature_id="STRING_FEATURE") # type: ignore[arg-type]
|
||||||
|
def skill_function() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert not caught
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
skill_function()
|
||||||
|
|
||||||
|
assert len(caught) == 1
|
||||||
|
assert "[STRING_FEATURE]" in str(caught[0].message)
|
||||||
|
assert "skill_function" in str(caught[0].message)
|
||||||
|
assert skill_function.__feature_id__ == "STRING_FEATURE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_experimental_decorator_allows_other_enum_values_at_runtime() -> None:
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
|
||||||
|
@experimental(feature_id=AlternateExperimentalFeature.ALTERNATE_FEATURE) # type: ignore[arg-type]
|
||||||
|
def my_function() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert not caught
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
my_function()
|
||||||
|
|
||||||
|
assert len(caught) == 1
|
||||||
|
assert f"[{AlternateExperimentalFeature.ALTERNATE_FEATURE.value}]" in str(caught[0].message)
|
||||||
|
assert "my_function" in str(caught[0].message)
|
||||||
|
assert my_function.__feature_id__ == AlternateExperimentalFeature.ALTERNATE_FEATURE.value
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_candidate_decorator_allows_string_feature_id_at_runtime() -> None:
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
|
||||||
|
@release_candidate(feature_id="RC_FEATURE") # type: ignore[arg-type]
|
||||||
|
class ReleaseCandidateClass:
|
||||||
|
"""A release-candidate class."""
|
||||||
|
|
||||||
|
assert not caught
|
||||||
|
assert ReleaseCandidateClass.__feature_stage__ == "release_candidate"
|
||||||
|
assert ReleaseCandidateClass.__feature_id__ == "RC_FEATURE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_feature_id_stringifies_non_string_enum_values() -> None:
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
|
||||||
|
@_feature_stage(
|
||||||
|
stage="experimental",
|
||||||
|
feature_id=NonStringFeature.INTEGER,
|
||||||
|
docstring_block=".. warning:: Experimental",
|
||||||
|
warning_category=ExperimentalWarning,
|
||||||
|
)
|
||||||
|
def numeric_feature() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert not caught
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
numeric_feature()
|
||||||
|
|
||||||
|
assert len(caught) == 1
|
||||||
|
assert "[1]" in str(caught[0].message)
|
||||||
|
assert "numeric_feature" in str(caught[0].message)
|
||||||
|
assert numeric_feature.__feature_id__ == "1"
|
||||||
@@ -11,7 +11,7 @@ from unittest.mock import AsyncMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from agent_framework import SessionContext, Skill, SkillResource, SkillsProvider
|
from agent_framework import SessionContext, Skill, SkillResource, SkillScript, SkillScriptRunner, SkillsProvider
|
||||||
from agent_framework._skills import (
|
from agent_framework._skills import (
|
||||||
DEFAULT_RESOURCE_EXTENSIONS,
|
DEFAULT_RESOURCE_EXTENSIONS,
|
||||||
DEFAULT_SCRIPT_EXTENSIONS,
|
DEFAULT_SCRIPT_EXTENSIONS,
|
||||||
@@ -32,6 +32,8 @@ from agent_framework._skills import (
|
|||||||
_validate_skill_metadata,
|
_validate_skill_metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.filterwarnings(r"ignore:\[SKILLS\].*:FutureWarning")
|
||||||
|
|
||||||
|
|
||||||
async def _noop_script_runner(skill: Any, script: Any, args: Any = None) -> None:
|
async def _noop_script_runner(skill: Any, script: Any, args: Any = None) -> None:
|
||||||
"""No-op script runner for tests that need a SkillScriptRunner."""
|
"""No-op script runner for tests that need a SkillScriptRunner."""
|
||||||
@@ -778,6 +780,44 @@ class TestSymlinkDetection:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkillsExperimentalStage:
|
||||||
|
"""Tests for the experimental stage annotations applied to skills APIs."""
|
||||||
|
|
||||||
|
def test_docstrings_include_experimental_warning(self) -> None:
|
||||||
|
assert SkillResource.__doc__ is not None
|
||||||
|
assert SkillScript.__doc__ is not None
|
||||||
|
assert Skill.__doc__ is not None
|
||||||
|
assert SkillScriptRunner.__doc__ is not None
|
||||||
|
assert SkillsProvider.__doc__ is not None
|
||||||
|
assert SkillScript.parameters_schema.__doc__ is not None
|
||||||
|
|
||||||
|
assert ".. warning:: Experimental" in SkillResource.__doc__
|
||||||
|
assert ".. warning:: Experimental" in SkillScript.__doc__
|
||||||
|
assert ".. warning:: Experimental" in Skill.__doc__
|
||||||
|
assert ".. warning:: Experimental" in SkillScriptRunner.__doc__
|
||||||
|
assert ".. warning:: Experimental" in SkillsProvider.__doc__
|
||||||
|
assert ".. warning:: Experimental" not in SkillScript.parameters_schema.__doc__
|
||||||
|
|
||||||
|
def test_feature_metadata_is_set(self) -> None:
|
||||||
|
assert SkillResource.__feature_stage__ == "experimental"
|
||||||
|
assert SkillScript.__feature_stage__ == "experimental"
|
||||||
|
assert Skill.__feature_stage__ == "experimental"
|
||||||
|
assert SkillsProvider.__feature_stage__ == "experimental"
|
||||||
|
feature_ids = [
|
||||||
|
SkillResource.__feature_id__,
|
||||||
|
SkillScript.__feature_id__,
|
||||||
|
Skill.__feature_id__,
|
||||||
|
SkillsProvider.__feature_id__,
|
||||||
|
]
|
||||||
|
assert all(isinstance(feature_id, str) and feature_id for feature_id in feature_ids)
|
||||||
|
assert len(set(feature_ids)) == 1
|
||||||
|
assert getattr(SkillScriptRunner, "__feature_stage__", None) is None
|
||||||
|
assert getattr(SkillScriptRunner, "__feature_id__", None) is None
|
||||||
|
assert SkillScript.parameters_schema.fget is not None
|
||||||
|
assert not hasattr(SkillScript.parameters_schema.fget, "__feature_stage__")
|
||||||
|
assert not hasattr(SkillScript.parameters_schema.fget, "__feature_id__")
|
||||||
|
|
||||||
|
|
||||||
class TestSkillResource:
|
class TestSkillResource:
|
||||||
"""Tests for SkillResource dataclass."""
|
"""Tests for SkillResource dataclass."""
|
||||||
|
|
||||||
@@ -1839,40 +1879,28 @@ class TestSkillScript:
|
|||||||
"""Tests for the SkillScript data model."""
|
"""Tests for the SkillScript data model."""
|
||||||
|
|
||||||
def test_empty_name_raises(self) -> None:
|
def test_empty_name_raises(self) -> None:
|
||||||
from agent_framework import SkillScript
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Script name cannot be empty"):
|
with pytest.raises(ValueError, match="Script name cannot be empty"):
|
||||||
SkillScript(name="")
|
SkillScript(name="")
|
||||||
|
|
||||||
def test_whitespace_name_raises(self) -> None:
|
def test_whitespace_name_raises(self) -> None:
|
||||||
from agent_framework import SkillScript
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Script name cannot be empty"):
|
with pytest.raises(ValueError, match="Script name cannot be empty"):
|
||||||
SkillScript(name=" ")
|
SkillScript(name=" ")
|
||||||
|
|
||||||
def test_path_default_none(self) -> None:
|
def test_path_default_none(self) -> None:
|
||||||
from agent_framework import SkillScript
|
|
||||||
|
|
||||||
script = SkillScript(name="test", function=lambda: None)
|
script = SkillScript(name="test", function=lambda: None)
|
||||||
assert script.path is None
|
assert script.path is None
|
||||||
|
|
||||||
def test_path_set_explicitly(self) -> None:
|
def test_path_set_explicitly(self) -> None:
|
||||||
from agent_framework import SkillScript
|
|
||||||
|
|
||||||
script = SkillScript(name="gen.py", path="/skills/my-skill/scripts/gen.py")
|
script = SkillScript(name="gen.py", path="/skills/my-skill/scripts/gen.py")
|
||||||
assert script.path == "/skills/my-skill/scripts/gen.py"
|
assert script.path == "/skills/my-skill/scripts/gen.py"
|
||||||
|
|
||||||
def test_create_with_function(self) -> None:
|
def test_create_with_function(self) -> None:
|
||||||
from agent_framework import SkillScript
|
|
||||||
|
|
||||||
script = SkillScript(name="analyze", description="Run analysis", function=lambda: "result")
|
script = SkillScript(name="analyze", description="Run analysis", function=lambda: "result")
|
||||||
assert script.name == "analyze"
|
assert script.name == "analyze"
|
||||||
assert script.description == "Run analysis"
|
assert script.description == "Run analysis"
|
||||||
assert script.function is not None
|
assert script.function is not None
|
||||||
|
|
||||||
def test_accepts_kwargs_true_for_kwargs_function(self) -> None:
|
def test_accepts_kwargs_true_for_kwargs_function(self) -> None:
|
||||||
from agent_framework import SkillScript
|
|
||||||
|
|
||||||
def func_with_kwargs(**kwargs: Any) -> str:
|
def func_with_kwargs(**kwargs: Any) -> str:
|
||||||
return "result"
|
return "result"
|
||||||
|
|
||||||
@@ -1880,8 +1908,6 @@ class TestSkillScript:
|
|||||||
assert script._accepts_kwargs is True
|
assert script._accepts_kwargs is True
|
||||||
|
|
||||||
def test_accepts_kwargs_false_for_regular_function(self) -> None:
|
def test_accepts_kwargs_false_for_regular_function(self) -> None:
|
||||||
from agent_framework import SkillScript
|
|
||||||
|
|
||||||
def func_no_kwargs(x: int = 0) -> str:
|
def func_no_kwargs(x: int = 0) -> str:
|
||||||
return "result"
|
return "result"
|
||||||
|
|
||||||
|
|||||||
@@ -47,58 +47,73 @@ class ProviderTypeMapping(TypedDict, total=True):
|
|||||||
package: str
|
package: str
|
||||||
name: str
|
name: str
|
||||||
model_id_field: str
|
model_id_field: str
|
||||||
|
endpoint_field: str | None
|
||||||
|
api_key_field: str | None
|
||||||
|
|
||||||
|
|
||||||
PROVIDER_TYPE_OBJECT_MAPPING: dict[str, ProviderTypeMapping] = {
|
PROVIDER_TYPE_OBJECT_MAPPING: dict[str, ProviderTypeMapping] = {
|
||||||
"AzureOpenAI.Chat": {
|
"AzureOpenAI": {
|
||||||
"package": "agent_framework.azure",
|
"package": "agent_framework.openai",
|
||||||
"name": "AzureOpenAIChatClient",
|
"name": "OpenAIChatClient",
|
||||||
"model_id_field": "deployment_name",
|
"model_id_field": "model",
|
||||||
|
"endpoint_field": "azure_endpoint",
|
||||||
|
"api_key_field": "api_key",
|
||||||
},
|
},
|
||||||
"AzureOpenAI.Assistants": {
|
"AzureOpenAI.Chat": {
|
||||||
"package": "agent_framework.azure",
|
"package": "agent_framework.openai",
|
||||||
"name": "AzureOpenAIAssistantsClient",
|
"name": "OpenAIChatCompletionClient",
|
||||||
"model_id_field": "deployment_name",
|
"model_id_field": "model",
|
||||||
|
"endpoint_field": "azure_endpoint",
|
||||||
|
"api_key_field": "api_key",
|
||||||
},
|
},
|
||||||
"AzureOpenAI.Responses": {
|
"AzureOpenAI.Responses": {
|
||||||
"package": "agent_framework.azure",
|
"package": "agent_framework.openai",
|
||||||
"name": "AzureOpenAIResponsesClient",
|
"name": "OpenAIChatClient",
|
||||||
"model_id_field": "deployment_name",
|
"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": {
|
"OpenAI.Chat": {
|
||||||
"package": "agent_framework.openai",
|
"package": "agent_framework.openai",
|
||||||
"name": "OpenAIChatClient",
|
"name": "OpenAIChatClient",
|
||||||
"model_id_field": "model_id",
|
"model_id_field": "model",
|
||||||
},
|
"endpoint_field": "base_url",
|
||||||
"OpenAI.Assistants": {
|
"api_key_field": "api_key",
|
||||||
"package": "agent_framework.openai",
|
|
||||||
"name": "OpenAIAssistantsClient",
|
|
||||||
"model_id_field": "model_id",
|
|
||||||
},
|
},
|
||||||
"OpenAI.Responses": {
|
"OpenAI.Responses": {
|
||||||
"package": "agent_framework.openai",
|
"package": "agent_framework.openai",
|
||||||
"name": "OpenAIResponsesClient",
|
"name": "OpenAIChatClient",
|
||||||
"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",
|
|
||||||
"model_id_field": "model",
|
"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": {
|
"Anthropic.Chat": {
|
||||||
"package": "agent_framework.anthropic",
|
"package": "agent_framework.anthropic",
|
||||||
"name": "AnthropicChatClient",
|
"name": "AnthropicChatClient",
|
||||||
"model_id_field": "model_id",
|
"model_id_field": "model_id",
|
||||||
|
"endpoint_field": None,
|
||||||
|
"api_key_field": "api_key",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,11 +152,11 @@ class AgentFactory:
|
|||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
from agent_framework.azure import AzureOpenAIChatClient
|
from agent_framework.openai import OpenAIChatClient
|
||||||
from agent_framework_declarative import AgentFactory
|
from agent_framework_declarative import AgentFactory
|
||||||
|
|
||||||
# With pre-configured chat client
|
# With pre-configured chat client
|
||||||
client = AzureOpenAIChatClient()
|
client = OpenAIChatClient()
|
||||||
factory = AgentFactory(client=client)
|
factory = AgentFactory(client=client)
|
||||||
agent = factory.create_agent_from_yaml_path("agent.yaml")
|
agent = factory.create_agent_from_yaml_path("agent.yaml")
|
||||||
|
|
||||||
@@ -171,7 +186,7 @@ class AgentFactory:
|
|||||||
connections: Mapping[str, Any] | None = None,
|
connections: Mapping[str, Any] | None = None,
|
||||||
client_kwargs: Mapping[str, Any] | None = None,
|
client_kwargs: Mapping[str, Any] | None = None,
|
||||||
additional_mappings: Mapping[str, ProviderTypeMapping] | None = None,
|
additional_mappings: Mapping[str, ProviderTypeMapping] | None = None,
|
||||||
default_provider: str = "AzureAIClient",
|
default_provider: str = "OpenAI",
|
||||||
safe_mode: bool = True,
|
safe_mode: bool = True,
|
||||||
env_file_path: str | None = None,
|
env_file_path: str | None = None,
|
||||||
env_file_encoding: str | None = None,
|
env_file_encoding: str | None = None,
|
||||||
@@ -192,13 +207,15 @@ class AgentFactory:
|
|||||||
..code-block:: python
|
..code-block:: python
|
||||||
|
|
||||||
additional_mappings = {
|
additional_mappings = {
|
||||||
"Provider.ApiType": {
|
"Provider.ApiType": {
|
||||||
"package": "package.name",
|
"package": "package.name",
|
||||||
"name": "ClassName",
|
"name": "ClassName",
|
||||||
"model_id_field": "field_name_in_constructor",
|
"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
|
Here, "Provider.ApiType" is the lookup key used when both provider and apiType are specified in the
|
||||||
model, "Provider" is also allowed.
|
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
|
SupportsChatGetResponse implementation, and model_id_field is the name of the field in the
|
||||||
constructor that accepts the model.id value.
|
constructor that accepts the model.id value.
|
||||||
default_provider: The default provider used when model.provider is not specified,
|
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.
|
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.
|
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.
|
You can still use environment variables, but through the constructors of the classes.
|
||||||
@@ -227,11 +244,11 @@ class AgentFactory:
|
|||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
from agent_framework.azure import AzureOpenAIChatClient
|
from agent_framework.openai import OpenAIChatClient
|
||||||
from agent_framework_declarative import AgentFactory
|
from agent_framework_declarative import AgentFactory
|
||||||
|
|
||||||
# With shared chat client
|
# With shared chat client
|
||||||
client = AzureOpenAIChatClient()
|
client = OpenAIChatClient()
|
||||||
factory = AgentFactory(
|
factory = AgentFactory(
|
||||||
client=client,
|
client=client,
|
||||||
env_file_path=".env",
|
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 def create_agent_from_yaml_path_async(self, yaml_path: str | Path) -> Agent:
|
||||||
"""Async version: Create a Agent from a YAML file path.
|
"""Async version: Create a Agent from a YAML file path.
|
||||||
|
|
||||||
Use this method when the provider requires async initialization, such as
|
This is the async counterpart to ``create_agent_from_dict`` and is useful when
|
||||||
AzureAI.ProjectProvider which creates agents on the Azure AI Agent Service.
|
the rest of your setup is already async.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
yaml_path: Path to the YAML file representation of a PromptAgent.
|
yaml_path: Path to the YAML file representation of a PromptAgent.
|
||||||
@@ -473,7 +490,7 @@ class AgentFactory:
|
|||||||
|
|
||||||
factory = AgentFactory(
|
factory = AgentFactory(
|
||||||
client_kwargs={"credential": credential},
|
client_kwargs={"credential": credential},
|
||||||
default_provider="AzureAI.ProjectProvider",
|
default_provider="Foundry",
|
||||||
)
|
)
|
||||||
agent = await factory.create_agent_from_yaml_path_async("agent.yaml")
|
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 def create_agent_from_yaml_async(self, yaml_str: str) -> Agent:
|
||||||
"""Async version: Create a Agent from a YAML string.
|
"""Async version: Create a Agent from a YAML string.
|
||||||
|
|
||||||
Use this method when the provider requires async initialization, such as
|
Use this method when the surrounding call site is already async and you
|
||||||
AzureAI.ProjectProvider which creates agents on the Azure AI Agent Service.
|
want to build an agent directly from YAML text.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
yaml_str: YAML string representation of a PromptAgent.
|
yaml_str: YAML string representation of a PromptAgent.
|
||||||
@@ -507,7 +524,7 @@ class AgentFactory:
|
|||||||
instructions: You are a helpful assistant.
|
instructions: You are a helpful assistant.
|
||||||
model:
|
model:
|
||||||
id: gpt-4o
|
id: gpt-4o
|
||||||
provider: AzureAI.ProjectProvider
|
provider: Foundry
|
||||||
'''
|
'''
|
||||||
|
|
||||||
factory = AgentFactory(client_kwargs={"credential": credential})
|
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 def create_agent_from_dict_async(self, agent_def: dict[str, Any]) -> Agent:
|
||||||
"""Async version: Create a Agent from a dictionary definition.
|
"""Async version: Create a Agent from a dictionary definition.
|
||||||
|
|
||||||
Use this method when the provider requires async initialization, such as
|
This is the async counterpart to ``create_agent_from_dict`` and is useful when
|
||||||
AzureAI.ProjectProvider which creates agents on the Azure AI Agent Service.
|
the rest of your setup is already async.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
agent_def: Dictionary representation of a PromptAgent.
|
agent_def: Dictionary representation of a PromptAgent.
|
||||||
@@ -538,7 +555,7 @@ class AgentFactory:
|
|||||||
"instructions": "You are a helpful assistant.",
|
"instructions": "You are a helpful assistant.",
|
||||||
"model": {
|
"model": {
|
||||||
"id": "gpt-4o",
|
"id": "gpt-4o",
|
||||||
"provider": "AzureAI.ProjectProvider",
|
"provider": "Foundry",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,12 +568,6 @@ class AgentFactory:
|
|||||||
if not isinstance(prompt_agent, PromptAgent):
|
if not isinstance(prompt_agent, PromptAgent):
|
||||||
raise DeclarativeLoaderError("Only definitions for a PromptAgent are supported for agent creation.")
|
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)
|
client = self._get_client(prompt_agent)
|
||||||
chat_options = self._parse_chat_options(prompt_agent.model)
|
chat_options = self._parse_chat_options(prompt_agent.model)
|
||||||
if tools := self._parse_tools(prompt_agent.tools):
|
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:
|
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
|
This remains available as an internal escape hatch for provider-style custom mappings
|
||||||
agents on a remote service (like Azure AI Agent Service) and returns
|
that return a fully constructed ``Agent`` rather than a chat client.
|
||||||
Agent instances directly.
|
|
||||||
"""
|
"""
|
||||||
# Import the provider class
|
|
||||||
module_name = mapping["package"]
|
module_name = mapping["package"]
|
||||||
class_name = mapping["name"]
|
class_name = mapping["name"]
|
||||||
module = __import__(module_name, fromlist=[class_name])
|
module = __import__(module_name, fromlist=[class_name])
|
||||||
provider_class = getattr(module, class_name)
|
provider_class = getattr(module, class_name)
|
||||||
|
|
||||||
# Build provider kwargs from client_kwargs and connection info
|
|
||||||
provider_kwargs: dict[str, Any] = {}
|
provider_kwargs: dict[str, Any] = {}
|
||||||
provider_kwargs.update(self.client_kwargs)
|
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:
|
if prompt_agent.model and prompt_agent.model.connection:
|
||||||
match 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():
|
case ApiKeyConnection():
|
||||||
if prompt_agent.model.connection.endpoint:
|
if api_key_field:
|
||||||
provider_kwargs["project_endpoint"] = prompt_agent.model.connection.endpoint
|
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():
|
case ReferenceConnection():
|
||||||
# Reference connections are resolved by concrete providers when supported.
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Create the provider and use it to create the agent
|
|
||||||
provider = provider_class(**provider_kwargs)
|
provider = provider_class(**provider_kwargs)
|
||||||
|
|
||||||
# Parse tools
|
|
||||||
tools = self._parse_tools(prompt_agent.tools) if prompt_agent.tools else None
|
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
|
default_options: dict[str, Any] | None = None
|
||||||
if prompt_agent.outputSchema:
|
if prompt_agent.outputSchema:
|
||||||
default_options = {"response_format": prompt_agent.outputSchema.to_json_schema()}
|
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(
|
return cast(
|
||||||
Agent,
|
Agent,
|
||||||
await provider.create_agent(
|
await provider.create_agent(
|
||||||
@@ -637,18 +642,35 @@ class AgentFactory:
|
|||||||
"alternatively define a model in the PromptAgent."
|
"alternatively define a model in the PromptAgent."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mapping = self._retrieve_provider_configuration(prompt_agent.model)
|
||||||
setup_dict: dict[str, Any] = {}
|
setup_dict: dict[str, Any] = {}
|
||||||
setup_dict.update(self.client_kwargs)
|
setup_dict.update(self.client_kwargs)
|
||||||
|
endpoint_field = mapping.get("endpoint_field")
|
||||||
|
api_key_field = mapping.get("api_key_field", "api_key")
|
||||||
|
|
||||||
# parse connections
|
# parse connections
|
||||||
if prompt_agent.model.connection:
|
if prompt_agent.model.connection:
|
||||||
match prompt_agent.model.connection:
|
match prompt_agent.model.connection:
|
||||||
case ApiKeyConnection():
|
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:
|
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():
|
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():
|
case ReferenceConnection():
|
||||||
if not self.connections:
|
if not self.connections:
|
||||||
raise ValueError("Connections must be provided to resolve ReferenceConnection")
|
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."
|
"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
|
# 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"]
|
module_name = mapping["package"]
|
||||||
class_name = mapping["name"]
|
class_name = mapping["name"]
|
||||||
module = __import__(module_name, fromlist=[class_name])
|
module = __import__(module_name, fromlist=[class_name])
|
||||||
|
|||||||
@@ -70,11 +70,11 @@ class WorkflowFactory:
|
|||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
from agent_framework.azure import AzureOpenAIChatClient
|
from agent_framework.openai import OpenAIChatClient
|
||||||
from agent_framework.declarative import WorkflowFactory
|
from agent_framework.declarative import WorkflowFactory
|
||||||
|
|
||||||
# Pre-register agents for InvokeAzureAgent actions
|
# Pre-register agents for InvokeAzureAgent actions
|
||||||
client = AzureOpenAIChatClient()
|
client = OpenAIChatClient()
|
||||||
agent = client.as_agent(name="MyAgent", instructions="You are helpful.")
|
agent = client.as_agent(name="MyAgent", instructions="You are helpful.")
|
||||||
|
|
||||||
factory = WorkflowFactory(agents={"MyAgent": agent})
|
factory = WorkflowFactory(agents={"MyAgent": agent})
|
||||||
@@ -116,11 +116,11 @@ class WorkflowFactory:
|
|||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
from agent_framework.azure import AzureOpenAIChatClient
|
from agent_framework.openai import OpenAIChatClient
|
||||||
from agent_framework.declarative import WorkflowFactory
|
from agent_framework.declarative import WorkflowFactory
|
||||||
|
|
||||||
# With pre-registered agents
|
# With pre-registered agents
|
||||||
client = AzureOpenAIChatClient()
|
client = OpenAIChatClient()
|
||||||
agents = {
|
agents = {
|
||||||
"WriterAgent": client.as_agent(name="Writer", instructions="Write content."),
|
"WriterAgent": client.as_agent(name="Writer", instructions="Write content."),
|
||||||
"ReviewerAgent": client.as_agent(name="Reviewer", instructions="Review content."),
|
"ReviewerAgent": client.as_agent(name="Reviewer", instructions="Review content."),
|
||||||
@@ -535,10 +535,10 @@ class WorkflowFactory:
|
|||||||
Examples:
|
Examples:
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
from agent_framework.azure import AzureOpenAIChatClient
|
from agent_framework.openai import OpenAIChatClient
|
||||||
from agent_framework.declarative import WorkflowFactory
|
from agent_framework.declarative import WorkflowFactory
|
||||||
|
|
||||||
client = AzureOpenAIChatClient()
|
client = OpenAIChatClient()
|
||||||
|
|
||||||
# Method chaining to register multiple agents
|
# Method chaining to register multiple agents
|
||||||
factory = (
|
factory = (
|
||||||
|
|||||||
@@ -69,11 +69,11 @@ Register cleanup hooks to properly close credentials and resources on shutdown:
|
|||||||
```python
|
```python
|
||||||
from azure.identity.aio import DefaultAzureCredential
|
from azure.identity.aio import DefaultAzureCredential
|
||||||
from agent_framework import Agent
|
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
|
from agent_framework_devui import register_cleanup, serve
|
||||||
|
|
||||||
credential = DefaultAzureCredential()
|
credential = DefaultAzureCredential()
|
||||||
client = AzureOpenAIChatClient()
|
client = OpenAIChatCompletionClient()
|
||||||
agent = Agent(name="MyAgent", client=client)
|
agent = Agent(name="MyAgent", client=client)
|
||||||
|
|
||||||
# Register cleanup hook - credential will be closed on shutdown
|
# Register cleanup hook - credential will be closed on shutdown
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -37,7 +37,7 @@ OPENAI_CHAT_MODEL="gpt-4o-mini"
|
|||||||
|
|
||||||
# Or for Azure OpenAI
|
# Or for Azure OpenAI
|
||||||
AZURE_OPENAI_ENDPOINT="your-endpoint"
|
AZURE_OPENAI_ENDPOINT="your-endpoint"
|
||||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="your-deployment-name"
|
AZURE_OPENAI_DEPLOYMENT_NAME="your-deployment-name"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. Test DevUI
|
## 4. Test DevUI
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ services:
|
|||||||
# Or Azure OpenAI
|
# Or Azure OpenAI
|
||||||
- AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY}
|
- AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY}
|
||||||
- AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT}
|
- 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
|
# Optional: Enable instrumentation
|
||||||
- ENABLE_INSTRUMENTATION=\${ENABLE_INSTRUMENTATION:-false}
|
- ENABLE_INSTRUMENTATION=\${ENABLE_INSTRUMENTATION:-false}
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -41,13 +41,13 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [
|
|||||||
],
|
],
|
||||||
requiredEnvVars: [
|
requiredEnvVars: [
|
||||||
{
|
{
|
||||||
name: "AZURE_AI_PROJECT_ENDPOINT",
|
name: "FOUNDRY_PROJECT_ENDPOINT",
|
||||||
description: "Azure AI Foundry project endpoint URL",
|
description: "Azure AI Foundry project endpoint URL",
|
||||||
required: true,
|
required: true,
|
||||||
example: "https://your-project.api.azureml.ms",
|
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",
|
description: "Name of the deployed model in Azure AI Foundry",
|
||||||
required: true,
|
required: true,
|
||||||
example: "gpt-4o",
|
example: "gpt-4o",
|
||||||
@@ -78,7 +78,7 @@ export const SAMPLE_ENTITIES: SampleEntity[] = [
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME",
|
name: "AZURE_OPENAI_DEPLOYMENT_NAME",
|
||||||
description: "Name of the deployed model in Azure OpenAI",
|
description: "Name of the deployed model in Azure OpenAI",
|
||||||
required: true,
|
required: true,
|
||||||
example: "gpt-4o",
|
example: "gpt-4o",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Durable execution support for long-running agent workflows using Azure Durable F
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from agent_framework import Agent
|
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 agent_framework_durabletask import DurableAIAgentClient, DurableAIAgentWorker
|
||||||
from durabletask.client import TaskHubGrpcClient
|
from durabletask.client import TaskHubGrpcClient
|
||||||
from durabletask.worker import TaskHubGrpcWorker
|
from durabletask.worker import TaskHubGrpcWorker
|
||||||
@@ -45,7 +45,7 @@ dt_worker = TaskHubGrpcWorker(host_address="localhost:4001")
|
|||||||
agent_worker = DurableAIAgentWorker(dt_worker)
|
agent_worker = DurableAIAgentWorker(dt_worker)
|
||||||
|
|
||||||
# Create a chat client for the agent
|
# Create a chat client for the agent
|
||||||
chat_client = AzureOpenAIChatClient()
|
chat_client = OpenAIChatCompletionClient()
|
||||||
my_agent = Agent(client=chat_client, name="assistant")
|
my_agent = Agent(client=chat_client, name="assistant")
|
||||||
agent_worker.add_agent(my_agent)
|
agent_worker.add_agent(my_agent)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ The durable task integration lets you host Microsoft Agent Framework agents usin
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from agent_framework import Agent
|
from agent_framework import Agent
|
||||||
from agent_framework.azure import AzureOpenAIChatClient
|
from agent_framework.openai import OpenAIChatCompletionClient
|
||||||
from agent_framework_durabletask import DurableAIAgentWorker
|
from agent_framework_durabletask import DurableAIAgentWorker
|
||||||
from durabletask.worker import TaskHubGrpcWorker
|
from durabletask.worker import TaskHubGrpcWorker
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ from durabletask.worker import TaskHubGrpcWorker
|
|||||||
worker = TaskHubGrpcWorker(host_address="localhost:4001")
|
worker = TaskHubGrpcWorker(host_address="localhost:4001")
|
||||||
agent_worker = DurableAIAgentWorker(worker)
|
agent_worker = DurableAIAgentWorker(worker)
|
||||||
|
|
||||||
chat_client = AzureOpenAIChatClient()
|
chat_client = OpenAIChatCompletionClient()
|
||||||
my_agent = Agent(client=chat_client, name="assistant")
|
my_agent = Agent(client=chat_client, name="assistant")
|
||||||
agent_worker.add_agent(my_agent)
|
agent_worker.add_agent(my_agent)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class DurableAIAgentWorker:
|
|||||||
```python
|
```python
|
||||||
from durabletask.worker import TaskHubGrpcWorker
|
from durabletask.worker import TaskHubGrpcWorker
|
||||||
from agent_framework import Agent
|
from agent_framework import Agent
|
||||||
from agent_framework.azure import AzureOpenAIChatClient
|
from agent_framework.openai import OpenAIChatCompletionClient
|
||||||
from agent_framework_durabletask import DurableAIAgentWorker
|
from agent_framework_durabletask import DurableAIAgentWorker
|
||||||
|
|
||||||
# Create the underlying worker
|
# Create the underlying worker
|
||||||
@@ -41,7 +41,7 @@ class DurableAIAgentWorker:
|
|||||||
agent_worker = DurableAIAgentWorker(worker)
|
agent_worker = DurableAIAgentWorker(worker)
|
||||||
|
|
||||||
# Register agents
|
# Register agents
|
||||||
client = AzureOpenAIChatClient()
|
client = OpenAIChatCompletionClient()
|
||||||
my_agent = Agent(client=client, name="assistant")
|
my_agent = Agent(client=client, name="assistant")
|
||||||
agent_worker.add_agent(my_agent)
|
agent_worker.add_agent(my_agent)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Azure OpenAI Configuration
|
# Azure OpenAI Configuration
|
||||||
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
|
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
|
# Optional: Use Azure CLI authentication if not provided
|
||||||
# AZURE_OPENAI_API_KEY=your-api-key
|
# AZURE_OPENAI_API_KEY=your-api-key
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "agent-framework-foundry"
|
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"}]
|
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class GAIATelemetryConfig:
|
|||||||
|
|
||||||
Note:
|
Note:
|
||||||
For Azure Monitor integration, configure using environment variables
|
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.
|
before creating the GAIA instance.
|
||||||
"""
|
"""
|
||||||
self.enable_tracing = enable_tracing
|
self.enable_tracing = enable_tracing
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ This module provides a factory function to create an Azure AI agent
|
|||||||
configured for GAIA benchmark tasks.
|
configured for GAIA benchmark tasks.
|
||||||
|
|
||||||
Required Environment Variables:
|
Required Environment Variables:
|
||||||
AZURE_AI_PROJECT_ENDPOINT: Azure AI project endpoint URL
|
FOUNDRY_PROJECT_ENDPOINT: Azure AI project endpoint URL
|
||||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: Name of the model deployment to use
|
FOUNDRY_MODEL: Name of the model deployment to use
|
||||||
|
|
||||||
Optional Environment Variables:
|
Optional Environment Variables:
|
||||||
BING_CONNECTION_ID: ID of the Bing connection for web search
|
BING_CONNECTION_ID: ID of the Bing connection for web search
|
||||||
@@ -17,17 +17,18 @@ Authentication:
|
|||||||
Run `az login` before executing to authenticate.
|
Run `az login` before executing to authenticate.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
export AZURE_AI_PROJECT_ENDPOINT="https://your-project.azure.com"
|
export FOUNDRY_PROJECT_ENDPOINT="https://your-project.azure.com"
|
||||||
export AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o"
|
export FOUNDRY_MODEL="gpt-4o"
|
||||||
export BING_CONNECTION_ID="connection-id"
|
export BING_CONNECTION_ID="connection-id"
|
||||||
az login
|
az login
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from agent_framework import Agent
|
from agent_framework import Agent
|
||||||
from agent_framework.azure import AzureAIAgentClient
|
from agent_framework.foundry import FoundryChatClient
|
||||||
from azure.identity.aio import AzureCliCredential
|
from azure.identity.aio import AzureCliCredential
|
||||||
|
|
||||||
|
|
||||||
@@ -49,13 +50,17 @@ async def create_gaia_agent() -> AsyncIterator[Agent]:
|
|||||||
"""
|
"""
|
||||||
async with (
|
async with (
|
||||||
AzureCliCredential() as credential,
|
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",
|
name="GaiaAgent",
|
||||||
instructions="Solve tasks to your best ability. Use Bing Search to find "
|
instructions="Solve tasks to your best ability. Use Bing Search to find "
|
||||||
"information and Code Interpreter to perform calculations and data analysis.",
|
"information and Code Interpreter to perform calculations and data analysis.",
|
||||||
tools=[
|
tools=[
|
||||||
AzureAIAgentClient.get_web_search_tool(),
|
FoundryChatClient.get_web_search_tool(),
|
||||||
AzureAIAgentClient.get_code_interpreter_tool(),
|
FoundryChatClient.get_code_interpreter_tool(),
|
||||||
],
|
],
|
||||||
) as agent,
|
) as agent,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from collections.abc import AsyncIterator
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from agent_framework import Agent
|
from agent_framework import Agent
|
||||||
from agent_framework.openai import OpenAIResponsesClient
|
from agent_framework.openai import OpenAIChatClient
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -47,15 +47,15 @@ async def create_gaia_agent() -> AsyncIterator[Agent]:
|
|||||||
result = await agent.run("What is the capital of France?")
|
result = await agent.run("What is the capital of France?")
|
||||||
print(result.text)
|
print(result.text)
|
||||||
"""
|
"""
|
||||||
client = OpenAIResponsesClient()
|
client = OpenAIChatClient()
|
||||||
|
|
||||||
async with client.as_agent(
|
async with client.as_agent(
|
||||||
name="GaiaAgent",
|
name="GaiaAgent",
|
||||||
instructions="Solve tasks to your best ability. Use Web Search to find "
|
instructions="Solve tasks to your best ability. Use Web Search to find "
|
||||||
"information and Code Interpreter to perform calculations and data analysis.",
|
"information and Code Interpreter to perform calculations and data analysis.",
|
||||||
tools=[
|
tools=[
|
||||||
OpenAIResponsesClient.get_web_search_tool(),
|
OpenAIChatClient.get_web_search_tool(),
|
||||||
OpenAIResponsesClient.get_code_interpreter_tool(),
|
OpenAIChatClient.get_code_interpreter_tool(),
|
||||||
],
|
],
|
||||||
) as agent:
|
) as agent:
|
||||||
yield agent
|
yield agent
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
from unittest.mock import patch
|
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._types import Content, Message
|
||||||
from agent_framework_lab_tau2._message_utils import flip_messages, log_messages
|
from agent_framework_lab_tau2._message_utils import flip_messages, log_messages
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
|
|
||||||
from unittest.mock import patch
|
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 import InMemoryHistoryProvider
|
||||||
from agent_framework._types import Content, Message
|
from agent_framework._types import Content, Message
|
||||||
from agent_framework_lab_tau2._sliding_window import SlidingWindowHistoryProvider
|
from agent_framework_lab_tau2._sliding_window import SlidingWindowHistoryProvider
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
"""Tests for tau2 utils module."""
|
"""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 import Content, FunctionTool, Message
|
||||||
from agent_framework_lab_tau2._tau2_utils import (
|
from agent_framework_lab_tau2._tau2_utils import (
|
||||||
convert_agent_framework_messages_to_tau2_messages,
|
convert_agent_framework_messages_to_tau2_messages,
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ agent_framework_openai/
|
|||||||
├── _chat_completion_client.py # OpenAIChatCompletionClient (Chat Completions API) + RawOpenAIChatCompletionClient
|
├── _chat_completion_client.py # OpenAIChatCompletionClient (Chat Completions API) + RawOpenAIChatCompletionClient
|
||||||
├── _embedding_client.py # OpenAIEmbeddingClient
|
├── _embedding_client.py # OpenAIEmbeddingClient
|
||||||
├── _exceptions.py # OpenAI-specific exceptions
|
├── _exceptions.py # OpenAI-specific exceptions
|
||||||
├── _shared.py # OpenAIBase, OpenAIConfigMixin, OpenAISettings
|
└── _shared.py # OpenAISettings and shared config helpers
|
||||||
├── _assistants_client.py # OpenAIAssistantsClient (DEPRECATED)
|
|
||||||
└── _assistant_provider.py # OpenAIAssistantProvider (DEPRECATED)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Classes
|
## Key Classes
|
||||||
@@ -23,7 +21,6 @@ agent_framework_openai/
|
|||||||
| `OpenAIChatClient` | Responses API | Primary |
|
| `OpenAIChatClient` | Responses API | Primary |
|
||||||
| `OpenAIChatCompletionClient` | Chat Completions API | Primary |
|
| `OpenAIChatCompletionClient` | Chat Completions API | Primary |
|
||||||
| `OpenAIEmbeddingClient` | Embeddings API | Primary |
|
| `OpenAIEmbeddingClient` | Embeddings API | Primary |
|
||||||
| `OpenAIAssistantsClient` | Assistants API | Deprecated |
|
|
||||||
|
|
||||||
All clients follow the Raw + Full-Featured pattern (e.g., `RawOpenAIChatClient` + `OpenAIChatClient`).
|
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
|
- `agent-framework-core` — core abstractions
|
||||||
- `openai` — OpenAI Python SDK
|
- `openai` — OpenAI Python SDK
|
||||||
- `packaging` — version checking
|
|
||||||
|
|||||||
@@ -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
|
- `OpenAIChatCompletionClient` uses the Chat Completions API and is mainly for compatibility with
|
||||||
existing Chat Completions-based integrations.
|
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
|
## Environment variables
|
||||||
|
|
||||||
|
|||||||
@@ -7,19 +7,7 @@ including clients for the Responses API and Chat Completions API.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import importlib.metadata
|
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 (
|
from ._chat_client import (
|
||||||
OpenAIChatClient,
|
OpenAIChatClient,
|
||||||
OpenAIChatOptions,
|
OpenAIChatOptions,
|
||||||
@@ -40,35 +28,8 @@ try:
|
|||||||
except importlib.metadata.PackageNotFoundError:
|
except importlib.metadata.PackageNotFoundError:
|
||||||
__version__ = "0.0.0" # Fallback for development mode
|
__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__ = [
|
__all__ = [
|
||||||
"AssistantToolResources",
|
|
||||||
"ContentFilterResultSeverity",
|
"ContentFilterResultSeverity",
|
||||||
"OpenAIAssistantProvider",
|
|
||||||
"OpenAIAssistantsClient",
|
|
||||||
"OpenAIAssistantsOptions",
|
|
||||||
"OpenAIChatClient",
|
"OpenAIChatClient",
|
||||||
"OpenAIChatCompletionClient",
|
"OpenAIChatCompletionClient",
|
||||||
"OpenAIChatCompletionOptions",
|
"OpenAIChatCompletionOptions",
|
||||||
@@ -77,11 +38,8 @@ __all__ = [
|
|||||||
"OpenAIContinuationToken",
|
"OpenAIContinuationToken",
|
||||||
"OpenAIEmbeddingClient",
|
"OpenAIEmbeddingClient",
|
||||||
"OpenAIEmbeddingOptions",
|
"OpenAIEmbeddingOptions",
|
||||||
"OpenAIResponsesClient",
|
|
||||||
"OpenAIResponsesOptions",
|
|
||||||
"OpenAISettings",
|
"OpenAISettings",
|
||||||
"RawOpenAIChatClient",
|
"RawOpenAIChatClient",
|
||||||
"RawOpenAIChatCompletionClient",
|
"RawOpenAIChatCompletionClient",
|
||||||
"RawOpenAIResponsesClient",
|
|
||||||
"__version__",
|
"__version__",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
@@ -1235,7 +1235,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
|||||||
def _check_model_presence(self, options: dict[str, Any]) -> None:
|
def _check_model_presence(self, options: dict[str, Any]) -> None:
|
||||||
"""Check if the 'model' param is present, and if not raise a Error.
|
"""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 options.get("model"):
|
||||||
if not self.model:
|
if not self.model:
|
||||||
|
|||||||
@@ -2,17 +2,13 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence
|
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||||
from copy import copy
|
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._settings import SecretString, load_settings
|
||||||
from agent_framework._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent
|
from agent_framework._telemetry import APP_INFO, prepend_agent_framework_to_user_agent
|
||||||
from agent_framework._tools import FunctionTool
|
|
||||||
from agent_framework.exceptions import SettingNotFoundError
|
from agent_framework.exceptions import SettingNotFoundError
|
||||||
from openai import AsyncAzureOpenAI, AsyncOpenAI, AsyncStream, _legacy_response # type: ignore
|
from openai import AsyncAzureOpenAI, AsyncOpenAI, AsyncStream, _legacy_response # type: ignore
|
||||||
from openai.types import Completion
|
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.images_response import ImagesResponse
|
||||||
from openai.types.responses.response import Response
|
from openai.types.responses.response import Response
|
||||||
from openai.types.responses.response_stream_event import ResponseStreamEvent
|
from openai.types.responses.response_stream_event import ResponseStreamEvent
|
||||||
from packaging.version import parse
|
|
||||||
|
|
||||||
if sys.version_info >= (3, 11):
|
if sys.version_info >= (3, 11):
|
||||||
from typing import TypedDict # type: ignore # pragma: no cover
|
from typing import TypedDict # type: ignore # pragma: no cover
|
||||||
@@ -35,8 +30,6 @@ if TYPE_CHECKING:
|
|||||||
AzureCredentialTypes = TokenCredential | AsyncTokenCredential
|
AzureCredentialTypes = TokenCredential | AsyncTokenCredential
|
||||||
|
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger("agent_framework.openai")
|
|
||||||
|
|
||||||
AZURE_OPENAI_TOKEN_SCOPE = "https://cognitiveservices.azure.com/.default" # noqa: S105 # nosec B105
|
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]]
|
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):
|
class OpenAISettings(TypedDict, total=False):
|
||||||
"""OpenAI environment settings.
|
"""OpenAI environment settings.
|
||||||
|
|
||||||
@@ -374,256 +344,4 @@ def get_api_key(
|
|||||||
if isinstance(api_key, SecretString):
|
if isinstance(api_key, SecretString):
|
||||||
return api_key.get_secret_value()
|
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
|
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
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "agent-framework-openai"
|
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"}]
|
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
@@ -25,7 +25,6 @@ classifiers = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-framework-core>=1.0.0rc6",
|
"agent-framework-core>=1.0.0rc6",
|
||||||
"openai>=1.99.0,<3",
|
"openai>=1.99.0,<3",
|
||||||
"packaging>=24.1,<25",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|||||||
@@ -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
|
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user