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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions src/claude/sdk_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions src/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
)
Expand Down Expand Up @@ -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."""
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/test_claude/test_sdk_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down