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
67 changes: 65 additions & 2 deletions packages/cli/src/repowise/cli/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,22 @@ 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"]
elif provider_name == "zai":
# Z.AI: API key, plan, base URL, and thinking mode
if os.environ.get("ZAI_API_KEY"):
kwargs["api_key"] = os.environ["ZAI_API_KEY"]
if os.environ.get("ZAI_PLAN"):
kwargs["plan"] = os.environ["ZAI_PLAN"]
if os.environ.get("ZAI_BASE_URL"):
kwargs["base_url"] = os.environ["ZAI_BASE_URL"]
if os.environ.get("ZAI_THINKING"):
kwargs["thinking"] = os.environ["ZAI_THINKING"]

return get_provider(provider_name, **kwargs)

Expand Down Expand Up @@ -293,10 +309,38 @@ 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)
# Z.AI: check for API key
if os.environ.get("ZAI_API_KEY") and os.environ["ZAI_API_KEY"].strip():
kwargs = {"api_key": os.environ["ZAI_API_KEY"]}
if model:
kwargs["model"] = model
if os.environ.get("ZAI_PLAN"):
kwargs["plan"] = os.environ["ZAI_PLAN"]
if os.environ.get("ZAI_BASE_URL"):
kwargs["base_url"] = os.environ["ZAI_BASE_URL"]
if os.environ.get("ZAI_THINKING"):
kwargs["thinking"] = os.environ["ZAI_THINKING"]
return get_provider("zai", **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 / ZAI_API_KEY."
)


Expand Down Expand Up @@ -332,7 +376,11 @@ 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)
"zai": ["ZAI_API_KEY"],
}

if provider_name:
Expand All @@ -348,6 +396,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 @@ -370,6 +422,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 @@ -268,18 +268,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 @@ -410,6 +414,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 @@ -476,14 +484,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
57 changes: 56 additions & 1 deletion packages/core/src/repowise/core/providers/llm/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, AsyncIterator, Protocol, runtime_checkable
from typing import Any, AsyncIterator, Protocol, TYPE_CHECKING, runtime_checkable

if TYPE_CHECKING:
from repowise.core.rate_limiter import RateLimitConfig, RateLimiter


@dataclass
Expand Down Expand Up @@ -59,8 +62,60 @@ class BaseProvider(ABC):
- Return GeneratedResponse with correct token counts
- Raise ProviderError on non-recoverable API errors
- Raise RateLimitError on 429 responses after retries are exhausted

Class Attributes:
RATE_LIMIT_TIERS: Optional mapping of tier name to RateLimitConfig.
Providers with subscription tiers (e.g., Z.AI's lite/pro/max,
MiniMax's starter/plus/max) define this to support tier-aware
rate limiting. When set, users can pass ``tier="pro"`` to the
constructor and the appropriate rate limiter is created automatically.
"""

RATE_LIMIT_TIERS: dict[str, Any] = {} # Override in subclasses

@staticmethod
def resolve_rate_limiter(
tier: str | None = None,
tiers: dict[str, Any] | None = None,
rate_limiter: Any | None = None,
) -> Any | None:
"""Resolve rate limiter using tier precedence.

Precedence: tier > explicit rate_limiter > None.

When tier is set, it takes precedence -- it represents a specific
provider signal that overrides the generic registry default.

Args:
tier: Tier name (e.g., 'lite', 'pro', 'max'). Case-insensitive.
tiers: Mapping of tier name to RateLimitConfig.
rate_limiter: Explicitly provided RateLimiter instance.

Returns:
A RateLimiter instance, or None if neither tier nor
rate_limiter is provided.

Raises:
ValueError: If tier is not found in the tiers mapping.
"""
# Late import to avoid circular dependency at module level
from repowise.core.rate_limiter import RateLimiter

if tier is not None:
if not tiers:
msg = f"Tier {tier!r} specified but provider defines no tiers"
raise ValueError(msg)
tier_key = tier.lower()
tier_config = tiers.get(tier_key)
if tier_config is None:
valid = ", ".join(sorted(tiers))
msg = f"Unknown tier {tier!r}. Valid tiers: {valid}"
raise ValueError(msg)
return RateLimiter(tier_config)
if rate_limiter is not None:
return rate_limiter
return None

@abstractmethod
async def generate(
self,
Expand Down
34 changes: 28 additions & 6 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 TYPE_CHECKING, 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 @@ -37,7 +40,6 @@
RateLimitError,
)

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

if TYPE_CHECKING:
Expand All @@ -55,9 +57,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 @@ -75,6 +81,13 @@ def __init__(
self._rate_limiter = rate_limiter
self._cost_tracker = cost_tracker

# 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 @@ -130,7 +143,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 @@ -142,6 +155,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 @@ -199,14 +214,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 @@ -218,6 +234,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 @@ -244,7 +262,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
7 changes: 6 additions & 1 deletion packages/core/src/repowise/core/providers/llm/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
Built-in providers:
- anthropic → AnthropicProvider
- openai → OpenAIProvider
- gemini → GeminiProvider
- ollama → OllamaProvider
- litellm → LiteLLMProvider
- zai → ZAIProvider
- mock → MockProvider (testing only)

Custom provider registration:
Expand All @@ -24,7 +26,8 @@
from __future__ import annotations

import importlib
from typing import Any, Callable
from collections.abc import Callable
from typing import Any

from repowise.core.providers.llm.base import BaseProvider
from repowise.core.rate_limiter import PROVIDER_DEFAULTS, RateLimitConfig, RateLimiter
Expand All @@ -39,6 +42,7 @@
"gemini": ("repowise.core.providers.llm.gemini", "GeminiProvider"),
"ollama": ("repowise.core.providers.llm.ollama", "OllamaProvider"),
"litellm": ("repowise.core.providers.llm.litellm", "LiteLLMProvider"),
"zai": ("repowise.core.providers.llm.zai", "ZAIProvider"),
"mock": ("repowise.core.providers.llm.mock", "MockProvider"),
}

Expand Down Expand Up @@ -135,6 +139,7 @@ def get_provider(
"gemini": "google-genai",
"ollama": "openai", # ollama uses the openai package
"litellm": "litellm",
"zai": "openai", # zai uses the openai package (OpenAI-compatible API)
}
package = _missing.get(name, name)
raise ImportError(
Expand Down
Loading