diff --git a/src/open_deep_research/configuration.py b/src/open_deep_research/configuration.py index 1c5bac9e9..87f37bfa4 100644 --- a/src/open_deep_research/configuration.py +++ b/src/open_deep_research/configuration.py @@ -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): @@ -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.""" @@ -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, @@ -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." } } ) @@ -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." } } ) @@ -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." } } ) @@ -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 @@ -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.""" diff --git a/src/open_deep_research/utils.py b/src/open_deep_research/utils.py index 82ce304e2..e869870fb 100644 --- a/src/open_deep_research/utils.py +++ b/src/open_deep_research/utils.py @@ -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' @@ -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() @@ -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): @@ -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."""