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
22 changes: 20 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
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
- **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
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 15 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion src/claude/sdk_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
48 changes: 47 additions & 1 deletion src/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -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."""
Expand Down
99 changes: 99 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,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(
Expand Down Expand Up @@ -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."""
Expand Down
75 changes: 75 additions & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down