Skip to content

Commit 603359c

Browse files
vinit13792claude
andcommitted
feat(litellm): add support for local proxy without API key
- Add litellm to interactive provider selection menu - Support LITELLM_BASE_URL for local proxy deployments (no API key required) - Auto-add openai/ prefix when using api_base for proper LiteLLM routing - Add dummy API key for local proxies (OpenAI SDK requirement) - Add validation and tests for litellm provider configuration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent baa4a8a commit 603359c

5 files changed

Lines changed: 324 additions & 16 deletions

File tree

packages/cli/src/repowise/cli/helpers.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,12 @@ def resolve_provider(
251251
kwargs["api_key"] = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
252252
elif provider_name == "ollama" and os.environ.get("OLLAMA_BASE_URL"):
253253
kwargs["base_url"] = os.environ["OLLAMA_BASE_URL"]
254+
elif provider_name == "litellm":
255+
# LiteLLM: API key for cloud, base URL for local proxy
256+
if os.environ.get("LITELLM_API_KEY"):
257+
kwargs["api_key"] = os.environ["LITELLM_API_KEY"]
258+
if os.environ.get("LITELLM_BASE_URL"):
259+
kwargs["api_base"] = os.environ["LITELLM_BASE_URL"]
254260

255261
return get_provider(provider_name, **kwargs)
256262

@@ -282,10 +288,26 @@ def resolve_provider(
282288
api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
283289
kwargs = {"model": model, "api_key": api_key} if model else {"api_key": api_key}
284290
return get_provider("gemini", **kwargs)
291+
# LiteLLM: check for API key (cloud) or base URL (local proxy)
292+
if os.environ.get("LITELLM_API_KEY") and os.environ["LITELLM_API_KEY"].strip():
293+
kwargs = (
294+
{"model": model, "api_key": os.environ["LITELLM_API_KEY"]}
295+
if model
296+
else {"api_key": os.environ["LITELLM_API_KEY"]}
297+
)
298+
return get_provider("litellm", **kwargs)
299+
if os.environ.get("LITELLM_BASE_URL") and os.environ["LITELLM_BASE_URL"].strip():
300+
kwargs = (
301+
{"model": model, "api_base": os.environ["LITELLM_BASE_URL"]}
302+
if model
303+
else {"api_base": os.environ["LITELLM_BASE_URL"]}
304+
)
305+
return get_provider("litellm", **kwargs)
285306

286307
raise click.ClickException(
287308
"No provider configured. Use --provider, set REPOWISE_PROVIDER, "
288-
"or set ANTHROPIC_API_KEY / OPENAI_API_KEY / OLLAMA_BASE_URL / GEMINI_API_KEY / GOOGLE_API_KEY."
309+
"or set ANTHROPIC_API_KEY / OPENAI_API_KEY / OLLAMA_BASE_URL / GEMINI_API_KEY / "
310+
"LITELLM_API_KEY / LITELLM_BASE_URL."
289311
)
290312

291313

@@ -321,7 +343,10 @@ def _is_env_var_exists(var_name: str) -> bool:
321343
"openai": ["OPENAI_API_KEY"],
322344
"gemini": ["GEMINI_API_KEY", "GOOGLE_API_KEY"], # Either one
323345
"ollama": ["OLLAMA_BASE_URL"],
324-
"litellm": ["LITELLM_API_KEY"], # May need others depending on backend
346+
"litellm": [
347+
"LITELLM_API_KEY",
348+
"LITELLM_BASE_URL",
349+
], # Either one (API key for cloud, base URL for local)
325350
}
326351

327352
if provider_name:
@@ -337,6 +362,10 @@ def _is_env_var_exists(var_name: str) -> bool:
337362
# Special case: either GEMINI_API_KEY or GOOGLE_API_KEY
338363
if not (_is_env_var_set("GEMINI_API_KEY") or _is_env_var_set("GOOGLE_API_KEY")):
339364
missing_vars = env_vars
365+
elif provider_name == "litellm":
366+
# Special case: LITELLM_API_KEY (cloud) OR LITELLM_BASE_URL (local proxy)
367+
if not (_is_env_var_set("LITELLM_API_KEY") or _is_env_var_set("LITELLM_BASE_URL")):
368+
missing_vars = env_vars
340369
else:
341370
for var in env_vars:
342371
if not _is_env_var_set(var):
@@ -359,6 +388,17 @@ def _is_env_var_exists(var_name: str) -> bool:
359388
)
360389
continue
361390

391+
if name == "litellm":
392+
# Special case: LITELLM_API_KEY (cloud) OR LITELLM_BASE_URL (local proxy)
393+
# Only warn if explicitly requested and neither is set
394+
if os.environ.get("REPOWISE_PROVIDER") == "litellm" and not (
395+
_is_env_var_set("LITELLM_API_KEY") or _is_env_var_set("LITELLM_BASE_URL")
396+
):
397+
warnings.append(
398+
"Provider 'litellm' requires LITELLM_API_KEY or LITELLM_BASE_URL environment variable"
399+
)
400+
continue
401+
362402
missing = [var for var in env_vars if not _is_env_var_set(var)]
363403
if missing:
364404
# Only warn if this provider is explicitly requested OR

