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
181 changes: 170 additions & 11 deletions src/open_deep_research/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

import os
from enum import Enum
from typing import Any, List, Optional
from typing import Any, Dict, List, Optional

from langchain_core.runnables import RunnableConfig
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator


class SearchAPI(Enum):
Expand All @@ -16,6 +16,66 @@ class SearchAPI(Enum):
TAVILY = "tavily"
NONE = "none"

class ModelPreset(Enum):
"""Enumeration of available model presets for quick configuration."""

DEEPSEEK_OPENROUTER = "deepseek_openrouter"
GPT4_OPENAI = "gpt4_openai"
CLAUDE_ANTHROPIC = "claude_anthropic"
GEMINI_GOOGLE = "gemini_google"
CUSTOM = "custom"

# Model preset configurations
MODEL_PRESETS: Dict[ModelPreset, Dict[str, Any]] = {
ModelPreset.DEEPSEEK_OPENROUTER: {
"summarization_model": "openai:gpt-4o-mini",
"research_model": "openai:deepseek/deepseek-chat",
"compression_model": "openai:deepseek/deepseek-chat",
"final_report_model": "openai:deepseek/deepseek-chat",
"summarization_model_max_tokens": 8192,
"research_model_max_tokens": 10000,
"compression_model_max_tokens": 8192,
"final_report_model_max_tokens": 10000,
"description": "使用 OpenRouter API 的 DeepSeek 模型,成本效益高"
},
ModelPreset.GPT4_OPENAI: {
"summarization_model": "openai:gpt-4o-mini",
"research_model": "openai:gpt-4o",
"compression_model": "openai:gpt-4o",
"final_report_model": "openai:gpt-4o",
"summarization_model_max_tokens": 8192,
"research_model_max_tokens": 8192,
"compression_model_max_tokens": 8192,
"final_report_model_max_tokens": 8192,
"description": "使用 OpenAI GPT-4o 模型,性能优秀但成本较高"
},
ModelPreset.CLAUDE_ANTHROPIC: {
"summarization_model": "anthropic:claude-3-5-haiku",
"research_model": "anthropic:claude-3-5-sonnet",
"compression_model": "anthropic:claude-3-5-sonnet",
"final_report_model": "anthropic:claude-3-5-sonnet",
"summarization_model_max_tokens": 8192,
"research_model_max_tokens": 8192,
"compression_model_max_tokens": 8192,
"final_report_model_max_tokens": 8192,
"description": "使用 Anthropic Claude 模型,擅长推理和分析"
},
ModelPreset.GEMINI_GOOGLE: {
"summarization_model": "google:gemini-1.5-flash",
"research_model": "google:gemini-1.5-pro",
"compression_model": "google:gemini-1.5-pro",
"final_report_model": "google:gemini-1.5-pro",
"summarization_model_max_tokens": 8192,
"research_model_max_tokens": 8192,
"compression_model_max_tokens": 8192,
"final_report_model_max_tokens": 8192,
"description": "使用 Google Gemini 模型,支持长上下文"
},
ModelPreset.CUSTOM: {
"description": "自定义模型配置,需要手动设置各个模型参数"
}
}

class MCPConfig(BaseModel):
"""Configuration for Model Context Protocol (MCP) servers."""

Expand All @@ -38,6 +98,25 @@ class MCPConfig(BaseModel):
class Configuration(BaseModel):
"""Main configuration class for the Deep Research agent."""

# Model Preset Selection
model_preset: ModelPreset = Field(
default=ModelPreset.DEEPSEEK_OPENROUTER,
metadata={
"x_oap_ui_config": {
"type": "select",
"default": ModelPreset.DEEPSEEK_OPENROUTER.value,
"description": "Choose a model preset for quick configuration. When not CUSTOM, individual model settings will be overridden.",
"options": [
{"label": "DeepSeek (OpenRouter) - 成本效益", "value": ModelPreset.DEEPSEEK_OPENROUTER.value},
{"label": "GPT-4o (OpenAI) - 高性能", "value": ModelPreset.GPT4_OPENAI.value},
{"label": "Claude (Anthropic) - 善于推理", "value": ModelPreset.CLAUDE_ANTHROPIC.value},
{"label": "Gemini (Google) - 长上下文", "value": ModelPreset.GEMINI_GOOGLE.value},
{"label": "Custom - 自定义配置", "value": ModelPreset.CUSTOM.value}
]
}
}
)

