diff --git a/.env.template b/.env.template index 4501565..ef98efa 100644 --- a/.env.template +++ b/.env.template @@ -20,6 +20,11 @@ AZURE_OPENAI_MODEL_CHAT="gpt-5" AZURE_OPENAI_MODEL_EMBEDDING="text-embedding-3-small" AZURE_OPENAI_MODEL_REASONING="o4-mini" +## Azure AI Foundry +AZURE_AI_FOUNDRY_INFERENCE_ENDPOINT="https://xxx.services.ai.azure.com/api/projects/xxx" +AZURE_AI_FOUNDRY_INFERENCE_API_VERSION="2025-04-01-preview" +AZURE_AI_FOUNDRY_INFERENCE_MODEL_CHAT="gpt-5" + ## Ollama Settings OLLAMA_MODEL_CHAT="gemma3:270m" diff --git a/docs/references.md b/docs/references.md index 9daadcf..52165e9 100644 --- a/docs/references.md +++ b/docs/references.md @@ -29,6 +29,11 @@ - [Azure Cosmos DB No SQL](https://python.langchain.com/docs/integrations/vectorstores/azure_cosmos_db_no_sql/) - [Azure AI Search](https://python.langchain.com/docs/integrations/vectorstores/azuresearch/) +### Azure AI Foundry + +- [Quickstart: Get started with Azure AI Foundry](https://learn.microsoft.com/azure/ai-foundry/quickstarts/get-started-code?tabs=python&pivots=fdp-project) +- [azure-rest-api-specs/specification/ai/data-plane/Azure.AI.Agents](https://github.com/Azure/azure-rest-api-specs/tree/main/specification/ai/data-plane/Azure.AI.Agents) + ### Services - [FastAPI](https://fastapi.tiangolo.com/) diff --git a/pyproject.toml b/pyproject.toml index 34e7740..0ece028 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "A GitHub template repository for Python" readme = "README.md" requires-python = ">=3.10" dependencies = [ + "azure-ai-projects>=1.0.0", "azure-cosmos>=4.9.0", "azure-identity>=1.23.1", "azure-search-documents>=11.5.3", diff --git a/scripts/azure_ai_foundry_operator.py b/scripts/azure_ai_foundry_operator.py new file mode 100644 index 0000000..e7c61d8 --- /dev/null +++ b/scripts/azure_ai_foundry_operator.py @@ -0,0 +1,347 @@ +import logging + +import typer +from dotenv import load_dotenv + +from template_langgraph.llms.azure_ai_foundrys import AzureAiFoundryWrapper +from template_langgraph.loggers import get_logger + +# Initialize the Typer application +app = typer.Typer( + add_completion=False, + help="Azure AI Foundry operator CLI", +) + +# Set up logging +logger = get_logger(__name__) + + +@app.command() +def chat( + query: str = typer.Option( + "Hello", + "--query", + "-q", + help="The query to send to the AI", + ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Enable verbose output", + ), +): + # Set up logging + if verbose: + logger.setLevel(logging.DEBUG) + + logger.info("Running Azure AI Foundry chat...") + + wrapper = AzureAiFoundryWrapper() + + openai_client = wrapper.get_openai_client() + + response = openai_client.chat.completions.create( + model=wrapper.settings.azure_ai_foundry_inference_model_chat, + messages=[ + {"role": "user", "content": query}, + ], + ) + logger.info(response.choices[0].message.content) + + +@app.command() +def create_agent( + name: str = typer.Option( + "MyAgent", + "--name", + "-n", + help="The name of the agent", + ), + instructions: str = typer.Option( + "This is my agent", + "--instructions", + "-i", + help="The instructions for the agent", + ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Enable verbose output", + ), +): + # Set up logging + if verbose: + logger.setLevel(logging.DEBUG) + + logger.info("Creating agent...") + project_client = AzureAiFoundryWrapper().get_ai_project_client() + with project_client.agents as agents_client: + # Create a new agent + agent = agents_client.create_agent( + name=name, + instructions=instructions, + model=AzureAiFoundryWrapper().settings.azure_ai_foundry_inference_model_chat, + ) + logger.info(f"Created agent: {agent.as_dict()}") + + +@app.command() +def delete_agent( + agent_id: str = typer.Option( + "asst_xxx", + "--agent-id", + "-a", + help="The ID of the agent to delete", + ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Enable verbose output", + ), +): + # Set up logging + if verbose: + logger.setLevel(logging.DEBUG) + + logger.info("Deleting agent...") + project_client = AzureAiFoundryWrapper().get_ai_project_client() + with project_client.agents as agents_client: + agents_client.delete_agent(agent_id=agent_id) + + +@app.command() +def list_agents( + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Enable verbose output", + ), +): + # Set up logging + if verbose: + logger.setLevel(logging.DEBUG) + + logger.info("Listing agents...") + + project_client = AzureAiFoundryWrapper().get_ai_project_client() + with project_client.agents as agents_client: + agents = agents_client.list_agents() + for agent in agents: + logger.info(f"Agent ID: {agent.id}, Name: {agent.name}") + + +@app.command() +def create_thread( + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Enable verbose output", + ), +): + # Set up logging + if verbose: + logger.setLevel(logging.DEBUG) + + logger.info("Creating thread...") + + project_client = AzureAiFoundryWrapper().get_ai_project_client() + thread = project_client.agents.threads.create() + logger.info(thread.as_dict()) + + +@app.command() +def delete_thread( + thread_id: str = typer.Option( + "thread_xxx", + "--thread-id", + "-t", + help="The ID of the thread to delete", + ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Enable verbose output", + ), +): + # Set up logging + if verbose: + logger.setLevel(logging.DEBUG) + + logger.info("Deleting thread...") + project_client = AzureAiFoundryWrapper().get_ai_project_client() + project_client.agents.threads.delete(thread_id=thread_id) + + +@app.command() +def list_threads( + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Enable verbose output", + ), +): + # Set up logging + if verbose: + logger.setLevel(logging.DEBUG) + + logger.info("Listing threads...") + + project_client = AzureAiFoundryWrapper().get_ai_project_client() + threads = project_client.agents.threads.list() + for thread in threads: + logger.info(thread.as_dict()) + + +@app.command() +def create_message( + role: str = typer.Option( + "user", + "--role", + "-r", + help="The role of the message sender", + ), + content: str = typer.Option( + "Hello, world!", + "--content", + "-c", + help="The content of the message", + ), + thread_id: str = typer.Option( + "thread_xxx", + "--thread-id", + "-t", + help="The ID of the thread to list messages from", + ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Enable verbose output", + ), +): + # Set up logging + if verbose: + logger.setLevel(logging.DEBUG) + + logger.info("Creating message...") + + project_client = AzureAiFoundryWrapper().get_ai_project_client() + message = project_client.agents.messages.create( + thread_id=thread_id, + role=role, + content=content, + ) + logger.info(message.as_dict()) + + +@app.command() +def list_messages( + thread_id: str = typer.Option( + "thread_xxx", + "--thread-id", + "-t", + help="The ID of the thread to list messages from", + ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Enable verbose output", + ), +): + # Set up logging + if verbose: + logger.setLevel(logging.DEBUG) + + logger.info("Listing messages...") + + project_client = AzureAiFoundryWrapper().get_ai_project_client() + messages = project_client.agents.messages.list(thread_id=thread_id) + for message in messages: + logger.info(message.as_dict()) + + +@app.command() +def run_thread( + agent_id: str = typer.Option( + "agent_xxx", + "--agent-id", + "-a", + help="The ID of the agent to run", + ), + thread_id: str = typer.Option( + "thread_xxx", + "--thread-id", + "-t", + help="The ID of the thread to run", + ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Enable verbose output", + ), +): + # Set up logging + if verbose: + logger.setLevel(logging.DEBUG) + + logger.info("Running thread...") + + project_client = AzureAiFoundryWrapper().get_ai_project_client() + run = project_client.agents.runs.create( + thread_id=thread_id, + agent_id=agent_id, + ) + + logger.info(f"Run created: {run.as_dict()}") + + +@app.command() +def get_run( + thread_id: str = typer.Option( + "thread_xxx", + "--thread-id", + "-t", + help="The ID of the thread to run", + ), + run_id: str = typer.Option( + "run_xxx", + "--run-id", + "-r", + help="The ID of the run to retrieve", + ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Enable verbose output", + ), +): + # Set up logging + if verbose: + logger.setLevel(logging.DEBUG) + + logger.info("Getting run...") + + project_client = AzureAiFoundryWrapper().get_ai_project_client() + run = project_client.agents.runs.get( + thread_id=thread_id, + run_id=run_id, + ) + logger.info(run.as_dict()) + + +if __name__ == "__main__": + load_dotenv( + override=True, + verbose=True, + ) + app() diff --git a/template_langgraph/llms/azure_ai_foundrys.py b/template_langgraph/llms/azure_ai_foundrys.py new file mode 100644 index 0000000..91fd458 --- /dev/null +++ b/template_langgraph/llms/azure_ai_foundrys.py @@ -0,0 +1,42 @@ +from functools import lru_cache + +from azure.ai.projects import AIProjectClient +from azure.identity import DefaultAzureCredential +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + azure_ai_foundry_inference_endpoint: str = "https://xxx.services.ai.azure.com/api/projects/xxx" + azure_ai_foundry_inference_api_version: str = "2025-04-01-preview" + azure_ai_foundry_inference_model_chat: str = "gpt-5" + + model_config = SettingsConfigDict( + env_file=".env", + env_ignore_empty=True, + extra="ignore", + ) + + +@lru_cache +def get_azure_ai_foundry_settings() -> Settings: + return Settings() + + +class AzureAiFoundryWrapper: + def __init__(self, settings: Settings = None): + if settings is None: + settings = get_azure_ai_foundry_settings() + self.settings = settings + self.project_client = AIProjectClient( + endpoint=self.settings.azure_ai_foundry_inference_endpoint, + credential=DefaultAzureCredential(), + ) + self.openai_client = self.project_client.get_openai_client( + api_version=self.settings.azure_ai_foundry_inference_api_version + ) + + def get_ai_project_client(self): + return self.project_client + + def get_openai_client(self): + return self.openai_client diff --git a/uv.lock b/uv.lock index 78ead63..fba5546 100644 --- a/uv.lock +++ b/uv.lock @@ -297,6 +297,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, ] +[[package]] +name = "azure-ai-agents" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/98/bbe2e9e5b0a934be1930545025bf7018ebc4cc33b10134cc3314d6487076/azure_ai_agents-1.1.0.tar.gz", hash = "sha256:eb9d7226282d03206c3fab3f3ee0a2fc71e0ad38e52d2f4f19a92c56ed951aea", size = 303656, upload-time = "2025-08-05T19:02:26.7Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/31/43750555bf20d3d2d7589fcd775c96ce7c96e58e208b81c1ed6d4bad6c5f/azure_ai_agents-1.1.0-py3-none-any.whl", hash = "sha256:f660bb0d564aeb88e33140ebc1e4700d2e36e2e12ee60c3346915d702a9310a9", size = 191126, upload-time = "2025-08-05T19:02:28.178Z" }, +] + [[package]] name = "azure-ai-inference" version = "1.0.0b9" @@ -316,6 +330,22 @@ opentelemetry = [ { name = "azure-core-tracing-opentelemetry" }, ] +[[package]] +name = "azure-ai-projects" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-ai-agents" }, + { name = "azure-core" }, + { name = "azure-storage-blob" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/95/9c04cb5f658c7f856026aa18432e0f0fa254ead2983a3574a0f5558a7234/azure_ai_projects-1.0.0.tar.gz", hash = "sha256:b5f03024ccf0fd543fbe0f5abcc74e45b15eccc1c71ab87fc71c63061d9fd63c", size = 130798, upload-time = "2025-07-31T02:09:27.912Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/db/7149cdf71e12d9737f186656176efc94943ead4f205671768c1549593efe/azure_ai_projects-1.0.0-py3-none-any.whl", hash = "sha256:81369ed7a2f84a65864f57d3fa153e16c30f411a1504d334e184fb070165a3fa", size = 115188, upload-time = "2025-07-31T02:09:29.362Z" }, +] + [[package]] name = "azure-common" version = "1.1.28" @@ -396,6 +426,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/f5/0f6b52567cbb33f1efba13060514ed7088a86de84d74b77cda17d278bcd9/azure_search_documents-11.5.3-py3-none-any.whl", hash = "sha256:110617751c6c8bd50b1f0af2b00a478bd4fbaf4e2f0387e3454c26ec3eb433d6", size = 298772, upload-time = "2025-06-25T16:49:00.764Z" }, ] +[[package]] +name = "azure-storage-blob" +version = "12.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/95/3e3414491ce45025a1cde107b6ae72bf72049e6021597c201cd6a3029b9a/azure_storage_blob-12.26.0.tar.gz", hash = "sha256:5dd7d7824224f7de00bfeb032753601c982655173061e242f13be6e26d78d71f", size = 583332, upload-time = "2025-07-16T21:34:07.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/64/63dbfdd83b31200ac58820a7951ddfdeed1fbee9285b0f3eae12d1357155/azure_storage_blob-12.26.0-py3-none-any.whl", hash = "sha256:8c5631b8b22b4f53ec5fff2f3bededf34cfef111e2af613ad42c9e6de00a77fe", size = 412907, upload-time = "2025-07-16T21:34:09.367Z" }, +] + [[package]] name = "babel" version = "2.17.0" @@ -4691,6 +4736,7 @@ name = "template-langgraph" version = "0.0.1" source = { editable = "." } dependencies = [ + { name = "azure-ai-projects" }, { name = "azure-cosmos" }, { name = "azure-identity" }, { name = "azure-search-documents" }, @@ -4737,6 +4783,7 @@ docs = [ [package.metadata] requires-dist = [ + { name = "azure-ai-projects", specifier = ">=1.0.0" }, { name = "azure-cosmos", specifier = ">=4.9.0" }, { name = "azure-identity", specifier = ">=1.23.1" }, { name = "azure-search-documents", specifier = ">=11.5.3" },