Skip to content

Commit b262407

Browse files
Avangardclaude
authored andcommitted
feat: add OpenRouter support as alternative LLM provider
LLM_PROVIDER env var switches between anthropic and openrouter. OpenRouter gives access to DeepSeek, GPT-4o, Gemini and other models. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 56fa699 commit b262407

5 files changed

Lines changed: 79 additions & 15 deletions

File tree

.env.example

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@
22
TELEGRAM_BOT_TOKEN=your-bot-token-here
33
ALLOWED_USER_IDS=[123456789]
44

5-
# Anthropic API
5+
# LLM Provider: "anthropic" or "openrouter"
6+
LLM_PROVIDER=anthropic
7+
8+
# Anthropic API (required if LLM_PROVIDER=anthropic)
69
ANTHROPIC_API_KEY=sk-ant-xxx
710

11+
# OpenRouter API (required if LLM_PROVIDER=openrouter)
12+
OPENROUTER_API_KEY=sk-or-xxx
13+
814
# Debug mode
915
DEBUG=false

src/agent.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,16 +125,46 @@ def __init__(
125125
ssh_manager: Manager for SSH connections.
126126
client: Optional AsyncAnthropic client for dependency injection.
127127
"""
128-
self._client = client or AsyncAnthropic(
129-
api_key=settings.anthropic_api_key.get_secret_value()
130-
)
128+
self._client = client or self._create_client()
131129
self._tools = tool_registry
132130
self._state = state_manager
133131
self._security = security_guard
134132
self._ssh = ssh_manager
135133
self._max_iterations = settings.max_iterations
136134
self._system_prompt = self._build_system_prompt()
137135

136+
@staticmethod
137+
def _create_client() -> AsyncAnthropic:
138+
"""Create LLM client based on configured provider.
139+
140+
Returns:
141+
AsyncAnthropic client (direct or via OpenRouter).
142+
143+
Raises:
144+
ValueError: If provider is unknown or API key is missing.
145+
"""
146+
provider = settings.llm_provider.lower()
147+
148+
if provider == "openrouter":
149+
api_key = settings.openrouter_api_key.get_secret_value()
150+
if not api_key:
151+
msg = "OPENROUTER_API_KEY is required when LLM_PROVIDER=openrouter"
152+
raise ValueError(msg)
153+
return AsyncAnthropic(
154+
api_key=api_key,
155+
base_url=settings.openrouter_base_url,
156+
)
157+
158+
if provider == "anthropic":
159+
api_key = settings.anthropic_api_key.get_secret_value()
160+
if not api_key:
161+
msg = "ANTHROPIC_API_KEY is required when LLM_PROVIDER=anthropic"
162+
raise ValueError(msg)
163+
return AsyncAnthropic(api_key=api_key)
164+
165+
msg = f"Unknown LLM_PROVIDER: {provider}. Use 'anthropic' or 'openrouter'."
166+
raise ValueError(msg)
167+
138168
def _build_system_prompt(self) -> str:
139169
"""Build system prompt with SSH hosts information.
140170

src/bot.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,29 @@
2929

3030
logger = logging.getLogger(__name__)
3131

32-
# Available Claude models
33-
MODELS = {
32+
# Available models per provider
33+
ANTHROPIC_MODELS = {
3434
"sonnet": ("claude-sonnet-4-20250514", "Sonnet 4"),
3535
"opus": ("claude-opus-4-20250514", "Opus 4"),
3636
"haiku": ("claude-3-5-haiku-20241022", "Haiku 3.5"),
3737
}
3838

39+
OPENROUTER_MODELS = {
40+
"sonnet": ("anthropic/claude-sonnet-4-20250514", "Sonnet 4"),
41+
"opus": ("anthropic/claude-opus-4-20250514", "Opus 4"),
42+
"haiku": ("anthropic/claude-3.5-haiku", "Haiku 3.5"),
43+
"deepseek": ("deepseek/deepseek-chat-v3-0324", "DeepSeek V3"),
44+
"gpt4o": ("openai/gpt-4o", "GPT-4o"),
45+
"gemini": ("google/gemini-2.5-flash-preview", "Gemini 2.5 Flash"),
46+
}
47+
48+
49+
def get_models() -> dict[str, tuple[str, str]]:
50+
"""Get available models based on configured provider."""
51+
if settings.llm_provider.lower() == "openrouter":
52+
return OPENROUTER_MODELS
53+
return ANTHROPIC_MODELS
54+
3955

4056
class RateLimiter:
4157
"""Simple in-memory rate limiter.
@@ -308,7 +324,7 @@ async def _handle_health(self, message: Message) -> None:
308324

309325
# Get user's selected model from database
310326
model_key = await self._state.get_user_model(user_id)
311-
model_id = MODELS.get(model_key, MODELS["sonnet"])[0]
327+
model_id = get_models().get(model_key, get_models()["sonnet"])[0]
312328

313329
# Use agent to check health via SSH
314330
result = await self._agent.run(
@@ -350,7 +366,7 @@ async def _handle_logs(self, message: Message) -> None:
350366

351367
# Get user's selected model from database
352368
model_key = await self._state.get_user_model(user_id)
353-
model_id = MODELS.get(model_key, MODELS["sonnet"])[0]
369+
model_id = get_models().get(model_key, get_models()["sonnet"])[0]
354370

355371
# Use agent to read logs via SSH
356372
result = await self._agent.run(
@@ -452,11 +468,11 @@ async def _handle_model(self, message: Message) -> None:
452468

453469
user_id = message.from_user.id if message.from_user else 0
454470
current_key = await self._state.get_user_model(user_id)
455-
current_name = MODELS.get(current_key, MODELS["sonnet"])[1]
471+
current_name = get_models().get(current_key, get_models()["sonnet"])[1]
456472

457473
# Build inline keyboard
458474
buttons = []
459-
for key, (_model_id, name) in MODELS.items():
475+
for key, (_model_id, name) in get_models().items():
460476
marker = " ✓" if key == current_key else ""
461477
buttons.append(
462478
InlineKeyboardButton(
@@ -490,17 +506,17 @@ async def _handle_model_callback(self, callback: CallbackQuery) -> None:
490506
# Extract model key from callback data
491507
model_key = callback.data.split(":")[1] if callback.data else "sonnet"
492508

493-
if model_key not in MODELS:
509+
if model_key not in get_models():
494510
await callback.answer("Неизвестная модель", show_alert=True)
495511
return
496512

497513
# Save user preference to database
498514
await self._state.set_user_model(user_id, model_key)
499-
model_name = MODELS[model_key][1]
515+
model_name = get_models()[model_key][1]
500516

501517
# Update keyboard with new selection
502518
buttons = []
503-
for key, (_model_id, name) in MODELS.items():
519+
for key, (_model_id, name) in get_models().items():
504520
marker = " ✓" if key == model_key else ""
505521
buttons.append(
506522
InlineKeyboardButton(
@@ -542,7 +558,7 @@ async def _handle_message(self, message: Message) -> None:
542558

543559
# Get user's selected model from database
544560
model_key = await self._state.get_user_model(user_id)
545-
model_id = MODELS.get(model_key, MODELS["sonnet"])[0]
561+
model_id = get_models().get(model_key, get_models()["sonnet"])[0]
546562

547563
# Run agent
548564
result = await self._agent.run(user_id=user_id, query=text, model=model_id)

src/config.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,22 @@ class Settings(BaseSettings):
1919
telegram_bot_token: SecretStr = Field(alias="TELEGRAM_BOT_TOKEN")
2020
allowed_user_ids: list[int] = Field(default_factory=list, alias="ALLOWED_USER_IDS")
2121

22+
# LLM Provider
23+
llm_provider: str = Field(default="anthropic", alias="LLM_PROVIDER")
24+
2225
# Anthropic
23-
anthropic_api_key: SecretStr = Field(alias="ANTHROPIC_API_KEY")
26+
anthropic_api_key: SecretStr = Field(
27+
default=SecretStr(""), alias="ANTHROPIC_API_KEY"
28+
)
2429
model: str = "claude-sonnet-4-20250514"
2530
max_tokens: int = 4096
2631

32+
# OpenRouter
33+
openrouter_api_key: SecretStr = Field(
34+
default=SecretStr(""), alias="OPENROUTER_API_KEY"
35+
)
36+
openrouter_base_url: str = "https://openrouter.ai/api/v1"
37+
2738
# Paths
2839
base_dir: Path = Path(__file__).parent.parent
2940

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
# Set test environment variables before any src imports
66
os.environ.setdefault("TELEGRAM_BOT_TOKEN", "test-token-for-ci")
77
os.environ.setdefault("ANTHROPIC_API_KEY", "sk-ant-test-key")
8+
os.environ.setdefault("LLM_PROVIDER", "anthropic")

0 commit comments

Comments
 (0)