# General Configuration
max_structured_output_retries: int = Field(
default=3,
Expand Down Expand Up @@ -151,12 +230,12 @@ class Configuration(BaseModel):
}
)
research_model: str = Field(
default="openai:gpt-4.1",
default="openai:deepseek/deepseek-chat",
metadata={
"x_oap_ui_config": {
"type": "text",
"default": "openai:gpt-4.1",
"description": "Model for conducting research. NOTE: Make sure your Researcher Model supports the selected search API."
"default": "openai:deepseek/deepseek-chat",
"description": "Model for conducting research. Use 'openai:model_name' format for OpenRouter models to explicitly use OpenAI provider."
}
}
)
Expand All @@ -171,12 +250,12 @@ class Configuration(BaseModel):
}
)
compression_model: str = Field(
default="openai:gpt-4.1",
default="openai:deepseek/deepseek-chat",
metadata={
"x_oap_ui_config": {
"type": "text",
"default": "openai:gpt-4.1",
"description": "Model for compressing research findings from sub-agents. NOTE: Make sure your Compression Model supports the selected search API."
"default": "openai:deepseek/deepseek-chat",
"description": "Model for compressing research findings from sub-agents. Use 'openai:model_name' format for OpenRouter models."
}
}
)
Expand All @@ -191,12 +270,12 @@ class Configuration(BaseModel):
}
)
final_report_model: str = Field(
default="openai:gpt-4.1",
default="openai:deepseek/deepseek-chat",
metadata={
"x_oap_ui_config": {
"type": "text",
"default": "openai:gpt-4.1",
"description": "Model for writing the final report from all research findings"
"default": "openai:deepseek/deepseek-chat",
"description": "Model for writing the final report from all research findings. Use 'openai:model_name' format for OpenRouter models."
}
}
)
Expand Down Expand Up @@ -231,6 +310,61 @@ class Configuration(BaseModel):
}
}
)
apiKeys: Optional[dict[str, str]] = Field(
default={
"OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY"),
"ANTHROPIC_API_KEY": os.environ.get("ANTHROPIC_API_KEY"),
"GOOGLE_API_KEY": os.environ.get("GOOGLE_API_KEY"),
"OPENROUTER_API_KEY": os.environ.get("OPENROUTER_API_KEY"),
"TAVILY_API_KEY": os.environ.get("TAVILY_API_KEY")
},
optional=True
)

@model_validator(mode='before')
@classmethod
def apply_model_preset(cls, data: Any) -> Any:
"""Apply model preset configuration if not using custom preset."""
if not isinstance(data, dict):
return data

# Get the model preset from the data
model_preset = data.get('model_preset', ModelPreset.DEEPSEEK_OPENROUTER)

# If using custom preset, don't override the values
if model_preset == ModelPreset.CUSTOM:
return data

# Ensure model_preset is a ModelPreset enum
if isinstance(model_preset, str):
try:
model_preset = ModelPreset(model_preset)
except ValueError:
model_preset = ModelPreset.DEEPSEEK_OPENROUTER

# Apply preset configuration
preset_config = MODEL_PRESETS.get(model_preset, {})

# Create a copy of data to modify
result = data.copy()

# Apply preset values for model fields
model_fields = [
'summarization_model', 'research_model', 'compression_model', 'final_report_model',
'summarization_model_max_tokens', 'research_model_max_tokens',
'compression_model_max_tokens', 'final_report_model_max_tokens'
]

for field_name in model_fields:
if field_name in preset_config:
# Only apply preset if the field is not explicitly set by user
if field_name not in data or data[field_name] is None:
result[field_name] = preset_config[field_name]
# For non-custom presets, always apply the preset (override user values)
else:
result[field_name] = preset_config[field_name]

return result


@classmethod
Expand All @@ -245,6 +379,31 @@ def from_runnable_config(
for field_name in field_names
}
return cls(**{k: v for k, v in values.items() if v is not None})

def get_preset_description(self) -> str:
"""Get the description of the current model preset."""
preset_config = MODEL_PRESETS.get(self.model_preset, {})
return preset_config.get("description", "Unknown preset")

def get_preset_info(self) -> Dict[str, Any]:
"""Get detailed information about the current model preset."""
preset_config = MODEL_PRESETS.get(self.model_preset, {})
return {
"preset": self.model_preset.value,
"description": preset_config.get("description", "Unknown preset"),
"models": {
"summarization": self.summarization_model,
"research": self.research_model,
"compression": self.compression_model,
"final_report": self.final_report_model
},
"max_tokens": {
"summarization": self.summarization_model_max_tokens,
"research": self.research_model_max_tokens,
"compression": self.compression_model_max_tokens,
"final_report": self.final_report_model_max_tokens
}
}

