Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,9 @@ LANGSMITH_TRACING=
SUPABASE_KEY=
SUPABASE_URL=
# Should be set to true for a production deployment on Open Agent Platform. Should be set to false otherwise, such as for local development.
GET_API_KEYS_FROM_CONFIG=false
GET_API_KEYS_FROM_CONFIG=false

# Only necessary for Azure OpenAI Deployment
# OPENAI_API_TYPE="azure_ad" # Uncomment this line if using Entra ID instead of an API key
AZURE_OPENAI_ENDPOINT=""
OPENAI_API_VERSION=""
16 changes: 8 additions & 8 deletions src/open_deep_research/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,11 @@ class Configuration(BaseModel):
)
# Model Configuration
summarization_model: str = Field(
default="openai:gpt-4.1-mini",
default="azure_openai:gpt-4.1-mini",
metadata={
"x_oap_ui_config": {
"type": "text",
"default": "openai:gpt-4.1-mini",
"default": "azure_openai:gpt-4.1-mini",
"description": "Model for summarizing research results from Tavily search results"
}
}
Expand Down Expand Up @@ -151,11 +151,11 @@ class Configuration(BaseModel):
}
)
research_model: str = Field(
default="openai:gpt-4.1",
default="azure_openai:gpt-4.1",
metadata={
"x_oap_ui_config": {
"type": "text",
"default": "openai:gpt-4.1",
"default": "azure_openai:gpt-4.1",
"description": "Model for conducting research. NOTE: Make sure your Researcher Model supports the selected search API."
}
}
Expand All @@ -171,11 +171,11 @@ class Configuration(BaseModel):
}
)
compression_model: str = Field(
default="openai:gpt-4.1",
default="azure_openai:gpt-4.1",
metadata={
"x_oap_ui_config": {
"type": "text",
"default": "openai:gpt-4.1",
"default": "azure_openai:gpt-4.1",
"description": "Model for compressing research findings from sub-agents. NOTE: Make sure your Compression Model supports the selected search API."
}
}
Expand All @@ -191,11 +191,11 @@ class Configuration(BaseModel):
}
)
final_report_model: str = Field(
default="openai:gpt-4.1",
default="azure_openai:gpt-4.1",
metadata={
"x_oap_ui_config": {
"type": "text",
"default": "openai:gpt-4.1",
"default": "azure_openai:gpt-4.1",
"description": "Model for writing the final report from all research findings"
}
}
Expand Down
88 changes: 84 additions & 4 deletions src/open_deep_research/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import logging
import os
import warnings
import threading
import time
from datetime import datetime, timedelta, timezone
from typing import Annotated, Any, Dict, List, Literal, Optional

Expand Down Expand Up @@ -33,6 +35,18 @@
from open_deep_research.prompts import summarize_webpage_prompt
from open_deep_research.state import ResearchComplete, Summary

from azure.identity import DefaultAzureCredential

# In-process cache for Azure AD tokens to avoid repeated network calls
# keyed by scope. Protect with a lock for thread-safety.
_AZURE_TOKEN_CACHE: dict = {
"token": None,
"expires_on": 0,
"credential": None,
}
_AZURE_TOKEN_CACHE_LOCK = threading.Lock()


##########################
# Tavily Search Tool Utils
##########################
Expand Down Expand Up @@ -871,12 +885,18 @@ def remove_up_to_last_ai_message(messages: list[MessageLikeRepresentation]) -> l

def get_today_str() -> str:
"""Get current date formatted for display in prompts and outputs.

Returns:
Human-readable date string in format like 'Mon Jan 15, 2024'
Human-readable date string in format like 'Mon Jan 15, 2024'.
Uses a fixed English abbreviation mapping to be consistent across OSes.
"""
now = datetime.now()
return f"{now:%a} {now:%b} {now.day}, {now:%Y}"
# Use fixed English abbreviations to avoid locale/strftime differences
weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
weekday = weekdays[now.weekday()] # Monday == 0
month = months[now.month - 1]
return f"{weekday} {month} {now.day}, {now.year}"

def get_config_value(value):
"""Extract value from configuration, handling enums and None values."""
Expand All @@ -891,8 +911,19 @@ def get_config_value(value):

def get_api_key_for_model(model_name: str, config: RunnableConfig):
"""Get API key for a specific model from environment or config."""
# Normalize
should_get_from_config = os.getenv("GET_API_KEYS_FROM_CONFIG", "false")
model_name = model_name.lower()
model_name = (model_name or "").lower()

# New: support Azure-hosted OpenAI endpoints. When model string starts with
# "azure_openai:" we obtain an Azure AD access token using
# DefaultAzureCredential and return that token. To avoid excessive network
# I/O, cache the token in-process until shortly before expiration.
if model_name.startswith("azure_openai:"):
scope = "https://cognitiveservices.azure.com/.default"
return _get_cached_azure_token(scope, config)

# Existing behavior for other providers. Prefer config keys if configured.
if should_get_from_config.lower() == "true":
api_keys = config.get("configurable", {}).get("apiKeys", {})
if not api_keys:
Expand All @@ -913,6 +944,55 @@ def get_api_key_for_model(model_name: str, config: RunnableConfig):
return os.getenv("GOOGLE_API_KEY")
return None


def _get_cached_azure_token(scope: str, config: RunnableConfig, safety_margin: int = 60):
"""Return an Azure AD access token for the given scope, using an
in-process cache to minimize network calls.

Args:
scope: The OAuth scope to request (e.g. https://cognitiveservices.azure.com/.default)
config: Runtime configuration (not currently used but kept for parity)
safety_margin: Seconds before token expiry to proactively refresh

Returns:
Access token string, or None if it cannot be obtained
"""
now = int(time.time())

with _AZURE_TOKEN_CACHE_LOCK:
token = _AZURE_TOKEN_CACHE.get("token")
expires_on = _AZURE_TOKEN_CACHE.get("expires_on", 0)
credential = _AZURE_TOKEN_CACHE.get("credential")

# If token is still valid (with safety margin), return it
if token and (expires_on - safety_margin) > now:
return token

# Otherwise, obtain or reuse a DefaultAzureCredential and fetch a new token
if credential is None:
try:
credential = DefaultAzureCredential()
_AZURE_TOKEN_CACHE["credential"] = credential
except Exception:
# If credential creation fails, do not repeatedly attempt in tight loop
return None

try:
access_token = credential.get_token(scope)
if not access_token:
return None

_AZURE_TOKEN_CACHE["token"] = access_token.token
# access_token.expires_on is an int POSIX timestamp in many SDKs
expires_on_ts = int(getattr(access_token, "expires_on", int(time.time()) + 300))
_AZURE_TOKEN_CACHE["expires_on"] = expires_on_ts

return access_token.token
except Exception:
# On failure, clear cached credential to allow retry later
_AZURE_TOKEN_CACHE["credential"] = None
return None

def get_tavily_api_key(config: RunnableConfig):
"""Get Tavily API key from environment or config."""
should_get_from_config = os.getenv("GET_API_KEYS_FROM_CONFIG", "false")
Expand Down
2 changes: 2 additions & 0 deletions to run.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
With no rebuild of packages: uvx --from "langgraph-cli[inmem]" --with-editable . --python 3.13 langgraph dev --allow-blocking
with package rebuild: uvx --refresh --from "langgraph-cli[inmem]" --with-editable . --python 3.13 langgraph dev --allow-blocking