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
44 changes: 42 additions & 2 deletions packages/cli/src/repowise/cli/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,12 @@ def resolve_provider(
kwargs["api_key"] = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
elif provider_name == "ollama" and os.environ.get("OLLAMA_BASE_URL"):
kwargs["base_url"] = os.environ["OLLAMA_BASE_URL"]
elif provider_name == "litellm":
# LiteLLM: API key for cloud, base URL for local proxy
if os.environ.get("LITELLM_API_KEY"):
kwargs["api_key"] = os.environ["LITELLM_API_KEY"]
if os.environ.get("LITELLM_BASE_URL"):
kwargs["api_base"] = os.environ["LITELLM_BASE_URL"]

return get_provider(provider_name, **kwargs)

Expand Down Expand Up @@ -282,10 +288,26 @@ def resolve_provider(
api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
kwargs = {"model": model, "api_key": api_key} if model else {"api_key": api_key}
return get_provider("gemini", **kwargs)
# LiteLLM: check for API key (cloud) or base URL (local proxy)
if os.environ.get("LITELLM_API_KEY") and os.environ["LITELLM_API_KEY"].strip():
kwargs = (
{"model": model, "api_key": os.environ["LITELLM_API_KEY"]}
if model
else {"api_key": os.environ["LITELLM_API_KEY"]}
)
return get_provider("litellm", **kwargs)
if os.environ.get("LITELLM_BASE_URL") and os.environ["LITELLM_BASE_URL"].strip():
kwargs = (
{"model": model, "api_base": os.environ["LITELLM_BASE_URL"]}
if model
else {"api_base": os.environ["LITELLM_BASE_URL"]}
)
return get_provider("litellm", **kwargs)

raise click.ClickException(
"No provider configured. Use --provider, set REPOWISE_PROVIDER, "
"or set ANTHROPIC_API_KEY / OPENAI_API_KEY / OLLAMA_BASE_URL / GEMINI_API_KEY / GOOGLE_API_KEY."
"or set ANTHROPIC_API_KEY / OPENAI_API_KEY / OLLAMA_BASE_URL / GEMINI_API_KEY / "
"LITELLM_API_KEY / LITELLM_BASE_URL."
)


Expand Down Expand Up @@ -321,7 +343,10 @@ def _is_env_var_exists(var_name: str) -> bool:
"openai": ["OPENAI_API_KEY"],
"gemini": ["GEMINI_API_KEY", "GOOGLE_API_KEY"], # Either one
"ollama": ["OLLAMA_BASE_URL"],
"litellm": ["LITELLM_API_KEY"], # May need others depending on backend
"litellm": [
"LITELLM_API_KEY",
"LITELLM_BASE_URL",
], # Either one (API key for cloud, base URL for local)
}

if provider_name:
Expand All @@ -337,6 +362,10 @@ def _is_env_var_exists(var_name: str) -> bool:
# Special case: either GEMINI_API_KEY or GOOGLE_API_KEY
if not (_is_env_var_set("GEMINI_API_KEY") or _is_env_var_set("GOOGLE_API_KEY")):
missing_vars = env_vars
elif provider_name == "litellm":
# Special case: LITELLM_API_KEY (cloud) OR LITELLM_BASE_URL (local proxy)
if not (_is_env_var_set("LITELLM_API_KEY") or _is_env_var_set("LITELLM_BASE_URL")):
missing_vars = env_vars
else:
for var in env_vars:
if not _is_env_var_set(var):
Expand All @@ -359,6 +388,17 @@ def _is_env_var_exists(var_name: str) -> bool:
)
continue

if name == "litellm":
# Special case: LITELLM_API_KEY (cloud) OR LITELLM_BASE_URL (local proxy)
# Only warn if explicitly requested and neither is set
if os.environ.get("REPOWISE_PROVIDER") == "litellm" and not (
_is_env_var_set("LITELLM_API_KEY") or _is_env_var_set("LITELLM_BASE_URL")
):
warnings.append(
"Provider 'litellm' requires LITELLM_API_KEY or LITELLM_BASE_URL environment variable"
)
continue

