diff --git a/.env.example b/.env.example index 778272bf3..5d6f3d5b5 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ OPENAI_API_KEY= +AZURE_OPENAI_API_KEY= ANTHROPIC_API_KEY= GOOGLE_API_KEY= TAVILY_API_KEY= diff --git a/pyproject.toml b/pyproject.toml index 6a46d9d2d..39e1a0de3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "open_deep_research" -version = "0.0.16" +version = "0.0.17" description = "Planning, research, and report generation." authors = [ { name = "Lance Martin" } diff --git a/src/open_deep_research/configuration.py b/src/open_deep_research/configuration.py index 1c5bac9e9..a467f7a3a 100644 --- a/src/open_deep_research/configuration.py +++ b/src/open_deep_research/configuration.py @@ -13,6 +13,7 @@ class SearchAPI(Enum): ANTHROPIC = "anthropic" OPENAI = "openai" + AZURE_OPENAI = "azure_openai" TAVILY = "tavily" NONE = "none" @@ -85,6 +86,7 @@ class Configuration(BaseModel): "options": [ {"label": "Tavily", "value": SearchAPI.TAVILY.value}, {"label": "OpenAI Native Web Search", "value": SearchAPI.OPENAI.value}, + {"label": "Azure OpenAI Native Web Search", "value": SearchAPI.AZURE_OPENAI.value}, {"label": "Anthropic Native Web Search", "value": SearchAPI.ANTHROPIC.value}, {"label": "None", "value": SearchAPI.NONE.value} ] @@ -210,6 +212,37 @@ class Configuration(BaseModel): } } ) + # Azure OpenAI Configuration + azure_endpoint: Optional[str] = Field( + default=None, + optional=True, + metadata={ + "x_oap_ui_config": { + "type": "text", + "description": "Azure OpenAI endpoint URL (e.g., https://your-resource.openai.azure.com/)" + } + } + ) + deployment_name: Optional[str] = Field( + default=None, + optional=True, + metadata={ + "x_oap_ui_config": { + "type": "text", + "description": "Azure OpenAI deployment name" + } + } + ) + api_version: Optional[str] = Field( + default="2024-02-15-preview", + optional=True, + metadata={ + "x_oap_ui_config": { + "type": "text", + "description": "Azure OpenAI API version" + } + } + ) # MCP server configuration mcp_config: Optional[MCPConfig] = Field( default=None, diff --git a/src/open_deep_research/deep_researcher.py b/src/open_deep_research/deep_researcher.py index 279dbffd9..6cf84ecd0 100644 --- a/src/open_deep_research/deep_researcher.py +++ b/src/open_deep_research/deep_researcher.py @@ -41,8 +41,10 @@ ) from open_deep_research.utils import ( anthropic_websearch_called, + azure_openai_websearch_called, get_all_tools, get_api_key_for_model, + get_model_config, get_model_token_limit, get_notes_from_tool_calls, get_today_str, @@ -54,7 +56,7 @@ # Initialize a configurable model that we will use throughout the agent configurable_model = init_chat_model( - configurable_fields=("model", "max_tokens", "api_key"), + configurable_fields=("model", "max_tokens", "api_key", "azure_endpoint", "deployment_name", "api_version"), ) async def clarify_with_user(state: AgentState, config: RunnableConfig) -> Command[Literal["write_research_brief", "__end__"]]: @@ -78,12 +80,12 @@ async def clarify_with_user(state: AgentState, config: RunnableConfig) -> Comman # Step 2: Prepare the model for structured clarification analysis messages = state["messages"] - model_config = { - "model": configurable.research_model, - "max_tokens": configurable.research_model_max_tokens, - "api_key": get_api_key_for_model(configurable.research_model, config), - "tags": ["langsmith:nostream"] - } + model_config = get_model_config( + configurable.research_model, + configurable.research_model_max_tokens, + config, + configurable + ) # Configure model with structured output and retry logic clarification_model = ( @@ -131,12 +133,12 @@ async def write_research_brief(state: AgentState, config: RunnableConfig) -> Com """ # Step 1: Set up the research model for structured output configurable = Configuration.from_runnable_config(config) - research_model_config = { - "model": configurable.research_model, - "max_tokens": configurable.research_model_max_tokens, - "api_key": get_api_key_for_model(configurable.research_model, config), - "tags": ["langsmith:nostream"] - } + research_model_config = get_model_config( + configurable.research_model, + configurable.research_model_max_tokens, + config, + configurable + ) # Configure model for structured research question generation research_model = ( @@ -191,12 +193,12 @@ async def supervisor(state: SupervisorState, config: RunnableConfig) -> Command[ """ # Step 1: Configure the supervisor model with available tools configurable = Configuration.from_runnable_config(config) - research_model_config = { - "model": configurable.research_model, - "max_tokens": configurable.research_model_max_tokens, - "api_key": get_api_key_for_model(configurable.research_model, config), - "tags": ["langsmith:nostream"] - } + research_model_config = get_model_config( + configurable.research_model, + configurable.research_model_max_tokens, + config, + configurable + ) # Available tools: research delegation, completion signaling, and strategic thinking lead_researcher_tools = [ConductResearch, ResearchComplete, think_tool] @@ -389,12 +391,12 @@ async def researcher(state: ResearcherState, config: RunnableConfig) -> Command[ ) # Step 2: Configure the researcher model with tools - research_model_config = { - "model": configurable.research_model, - "max_tokens": configurable.research_model_max_tokens, - "api_key": get_api_key_for_model(configurable.research_model, config), - "tags": ["langsmith:nostream"] - } + research_model_config = get_model_config( + configurable.research_model, + configurable.research_model_max_tokens, + config, + configurable + ) # Prepare system prompt with MCP context if available researcher_prompt = research_system_prompt.format( @@ -457,6 +459,7 @@ async def researcher_tools(state: ResearcherState, config: RunnableConfig) -> Co has_tool_calls = bool(most_recent_message.tool_calls) has_native_search = ( openai_websearch_called(most_recent_message) or + azure_openai_websearch_called(most_recent_message) or anthropic_websearch_called(most_recent_message) ) @@ -524,12 +527,13 @@ async def compress_research(state: ResearcherState, config: RunnableConfig): """ # Step 1: Configure the compression model configurable = Configuration.from_runnable_config(config) - synthesizer_model = configurable_model.with_config({ - "model": configurable.compression_model, - "max_tokens": configurable.compression_model_max_tokens, - "api_key": get_api_key_for_model(configurable.compression_model, config), - "tags": ["langsmith:nostream"] - }) + compression_model_config = get_model_config( + configurable.compression_model, + configurable.compression_model_max_tokens, + config, + configurable + ) + synthesizer_model = configurable_model.with_config(compression_model_config) # Step 2: Prepare messages for compression researcher_messages = state.get("researcher_messages", []) @@ -624,12 +628,12 @@ async def final_report_generation(state: AgentState, config: RunnableConfig): # Step 2: Configure the final report generation model configurable = Configuration.from_runnable_config(config) - writer_model_config = { - "model": configurable.final_report_model, - "max_tokens": configurable.final_report_model_max_tokens, - "api_key": get_api_key_for_model(configurable.final_report_model, config), - "tags": ["langsmith:nostream"] - } + writer_model_config = get_model_config( + configurable.final_report_model, + configurable.final_report_model_max_tokens, + config, + configurable + ) # Step 3: Attempt report generation with token limit retry logic max_retries = 3 diff --git a/src/open_deep_research/utils.py b/src/open_deep_research/utils.py index 82ce304e2..4f1b2e8d8 100644 --- a/src/open_deep_research/utils.py +++ b/src/open_deep_research/utils.py @@ -82,13 +82,13 @@ async def tavily_search( max_char_to_include = configurable.max_content_length # Initialize summarization model with retry logic - model_api_key = get_api_key_for_model(configurable.summarization_model, config) - summarization_model = init_chat_model( - model=configurable.summarization_model, - max_tokens=configurable.summarization_model_max_tokens, - api_key=model_api_key, - tags=["langsmith:nostream"] - ).with_structured_output(Summary).with_retry( + summarization_model_config = get_model_config( + configurable.summarization_model, + configurable.summarization_model_max_tokens, + config, + configurable + ) + summarization_model = init_chat_model(**summarization_model_config).with_structured_output(Summary).with_retry( stop_after_attempt=configurable.max_structured_output_retries ) @@ -657,6 +657,28 @@ def openai_websearch_called(response): return False +def azure_openai_websearch_called(response): + """Detect if Azure OpenAI's web search functionality was used in the response. + + Args: + response: The response object from Azure OpenAI's API + + Returns: + True if web search was called, False otherwise + """ + # Azure OpenAI uses the same web search mechanism as OpenAI + # Check for tool outputs in the response metadata + tool_outputs = response.additional_kwargs.get("tool_outputs") + if not tool_outputs: + return False + + # Look for web search calls in the tool outputs + for tool_output in tool_outputs: + if tool_output.get("type") == "web_search_call": + return True + + return False + ########################## # Token Limit Exceeded Utils @@ -797,6 +819,17 @@ def _check_gemini_token_limit(exception: Exception, error_str: str) -> bool: "openai:o3-pro": 200000, "openai:o1": 200000, "openai:o1-pro": 200000, + "azure_openai:gpt-4.1-mini": 1047576, + "azure_openai:gpt-4.1-nano": 1047576, + "azure_openai:gpt-4.1": 1047576, + "azure_openai:gpt-4o-mini": 128000, + "azure_openai:gpt-4o": 128000, + "azure_openai:o4-mini": 200000, + "azure_openai:o3-mini": 200000, + "azure_openai:o3": 200000, + "azure_openai:o3-pro": 200000, + "azure_openai:o1": 200000, + "azure_openai:o1-pro": 200000, "anthropic:claude-opus-4": 200000, "anthropic:claude-sonnet-4": 200000, "anthropic:claude-3-7-sonnet": 200000, @@ -899,6 +932,8 @@ def get_api_key_for_model(model_name: str, config: RunnableConfig): return None if model_name.startswith("openai:"): return api_keys.get("OPENAI_API_KEY") + elif model_name.startswith("azure_openai:"): + return api_keys.get("AZURE_OPENAI_API_KEY") elif model_name.startswith("anthropic:"): return api_keys.get("ANTHROPIC_API_KEY") elif model_name.startswith("google"): @@ -907,6 +942,8 @@ def get_api_key_for_model(model_name: str, config: RunnableConfig): else: if model_name.startswith("openai:"): return os.getenv("OPENAI_API_KEY") + elif model_name.startswith("azure_openai:"): + return os.getenv("AZURE_OPENAI_API_KEY") elif model_name.startswith("anthropic:"): return os.getenv("ANTHROPIC_API_KEY") elif model_name.startswith("google"): @@ -923,3 +960,33 @@ def get_tavily_api_key(config: RunnableConfig): return api_keys.get("TAVILY_API_KEY") else: return os.getenv("TAVILY_API_KEY") + +def get_model_config(model_name: str, max_tokens: int, config: RunnableConfig, configurable: "Configuration") -> dict: + """Get properly configured model parameters based on model type. + + Args: + model_name: The model identifier (e.g., "azure_openai:gpt-4", "openai:gpt-4") + max_tokens: Maximum tokens for the model + config: Runtime configuration + configurable: Configuration object with Azure settings + + Returns: + Dictionary with properly configured model parameters + """ + model_config = { + "model": model_name, + "max_tokens": max_tokens, + "api_key": get_api_key_for_model(model_name, config), + "tags": ["langsmith:nostream"] + } + + # Only add Azure-specific parameters for Azure OpenAI models + if model_name.startswith("azure_openai:"): + if configurable.azure_endpoint: + model_config["azure_endpoint"] = configurable.azure_endpoint + if configurable.deployment_name: + model_config["deployment_name"] = configurable.deployment_name + if configurable.api_version: + model_config["api_version"] = configurable.api_version + + return model_config