From 6071032c7eb904e21923fcd0b52859b4459c37cf Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:57:53 +0000 Subject: [PATCH 1/2] feat: add ANTHROPIC_BASE_URL setting for custom API endpoints Add an optional ANTHROPIC_BASE_URL setting that configures the Claude Code SDK to use a proxy/enterprise endpoint or any Anthropic-compatible provider endpoint. ClaudeSDKManager exports the value to the ANTHROPIC_BASE_URL environment variable, preserving the existing SDK/session flow when the setting is left empty. --- .env.example | 6 +++ CHANGELOG.md | 3 ++ README.md | 1 + docs/configuration.md | 3 ++ src/claude/sdk_integration.py | 11 +++++ src/config/settings.py | 13 ++++++ .../unit/test_claude/test_sdk_integration.py | 43 +++++++++++++++++++ tests/unit/test_config.py | 34 +++++++++++++++ 8 files changed, 114 insertions(+) diff --git a/.env.example b/.env.example index 8c59a4b4..d6cbb325 100644 --- a/.env.example +++ b/.env.example @@ -63,6 +63,12 @@ USE_SDK=true # Get your API key from: https://console.anthropic.com/ ANTHROPIC_API_KEY= +# Custom base URL for the Anthropic API (optional) +# Use this to point the SDK at a proxy/enterprise endpoint or an +# Anthropic-compatible provider endpoint. Leave empty for the default endpoint. +# 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..e1e2e681 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 +- **Custom Anthropic base URL**: New `ANTHROPIC_BASE_URL` setting points the Claude Code SDK at a proxy/enterprise endpoint or any Anthropic-compatible provider endpoint, preserving the existing SDK/session flow when unset. + ## [1.6.0] - 2026-03-30 ### Added diff --git a/README.md b/README.md index e30bb05b..3ff2eebf 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,7 @@ ALLOWED_USERS=123456789 # Comma-separated Telegram user IDs ```bash # Claude ANTHROPIC_API_KEY=sk-ant-... # API key (optional if using CLI auth) +ANTHROPIC_BASE_URL=... # Custom API endpoint (optional, for proxy/enterprise or Anthropic-compatible endpoints) 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..8a4cceac 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -64,6 +64,9 @@ DISABLE_TOOL_VALIDATION=false # Authentication ANTHROPIC_API_KEY=sk-ant-api03-... # Optional: API key for SDK (uses CLI auth if omitted) +# Custom API endpoint (optional, for proxy/enterprise or Anthropic-compatible endpoints) +ANTHROPIC_BASE_URL=https://your-proxy.example.com/anthropic # Optional: custom base URL for Anthropic API + # 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..bd29dddd 100644 --- a/src/claude/sdk_integration.py +++ b/src/claude/sdk_integration.py @@ -258,6 +258,17 @@ def __init__( else: logger.info("No API key provided, using existing Claude CLI authentication") + # Configure a custom base URL (proxy/enterprise or Anthropic-compatible + # endpoint) when provided. The Claude Code SDK reads ANTHROPIC_BASE_URL + # from the environment, so the existing SDK/session flow is preserved. + if config.anthropic_base_url_str: + os.environ["ANTHROPIC_BASE_URL"] = config.anthropic_base_url_str + logger.info( + "Using custom Anthropic base URL for Claude SDK", + ) + 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. diff --git a/src/config/settings.py b/src/config/settings.py index c4f7cb18..17fe036c 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -78,6 +78,13 @@ class Settings(BaseSettings): None, description="Anthropic API key for SDK (optional if CLI logged in)", ) + 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 +542,12 @@ 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 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..21b81a6f 100644 --- a/tests/unit/test_claude/test_sdk_integration.py +++ b/tests/unit/test_claude/test_sdk_integration.py @@ -157,6 +157,49 @@ 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 + async def test_execute_command_success(self, sdk_manager): """Test successful command execution.""" mock_factory = _mock_client_factory( diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 2f0dcd9e..dd2e2fba 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -560,6 +560,40 @@ 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_feature_flags(): """Test feature flag system.""" # Create test MCP config file with valid structure before creating settings From 264b453735788b266886b376df2ef9ff18e05369 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:35:58 +0800 Subject: [PATCH 2/2] feat: add MiniMax provider configuration --- .env.example | 18 +++++- CHANGELOG.md | 2 +- README.md | 7 ++- docs/configuration.md | 17 +++++- src/claude/sdk_integration.py | 15 ++--- src/config/settings.py | 35 +++++++++++- .../unit/test_claude/test_sdk_integration.py | 56 +++++++++++++++++++ tests/unit/test_config.py | 41 ++++++++++++++ 8 files changed, 174 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index d6cbb325..6a9d6843 100644 --- a/.env.example +++ b/.env.example @@ -59,13 +59,25 @@ 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 provider endpoint. Leave empty for the default endpoint. +# Anthropic-compatible endpoint. It overrides the provider endpoint when set. # Example: https://your-proxy.example.com/anthropic ANTHROPIC_BASE_URL= diff --git a/CHANGELOG.md b/CHANGELOG.md index e1e2e681..cd85d1be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- **Custom Anthropic base URL**: New `ANTHROPIC_BASE_URL` setting points the Claude Code SDK at a proxy/enterprise endpoint or any Anthropic-compatible provider endpoint, preserving the existing SDK/session flow when unset. +- **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 diff --git a/README.md b/README.md index 3ff2eebf..7470cb57 100644 --- a/README.md +++ b/README.md @@ -229,8 +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_BASE_URL=... # Custom API endpoint (optional, for proxy/enterprise or Anthropic-compatible endpoints) +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 8a4cceac..784706bc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -62,10 +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 -# Custom API endpoint (optional, for proxy/enterprise or Anthropic-compatible endpoints) -ANTHROPIC_BASE_URL=https://your-proxy.example.com/anthropic # Optional: custom base URL for Anthropic API +# 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 bd29dddd..c1f2def8 100644 --- a/src/claude/sdk_integration.py +++ b/src/claude/sdk_integration.py @@ -258,13 +258,14 @@ def __init__( else: logger.info("No API key provided, using existing Claude CLI authentication") - # Configure a custom base URL (proxy/enterprise or Anthropic-compatible - # endpoint) when provided. The Claude Code SDK reads ANTHROPIC_BASE_URL - # from the environment, so the existing SDK/session flow is preserved. - if config.anthropic_base_url_str: - os.environ["ANTHROPIC_BASE_URL"] = config.anthropic_base_url_str + # 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 custom Anthropic base URL for Claude SDK", + "Using Anthropic-compatible base URL for Claude SDK", + provider=config.claude_provider, ) else: os.environ.pop("ANTHROPIC_BASE_URL", None) @@ -332,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 17fe036c..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,15 @@ 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, @@ -548,6 +562,25 @@ def anthropic_base_url_str(self) -> Optional[str]: 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 21b81a6f..510f52b7 100644 --- a/tests/unit/test_claude/test_sdk_integration.py +++ b/tests/unit/test_claude/test_sdk_integration.py @@ -200,6 +200,31 @@ async def test_sdk_manager_initialization_without_base_url(self, config): 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( @@ -823,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 dd2e2fba..81f8a75c 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -594,6 +594,47 @@ def test_anthropic_base_url_property(tmp_path): ) +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