missing = [var for var in env_vars if not _is_env_var_set(var)]
if missing:
# Only warn if this provider is explicitly requested OR
Expand Down
32 changes: 24 additions & 8 deletions packages/cli/src/repowise/cli/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,18 +84,22 @@ def print_phase_header(
"litellm": "groq/llama-3.1-70b-versatile",
}

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

_PROVIDER_SIGNUP: dict[str, str] = {
"gemini": "https://aistudio.google.com/apikey",
"openai": "https://platform.openai.com/api-keys",
"anthropic": "https://console.anthropic.com/settings/keys",
"ollama": "https://ollama.com/download",
"litellm": "https://docs.litellm.ai/docs/proxy/proxy",
}


Expand Down Expand Up @@ -226,6 +230,10 @@ def _detect_provider_status() -> dict[str, str]:
if prov == "gemini":
if os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY"):
status[prov] = env_var
elif prov == "litellm":
# litellm can be configured via API key (cloud) OR base URL (local proxy)
if os.environ.get("LITELLM_API_KEY") or os.environ.get("LITELLM_BASE_URL"):
status[prov] = env_var
elif os.environ.get(env_var):
status[prov] = env_var
return status
Expand Down Expand Up @@ -292,14 +300,22 @@ def interactive_provider_select(
env_var = _PROVIDER_ENV[chosen]
signup_url = _PROVIDER_SIGNUP.get(chosen, "")
console.print()
console.print(f" [bold]{chosen}[/bold] requires [cyan]{env_var}[/cyan].")
if signup_url:
console.print(f" Get your API key here: [{BRAND}]{signup_url}[/]")
console.print()
key = _prompt_api_key(console, chosen, env_var, repo_path=repo_path)
if not key:
console.print(f" [{WARN}]Skipped. Please select another provider.[/]")
return interactive_provider_select(console, model_flag, repo_path=repo_path)
# Special case: litellm local proxy doesn't need an API key
if chosen == "litellm" and os.environ.get("LITELLM_BASE_URL"):
console.print(
f" [{OK}]✓ Using LiteLLM proxy at[/] [{BRAND}]{os.environ['LITELLM_BASE_URL']}[/]"
)
console.print(" [dim]No API key required for local proxy.[/dim]")
console.print()
else:
console.print(f" [bold]{chosen}[/bold] requires [cyan]{env_var}[/cyan].")
if signup_url:
console.print(f" Get your API key here: [{BRAND}]{signup_url}[/]")
console.print()
key = _prompt_api_key(console, chosen, env_var, repo_path=repo_path)
if not key:
console.print(f" [{WARN}]Skipped. Please select another provider.[/]")
return interactive_provider_select(console, model_flag, repo_path=repo_path)

# --- model ---
default_model = _PROVIDER_DEFAULTS.get(chosen, "")
Expand Down
35 changes: 28 additions & 7 deletions packages/core/src/repowise/core/providers/llm/litellm.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@

from __future__ import annotations

from collections.abc import AsyncIterator
from typing import Any

import structlog
from tenacity import (
RetryError,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential_jitter,
RetryError,
)

from repowise.core.providers.llm.base import (
Expand All @@ -36,8 +39,6 @@
ProviderError,
RateLimitError,
)

from typing import Any, AsyncIterator
from repowise.core.rate_limiter import RateLimiter

log = structlog.get_logger(__name__)
Expand All @@ -52,9 +53,13 @@ class LiteLLMProvider(BaseProvider):

Args:
model: LiteLLM model string (e.g., "groq/llama-3.1-70b-versatile").
When using api_base (local proxy), just use the model name
(e.g., "zai.glm-5") - the provider will auto-add "openai/" prefix.
api_key: API key for the target provider. Some providers read from
environment variables (e.g., GROQ_API_KEY, TOGETHER_API_KEY).
api_base: Optional custom API base URL (e.g., for self-hosted deployments).
For local proxies without auth, a dummy key is used.
api_base: Optional custom API base URL for self-hosted LiteLLM proxy.
When set, the model is treated as OpenAI-compatible.
rate_limiter: Optional RateLimiter instance.
"""

Expand All @@ -70,6 +75,13 @@ def __init__(
self._api_base = api_base
self._rate_limiter = rate_limiter

# When using a custom api_base (proxy), treat model as OpenAI-compatible.
# LiteLLM requires "openai/" prefix to route to custom endpoints.
if api_base and not model.startswith("openai/"):
self._litellm_model = f"openai/{model}"
else:
self._litellm_model = model

@property
def provider_name(self) -> str:
return "litellm"
Expand Down Expand Up @@ -125,7 +137,7 @@ async def _generate_with_retry(
litellm.suppress_debug_info = True

call_kwargs: dict[str, object] = {
"model": self._model,
"model": self._litellm_model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
Expand All @@ -137,6 +149,8 @@ async def _generate_with_retry(
call_kwargs["api_key"] = self._api_key
if self._api_base:
call_kwargs["api_base"] = self._api_base
if not self._api_key:
call_kwargs["api_key"] = "sk-dummy" # LiteLLM requires a non-empty key even for unauthenticated local proxies (OpenAI SDK requirement)

try:
response = await litellm.acompletion(**call_kwargs)
Expand Down Expand Up @@ -177,14 +191,15 @@ async def stream_chat(
tool_executor: Any | None = None,
) -> AsyncIterator[ChatStreamEvent]:
import json as _json

import litellm # type: ignore[import-untyped]

litellm.set_verbose = False
litellm.suppress_debug_info = True

full_messages = [{"role": "system", "content": system_prompt}, *messages]
call_kwargs: dict[str, Any] = {
"model": self._model,
"model": self._litellm_model,
"messages": full_messages,
"temperature": temperature,
"max_tokens": max_tokens,
Expand All @@ -196,6 +211,8 @@ async def stream_chat(
call_kwargs["api_key"] = self._api_key
if self._api_base:
call_kwargs["api_base"] = self._api_base
if not self._api_key:
call_kwargs["api_key"] = "sk-dummy" # LiteLLM requires a non-empty key even for unauthenticated local proxies (OpenAI SDK requirement)

try:
stream = await litellm.acompletion(**call_kwargs)
Expand All @@ -222,7 +239,11 @@ async def stream_chat(
for tc_delta in delta.tool_calls:
idx = tc_delta.index
if idx not in tool_calls_acc:
tool_calls_acc[idx] = {"id": getattr(tc_delta, "id", "") or "", "name": "", "arguments": ""}
tool_calls_acc[idx] = {
"id": getattr(tc_delta, "id", "") or "",
"name": "",
"arguments": "",
}
acc = tool_calls_acc[idx]
if getattr(tc_delta, "id", None):
acc["id"] = tc_delta.id
Expand Down
27 changes: 27 additions & 0 deletions tests/unit/cli/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,30 @@ def test_anthropic_empty_key_auto_detect(self, monkeypatch):
assert len(warnings) == 1
assert "anthropic" in warnings[0]
assert "ANTHROPIC_API_KEY" in warnings[0]

# --- litellm tests ---

def test_litellm_with_api_key(self, monkeypatch):
monkeypatch.setenv("LITELLM_API_KEY", "test-key")
monkeypatch.setenv("REPOWISE_PROVIDER", "litellm")

assert validate_provider_config() == []

def test_litellm_with_base_url(self, monkeypatch):
"""Local proxy without API key should be valid."""
monkeypatch.delenv("LITELLM_API_KEY", raising=False)
monkeypatch.setenv("LITELLM_BASE_URL", "http://localhost:4000/v1")
monkeypatch.setenv("REPOWISE_PROVIDER", "litellm")

assert validate_provider_config() == []

def test_litellm_missing_both(self, monkeypatch):
"""Should warn when neither API key nor base URL is set."""
monkeypatch.delenv("LITELLM_API_KEY", raising=False)
monkeypatch.delenv("LITELLM_BASE_URL", raising=False)
monkeypatch.setenv("REPOWISE_PROVIDER", "litellm")

warnings = validate_provider_config()
assert len(warnings) == 1
assert "litellm" in warnings[0]
assert "LITELLM_API_KEY" in warnings[0] or "LITELLM_BASE_URL" in warnings[0]
Loading