diff --git a/.env.example b/.env.example index 8c59a4b4..6a9d6843 100644 --- a/.env.example +++ b/.env.example @@ -59,10 +59,28 @@ DISABLE_TOOL_VALIDATION=false # Integration method: Use Python SDK (true) or CLI subprocess (false) USE_SDK=true -# Anthropic API key for SDK integration (optional if using CLI authentication) -# Get your API key from: https://console.anthropic.com/ +# API key for SDK integration (optional if using Claude CLI authentication) +# Use an Anthropic key by default, or a MiniMax key with CLAUDE_PROVIDER=minimax. ANTHROPIC_API_KEY= +# Provider used by the Claude Agent SDK: anthropic or minimax +CLAUDE_PROVIDER=anthropic + +# MiniMax API region: global_en or cn_zh (used only for MiniMax) +# global_en: https://api.minimax.io/anthropic +# cn_zh: https://api.minimaxi.com/anthropic +MINIMAX_REGION=global_en + +# Optional model override. MiniMax supports MiniMax-M3 and MiniMax-M2.7. +# MiniMax-M3 is selected by default when CLAUDE_PROVIDER=minimax. +CLAUDE_MODEL= + +# Custom base URL for the Anthropic API (optional) +# Use this to point the SDK at a proxy/enterprise endpoint or an +# Anthropic-compatible endpoint. It overrides the provider endpoint when set. +# Example: https://your-proxy.example.com/anthropic +ANTHROPIC_BASE_URL= + # Path to Claude CLI executable (optional - will auto-detect if not specified) # Example: /usr/local/bin/claude or ~/.nvm/versions/node/v20.19.2/bin/claude CLAUDE_CLI_PATH= diff --git a/CHANGELOG.md b/CHANGELOG.md index 96760404..cd85d1be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **MiniMax provider configuration**: Select global or China Anthropic-compatible endpoints, use `MiniMax-M3` by default, choose `MiniMax-M2.7` with `CLAUDE_MODEL`, or override the endpoint with `ANTHROPIC_BASE_URL` while preserving the existing SDK/session flow. + ## [1.6.0] - 2026-03-30 ### Added diff --git a/README.md b/README.md index e30bb05b..7470cb57 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,11 @@ ALLOWED_USERS=123456789 # Comma-separated Telegram user IDs ```bash # Claude -ANTHROPIC_API_KEY=sk-ant-... # API key (optional if using CLI auth) +ANTHROPIC_API_KEY=... # Anthropic or MiniMax API key (optional if using CLI auth) +CLAUDE_PROVIDER=anthropic # anthropic or minimax +MINIMAX_REGION=global_en # global_en or cn_zh (MiniMax only) +CLAUDE_MODEL= # MiniMax-M3 or MiniMax-M2.7 +ANTHROPIC_BASE_URL=... # Optional override for the provider endpoint CLAUDE_MAX_COST_PER_USER=10.0 # Spending limit per user (USD) CLAUDE_TIMEOUT_SECONDS=300 # Operation timeout diff --git a/docs/configuration.md b/docs/configuration.md index 2bba7d9f..784706bc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -62,7 +62,21 @@ DISABLE_TOOL_VALIDATION=false ```bash # Authentication -ANTHROPIC_API_KEY=sk-ant-api03-... # Optional: API key for SDK (uses CLI auth if omitted) +ANTHROPIC_API_KEY=... # Anthropic or MiniMax API key; optional with Claude CLI auth + +# Provider used by the Claude Agent SDK: anthropic or minimax +CLAUDE_PROVIDER=anthropic + +# MiniMax configuration (used when CLAUDE_PROVIDER=minimax) +MINIMAX_REGION=global_en # global_en or cn_zh +CLAUDE_MODEL=MiniMax-M3 # MiniMax-M3 (default) or MiniMax-M2.7 + +# MiniMax resolves these Anthropic-compatible endpoints automatically: +# global_en: https://api.minimax.io/anthropic +# cn_zh: https://api.minimaxi.com/anthropic + +# Optional endpoint override for any provider +ANTHROPIC_BASE_URL=https://your-proxy.example.com/anthropic # Maximum conversation turns before requiring new session CLAUDE_MAX_TURNS=10 diff --git a/src/claude/sdk_integration.py b/src/claude/sdk_integration.py index 5a95f16d..c1f2def8 100644 --- a/src/claude/sdk_integration.py +++ b/src/claude/sdk_integration.py @@ -258,6 +258,18 @@ def __init__( else: logger.info("No API key provided, using existing Claude CLI authentication") + # Configure an explicit URL or the selected provider endpoint. The SDK + # reads ANTHROPIC_BASE_URL, so the existing client/session flow is unchanged. + resolved_base_url = config.resolved_anthropic_base_url + if resolved_base_url: + os.environ["ANTHROPIC_BASE_URL"] = resolved_base_url + logger.info( + "Using Anthropic-compatible base URL for Claude SDK", + provider=config.claude_provider, + ) + else: + os.environ.pop("ANTHROPIC_BASE_URL", None) + def _is_retryable_error(self, exc: BaseException) -> bool: """Return True for transient errors that warrant a retry. asyncio.TimeoutError is intentional (user-configured timeout) — not retried. @@ -321,7 +333,7 @@ def _stderr_callback(line: str) -> None: # Build Claude Agent options options = ClaudeAgentOptions( max_turns=self.config.claude_max_turns, - model=self.config.claude_model or None, + model=self.config.resolved_claude_model, max_budget_usd=self.config.claude_max_cost_per_request, cwd=str(working_directory), allowed_tools=sdk_allowed_tools, diff --git a/src/config/settings.py b/src/config/settings.py index c4f7cb18..f9ab4766 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -33,6 +33,12 @@ DEFAULT_SESSION_TIMEOUT_HOURS, ) +MINIMAX_ANTHROPIC_BASE_URLS = { + "global_en": "https://api.minimax.io/anthropic", + "cn_zh": "https://api.minimaxi.com/anthropic", +} +MINIMAX_DEFAULT_MODEL = "MiniMax-M3" + class Settings(BaseSettings): """Application settings loaded from environment variables.""" @@ -76,7 +82,22 @@ class Settings(BaseSettings): ) anthropic_api_key: Optional[SecretStr] = Field( None, - description="Anthropic API key for SDK (optional if CLI logged in)", + description="API key for the selected SDK provider (optional if CLI logged in)", + ) + claude_provider: Literal["anthropic", "minimax"] = Field( + "anthropic", + description="Provider used by the Claude Agent SDK", + ) + minimax_region: Literal["global_en", "cn_zh"] = Field( + "global_en", + description="MiniMax API region used to resolve the Anthropic-compatible URL", + ) + anthropic_base_url: Optional[str] = Field( + None, + description=( + "Custom base URL for the Anthropic API (optional, for " + "proxy/enterprise or Anthropic-compatible endpoints)" + ), ) claude_model: Optional[str] = Field( None, description="Claude model to use (defaults to CLI default if unset)" @@ -535,6 +556,31 @@ def anthropic_api_key_str(self) -> Optional[str]: else None ) + @property + def anthropic_base_url_str(self) -> Optional[str]: + """Get the custom Anthropic base URL as string, if configured.""" + base_url = self.anthropic_base_url + return base_url.strip() if base_url else None + + @property + def resolved_anthropic_base_url(self) -> Optional[str]: + """Resolve an explicit base URL or the selected provider endpoint.""" + if self.anthropic_base_url_str: + return self.anthropic_base_url_str + if self.claude_provider == "minimax": + return MINIMAX_ANTHROPIC_BASE_URLS[self.minimax_region] + return None + + @property + def resolved_claude_model(self) -> Optional[str]: + """Resolve the configured model, including the MiniMax default.""" + model = self.claude_model.strip() if self.claude_model else None + if model: + return model + if self.claude_provider == "minimax": + return MINIMAX_DEFAULT_MODEL + return None + @property def mistral_api_key_str(self) -> Optional[str]: """Get Mistral API key as string.""" diff --git a/tests/unit/test_claude/test_sdk_integration.py b/tests/unit/test_claude/test_sdk_integration.py index 9c2b3777..510f52b7 100644 --- a/tests/unit/test_claude/test_sdk_integration.py +++ b/tests/unit/test_claude/test_sdk_integration.py @@ -157,6 +157,74 @@ async def test_sdk_manager_initialization_without_api_key(self, config): if original_api_key: os.environ["ANTHROPIC_API_KEY"] = original_api_key + async def test_sdk_manager_initialization_with_base_url(self, tmp_path): + """Test SDK manager sets ANTHROPIC_BASE_URL when configured.""" + from src.config.settings import Settings + + config_with_base_url = Settings( + telegram_bot_token="test:token", + telegram_bot_username="testbot", + approved_directory=tmp_path, + anthropic_base_url="https://custom.example.com/anthropic", + claude_timeout_seconds=2, + ) + + original_base_url = os.environ.get("ANTHROPIC_BASE_URL") + + try: + ClaudeSDKManager(config_with_base_url) + + assert ( + os.environ.get("ANTHROPIC_BASE_URL") + == "https://custom.example.com/anthropic" + ) + finally: + if original_base_url: + os.environ["ANTHROPIC_BASE_URL"] = original_base_url + elif "ANTHROPIC_BASE_URL" in os.environ: + del os.environ["ANTHROPIC_BASE_URL"] + + async def test_sdk_manager_initialization_without_base_url(self, config): + """Test SDK manager does not set ANTHROPIC_BASE_URL when not configured.""" + original_base_url = os.environ.get("ANTHROPIC_BASE_URL") + + try: + if "ANTHROPIC_BASE_URL" in os.environ: + del os.environ["ANTHROPIC_BASE_URL"] + + ClaudeSDKManager(config) + + assert config.anthropic_base_url_str is None + assert "ANTHROPIC_BASE_URL" not in os.environ + finally: + if original_base_url: + os.environ["ANTHROPIC_BASE_URL"] = original_base_url + + @pytest.mark.parametrize( + ("region", "expected_url"), + [ + ("global_en", "https://api.minimax.io/anthropic"), + ("cn_zh", "https://api.minimaxi.com/anthropic"), + ], + ) + async def test_sdk_manager_initialization_with_minimax_region( + self, tmp_path, monkeypatch, region, expected_url + ): + """Test SDK manager exports the selected MiniMax endpoint.""" + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + config = Settings( + telegram_bot_token="test:token", + telegram_bot_username="testbot", + approved_directory=tmp_path, + claude_provider="minimax", + minimax_region=region, + claude_timeout_seconds=2, + ) + + ClaudeSDKManager(config) + + assert os.environ["ANTHROPIC_BASE_URL"] == expected_url + async def test_execute_command_success(self, sdk_manager): """Test successful command execution.""" mock_factory = _mock_client_factory( @@ -780,6 +848,37 @@ async def test_claude_model_none_when_unset(self, tmp_path): assert len(captured_options) == 1 assert captured_options[0].model is None + @pytest.mark.parametrize("model", ["MiniMax-M3", "MiniMax-M2.7"]) + async def test_minimax_model_passed_to_options(self, tmp_path, model): + """Test supported MiniMax model selections reach ClaudeAgentOptions.""" + config = Settings( + telegram_bot_token="test:token", + telegram_bot_username="testbot", + approved_directory=tmp_path, + claude_timeout_seconds=2, + claude_provider="minimax", + claude_model=model, + ) + manager = ClaudeSDKManager(config) + + captured_options = [] + mock_factory = _mock_client_factory( + _make_assistant_message("Test response"), + _make_result_message(total_cost_usd=0.01), + capture_options=captured_options, + ) + + with patch( + "src.claude.sdk_integration.ClaudeSDKClient", side_effect=mock_factory + ): + await manager.execute_command( + prompt="Test prompt", + working_directory=tmp_path, + ) + + assert len(captured_options) == 1 + assert captured_options[0].model == model + class TestClaudeMCPErrors: """Test MCP-specific error handling.""" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 2f0dcd9e..81f8a75c 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -560,6 +560,81 @@ def test_computed_properties(tmp_path): assert sqlite_settings.database_path == Path("data/bot.db").resolve() +def test_anthropic_base_url_property(tmp_path): + """Test the anthropic_base_url_str computed property.""" + test_dir = tmp_path / "projects" + test_dir.mkdir() + + # Defaults to None when not configured + unset_settings = Settings( + telegram_bot_token="test_token", + telegram_bot_username="test_bot", + approved_directory=str(test_dir), + ) + assert unset_settings.anthropic_base_url_str is None + + # Returns the configured base URL + set_settings = Settings( + telegram_bot_token="test_token", + telegram_bot_username="test_bot", + approved_directory=str(test_dir), + anthropic_base_url="https://custom.example.com/anthropic", + ) + assert set_settings.anthropic_base_url_str == "https://custom.example.com/anthropic" + + # Whitespace is trimmed + padded_settings = Settings( + telegram_bot_token="test_token", + telegram_bot_username="test_bot", + approved_directory=str(test_dir), + anthropic_base_url=" https://custom.example.com/anthropic ", + ) + assert ( + padded_settings.anthropic_base_url_str == "https://custom.example.com/anthropic" + ) + + +def test_minimax_provider_defaults(tmp_path): + """Test MiniMax resolves its global endpoint and default model.""" + settings = Settings( + telegram_bot_token="test_token", + telegram_bot_username="test_bot", + approved_directory=tmp_path, + claude_provider="minimax", + ) + + assert settings.resolved_anthropic_base_url == "https://api.minimax.io/anthropic" + assert settings.resolved_claude_model == "MiniMax-M3" + + +def test_minimax_china_region_and_model_selection(tmp_path): + """Test MiniMax resolves its China endpoint and selected model.""" + settings = Settings( + telegram_bot_token="test_token", + telegram_bot_username="test_bot", + approved_directory=tmp_path, + claude_provider="minimax", + minimax_region="cn_zh", + claude_model="MiniMax-M2.7", + ) + + assert settings.resolved_anthropic_base_url == "https://api.minimaxi.com/anthropic" + assert settings.resolved_claude_model == "MiniMax-M2.7" + + +def test_explicit_base_url_overrides_provider_endpoint(tmp_path): + """Test an explicit base URL takes precedence over provider resolution.""" + settings = Settings( + telegram_bot_token="test_token", + telegram_bot_username="test_bot", + approved_directory=tmp_path, + claude_provider="minimax", + anthropic_base_url="https://proxy.example.com/anthropic", + ) + + assert settings.resolved_anthropic_base_url == "https://proxy.example.com/anthropic" + + def test_feature_flags(): """Test feature flag system.""" # Create test MCP config file with valid structure before creating settings