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] 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 8c59a4b4e..d6cbb3253 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 96760404a..e1e2e6810 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 e30bb05be..3ff2eebfb 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 2bba7d9f2..8a4cceac9 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 5a95f16da..bd29ddddf 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 c4f7cb18b..17fe036ca 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 9c2b37773..21b81a6f8 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 2f0dcd9ee..dd2e2fba1 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