class Config:
"""Pydantic configuration."""
Expand Down
69 changes: 63 additions & 6 deletions src/open_deep_research/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,10 @@ def is_token_limit_exceeded(exception: Exception, model_name: str = None) -> boo
provider = None
if model_name:
model_str = str(model_name).lower()
if model_str.startswith('openai:'):
# Handle OpenRouter models using OpenAI provider format (openai:deepseek/model)
if (model_str.startswith('openai:') or
model_str.startswith('openrouter:') or
('/' in model_str and not model_str.startswith(('anthropic:', 'google:')))):
provider = 'openai'
elif model_str.startswith('anthropic:'):
provider = 'anthropic'
Expand Down Expand Up @@ -707,7 +710,7 @@ def _check_openai_token_limit(exception: Exception, error_str: str) -> bool:
class_name = exception.__class__.__name__
module_name = getattr(exception.__class__, '__module__', '')

# Check if this is an OpenAI exception
# Check if this is an OpenAI exception (including OpenRouter using OpenAI API)
is_openai_exception = (
'openai' in exception_type.lower() or
'openai' in module_name.lower()
Expand Down Expand Up @@ -826,6 +829,17 @@ def _check_gemini_token_limit(exception: Exception, error_str: str) -> bool:
"bedrock:us.anthropic.claude-sonnet-4-20250514-v1:0": 200000,
"bedrock:us.anthropic.claude-opus-4-20250514-v1:0": 200000,
"anthropic.claude-opus-4-1-20250805-v1:0": 200000,
# OpenRouter models using OpenAI provider format
"openai:deepseek/deepseek-chat": 256000,
"openai:deepseek/deepseek-chat-v3": 256000,
"openai:deepseek/deepseek-chat-v3.1": 256000,
# Legacy OpenRouter models
"openrouter:deepseek/deepseek-chat": 256000,
"openrouter:deepseek/deepseek-chat-v3": 256000,
"openrouter:deepseek/deepseek-chat-v3.1": 256000,
"openrouter:openai/gpt-4o": 128000,
"openrouter:openai/gpt-4o-mini": 128000,
"openrouter:anthropic/claude-3.5-sonnet": 200000,
}

def get_model_token_limit(model_string):
Expand Down Expand Up @@ -889,29 +903,72 @@ def get_config_value(value):
else:
return value.value

def get_model_config_for_openrouter(model_name: str, api_key: str) -> dict:
"""Get model configuration for OpenRouter models.

Args:
model_name: The OpenRouter model name (e.g., "openrouter:deepseek/deepseek-chat")
api_key: The OpenRouter API key

Returns:
Dictionary with model configuration including base_url
"""
# Extract the actual model name from the OpenRouter format
if model_name.startswith("openrouter:"):
actual_model = model_name[len("openrouter:"):]
else:
actual_model = model_name

return {
"model": "openai", # Use OpenAI provider for OpenRouter compatibility
"openai_api_base": "https://openrouter.ai/api/v1",
"openai_api_key": api_key,
"model_name": actual_model,
}

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

if should_get_from_config.lower() == "true":
api_keys = config.get("configurable", {}).get("apiKeys", {})
if not api_keys:
return None
if model_name.startswith("openai:"):

# Check for OpenRouter models using OpenAI provider (openai:deepseek/model)
if model_name.startswith("openai:") and "/" in model_name:
# This is likely an OpenRouter model using OpenAI provider
return api_keys.get("OPENAI_API_KEY")
elif model_name.startswith("openai:"):
return api_keys.get("OPENAI_API_KEY")
elif model_name.startswith("anthropic:"):
return api_keys.get("ANTHROPIC_API_KEY")
elif model_name.startswith("google"):
return api_keys.get("GOOGLE_API_KEY")
return None
elif model_name.startswith("deepseek"):
return api_keys.get("DEEPSEEK_API_KEY")
elif model_name.startswith("openrouter:"):
return api_keys.get("OPENROUTER_API_KEY")
# For models that don't match any prefix, use OpenAI key (for OpenRouter compatibility)
return api_keys.get("OPENAI_API_KEY")
else:
if model_name.startswith("openai:"):
# Check for OpenRouter models using OpenAI provider (openai:deepseek/model)
if model_name.startswith("openai:") and "/" in model_name:
# This is likely an OpenRouter model using OpenAI provider
return os.getenv("OPENAI_API_KEY")
elif model_name.startswith("openai:"):
return os.getenv("OPENAI_API_KEY")
elif model_name.startswith("anthropic:"):
return os.getenv("ANTHROPIC_API_KEY")
elif model_name.startswith("google"):
return os.getenv("GOOGLE_API_KEY")
return None
elif model_name.startswith("deepseek"):
return os.getenv("DEEPSEEK_API_KEY")
elif model_name.startswith("openrouter:"):
return os.getenv("OPENROUTER_API_KEY")
# For models that don't match any prefix, use OpenAI key (for OpenRouter compatibility)
return os.getenv("OPENAI_API_KEY")

def get_tavily_api_key(config: RunnableConfig):
"""Get Tavily API key from environment or config."""
Expand Down