packages/cli/src/repowise/cli/ui.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -84,18 +84,22 @@ def print_phase_header(
8484
"litellm": "groq/llama-3.1-70b-versatile",
8585
}
8686

87+
# For most providers, a single env var indicates configuration.
88+
# litellm is special: can use LITELLM_API_KEY (cloud) OR LITELLM_BASE_URL (local proxy).
8789
_PROVIDER_ENV: dict[str, str] = {
8890
"gemini": "GEMINI_API_KEY",
8991
"openai": "OPENAI_API_KEY",
9092
"anthropic": "ANTHROPIC_API_KEY",
9193
"ollama": "OLLAMA_BASE_URL",
94+
"litellm": "LITELLM_API_KEY", # Also checks LITELLM_BASE_URL in _detect_provider_status
9295
}
9396

9497
_PROVIDER_SIGNUP: dict[str, str] = {
9598
"gemini": "https://aistudio.google.com/apikey",
9699
"openai": "https://platform.openai.com/api-keys",
97100
"anthropic": "https://console.anthropic.com/settings/keys",
98101
"ollama": "https://ollama.com/download",
102+
"litellm": "https://docs.litellm.ai/docs/proxy/proxy",
99103
}
100104

101105

@@ -226,6 +230,10 @@ def _detect_provider_status() -> dict[str, str]:
226230
if prov == "gemini":
227231
if os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY"):
228232
status[prov] = env_var
233+
elif prov == "litellm":
234+
# litellm can be configured via API key (cloud) OR base URL (local proxy)
235+
if os.environ.get("LITELLM_API_KEY") or os.environ.get("LITELLM_BASE_URL"):
236+
status[prov] = env_var
229237
elif os.environ.get(env_var):
230238
status[prov] = env_var
231239
return status
@@ -292,14 +300,22 @@ def interactive_provider_select(
292300
env_var = _PROVIDER_ENV[chosen]
293301
signup_url = _PROVIDER_SIGNUP.get(chosen, "")
294302
console.print()
295-
console.print(f" [bold]{chosen}[/bold] requires [cyan]{env_var}[/cyan].")
296-
if signup_url:
297-
console.print(f" Get your API key here: [{BRAND}]{signup_url}[/]")
298-
console.print()
299-
key = _prompt_api_key(console, chosen, env_var, repo_path=repo_path)
300-
if not key:
301-
console.print(f" [{WARN}]Skipped. Please select another provider.[/]")
302-
return interactive_provider_select(console, model_flag, repo_path=repo_path)
303+
# Special case: litellm local proxy doesn't need an API key
304+
if chosen == "litellm" and os.environ.get("LITELLM_BASE_URL"):
305+
console.print(
306+
f" [{OK}]✓ Using LiteLLM proxy at[/] [{BRAND}]{os.environ['LITELLM_BASE_URL']}[/]"
307+
)
308+
console.print(" [dim]No API key required for local proxy.[/dim]")
309+
console.print()
310+
else:
311+
console.print(f" [bold]{chosen}[/bold] requires [cyan]{env_var}[/cyan].")
312+
if signup_url:
313+
console.print(f" Get your API key here: [{BRAND}]{signup_url}[/]")
314+
console.print()
315+
key = _prompt_api_key(console, chosen, env_var, repo_path=repo_path)
316+
if not key:
317+
console.print(f" [{WARN}]Skipped. Please select another provider.[/]")
318+
return interactive_provider_select(console, model_flag, repo_path=repo_path)
303319

304320
# --- model ---
305321
default_model = _PROVIDER_DEFAULTS.get(chosen, "")

packages/core/src/repowise/core/providers/llm/litellm.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,16 @@
1919

2020
from __future__ import annotations
2121

22+
from collections.abc import AsyncIterator
23+
from typing import TYPE_CHECKING, Any
24+
2225
import structlog
2326
from tenacity import (
27+
RetryError,
2428
retry,
2529
retry_if_exception_type,
2630
stop_after_attempt,
2731
wait_exponential_jitter,
28-
RetryError,
2932
)
3033

3134
from repowise.core.providers.llm.base import (
@@ -37,7 +40,6 @@
3740
RateLimitError,
3841
)
3942

40-
from typing import TYPE_CHECKING, Any, AsyncIterator
4143
from repowise.core.rate_limiter import RateLimiter
4244

4345
if TYPE_CHECKING:
@@ -55,9 +57,13 @@ class LiteLLMProvider(BaseProvider):
5557
5658
Args:
5759
model: LiteLLM model string (e.g., "groq/llama-3.1-70b-versatile").
60+
When using api_base (local proxy), just use the model name
61+
(e.g., "zai.glm-5") - the provider will auto-add "openai/" prefix.
5862
api_key: API key for the target provider. Some providers read from
5963
environment variables (e.g., GROQ_API_KEY, TOGETHER_API_KEY).
60-
api_base: Optional custom API base URL (e.g., for self-hosted deployments).
64+
For local proxies without auth, a dummy key is used.
65+
api_base: Optional custom API base URL for self-hosted LiteLLM proxy.
66+
When set, the model is treated as OpenAI-compatible.
6167
rate_limiter: Optional RateLimiter instance.
6268
"""
6369

@@ -75,6 +81,13 @@ def __init__(
7581
self._rate_limiter = rate_limiter
7682
self._cost_tracker = cost_tracker
7783

84+
# When using a custom api_base (proxy), treat model as OpenAI-compatible.
85+
# LiteLLM requires "openai/" prefix to route to custom endpoints.
86+
if api_base and not model.startswith("openai/"):
87+
self._litellm_model = f"openai/{model}"
88+
else:
89+
self._litellm_model = model
90+
7891
@property
7992
def provider_name(self) -> str:
8093
return "litellm"
@@ -130,7 +143,7 @@ async def _generate_with_retry(
130143
litellm.suppress_debug_info = True
131144

132145
call_kwargs: dict[str, object] = {
133-
"model": self._model,
146+
"model": self._litellm_model,
134147
"messages": [
135148
{"role": "system", "content": system_prompt},
136149
{"role": "user", "content": user_prompt},
@@ -142,6 +155,10 @@ async def _generate_with_retry(
142155
call_kwargs["api_key"] = self._api_key
143156
if self._api_base:
144157
call_kwargs["api_base"] = self._api_base
158+
# Local proxy without auth: OpenAI SDK still requires a key.
159+
# Use a dummy key if none provided.
160+
if not self._api_key:
161+
call_kwargs["api_key"] = "sk-dummy"
145162

146163
try:
147164
response = await litellm.acompletion(**call_kwargs)
@@ -199,14 +216,15 @@ async def stream_chat(
199216
tool_executor: Any | None = None,
200217
) -> AsyncIterator[ChatStreamEvent]:
201218
import json as _json
219+
202220
import litellm # type: ignore[import-untyped]
203221

204222
litellm.set_verbose = False
205223
litellm.suppress_debug_info = True
206224

207225
full_messages = [{"role": "system", "content": system_prompt}, *messages]
208226
call_kwargs: dict[str, Any] = {
209-
"model": self._model,
227+
"model": self._litellm_model,
210228
"messages": full_messages,
211229
"temperature": temperature,
212230
"max_tokens": max_tokens,
@@ -218,6 +236,10 @@ async def stream_chat(
218236
call_kwargs["api_key"] = self._api_key
219237
if self._api_base:
220238
call_kwargs["api_base"] = self._api_base
239+
# Local proxy without auth: OpenAI SDK still requires a key.
240+
# Use a dummy key if none provided.
241+
if not self._api_key:
242+
call_kwargs["api_key"] = "sk-dummy"
221243

222244
try:
223245
stream = await litellm.acompletion(**call_kwargs)
@@ -244,7 +266,11 @@ async def stream_chat(
244266
for tc_delta in delta.tool_calls:
245267
idx = tc_delta.index
246268
if idx not in tool_calls_acc:
247-
tool_calls_acc[idx] = {"id": getattr(tc_delta, "id", "") or "", "name": "", "arguments": ""}
269+
tool_calls_acc[idx] = {
270+
"id": getattr(tc_delta, "id", "") or "",
271+
"name": "",
272+
"arguments": "",
273+
}
248274
acc = tool_calls_acc[idx]
249275
if getattr(tc_delta, "id", None):
250276
acc["id"] = tc_delta.id

tests/unit/cli/test_helpers.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,3 +231,30 @@ def test_anthropic_empty_key_auto_detect(self, monkeypatch):
231231
assert len(warnings) == 1
232232
assert "anthropic" in warnings[0]
233233
assert "ANTHROPIC_API_KEY" in warnings[0]
234+
235+
# --- litellm tests ---
236+
237+
def test_litellm_with_api_key(self, monkeypatch):
238+
monkeypatch.setenv("LITELLM_API_KEY", "test-key")
239+
monkeypatch.setenv("REPOWISE_PROVIDER", "litellm")
240+
241+
assert validate_provider_config() == []
242+
243+
def test_litellm_with_base_url(self, monkeypatch):
244+
"""Local proxy without API key should be valid."""
245+
monkeypatch.delenv("LITELLM_API_KEY", raising=False)
246+
monkeypatch.setenv("LITELLM_BASE_URL", "http://localhost:4000/v1")
247+
monkeypatch.setenv("REPOWISE_PROVIDER", "litellm")
248+
249+
assert validate_provider_config() == []
250+
251+
def test_litellm_missing_both(self, monkeypatch):
252+
"""Should warn when neither API key nor base URL is set."""
253+
monkeypatch.delenv("LITELLM_API_KEY", raising=False)
254+
monkeypatch.delenv("LITELLM_BASE_URL", raising=False)
255+
monkeypatch.setenv("REPOWISE_PROVIDER", "litellm")
256+
257+
warnings = validate_provider_config()
258+
assert len(warnings) == 1
259+
assert "litellm" in warnings[0]
260+
assert "LITELLM_API_KEY" in warnings[0] or "LITELLM_BASE_URL" in warnings[0]

0 commit comments

Comments
 (0)