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
6 changes: 6 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -903,8 +903,14 @@ repowise watch --workspace # all workspace repos
| Variable | Required | Description |
|----------|----------|-------------|
| `ANTHROPIC_API_KEY` | If using Anthropic | Anthropic API key |
| `ANTHROPIC_BASE_URL` | No | Base URL override for Anthropic-compatible APIs |
| `OPENAI_API_KEY` | If using OpenAI | OpenAI API key |
| `OPENAI_BASE_URL` | No | Base URL override for OpenAI-compatible APIs |
| `GEMINI_API_KEY` | If using Gemini | Google Gemini API key |
| `GEMINI_BASE_URL` | No | Base URL override for Gemini-compatible APIs |
| `OLLAMA_BASE_URL` | If using Ollama | Ollama server URL (default: `http://localhost:11434`) |
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The env var table marks OLLAMA_BASE_URL as required “If using Ollama”, but the description states there is a default (http://localhost:11434). If the app works with the default URL, this variable isn’t actually required and the “Required” column should likely be No (or clarify that it’s only required when using a non-default host).

Suggested change
| `OLLAMA_BASE_URL` | If using Ollama | Ollama server URL (default: `http://localhost:11434`) |
| `OLLAMA_BASE_URL` | No | Ollama server URL override (default: `http://localhost:11434`) |

Copilot uses AI. Check for mistakes.
| `LITELLM_BASE_URL` | No | Base URL override for LiteLLM proxy |
| `LITELLM_API_BASE` | No | LiteLLM base URL alias (same as `LITELLM_BASE_URL`) |
| `REPOWISE_DB_URL` | No | Database URL override (default: `.repowise/wiki.db`) |
| `REPOWISE_EMBEDDER` | No | Embedder for semantic search: `gemini`, `openai`, `mock` |
| `REPOWISE_API_URL` | Frontend only | Backend URL for the web UI (default: `http://localhost:7337`) |
Expand Down
46 changes: 40 additions & 6 deletions packages/cli/src/repowise/cli/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,15 +228,37 @@ def resolve_provider(
"""
from repowise.core.providers import get_provider

cfg: dict[str, Any] = {}
if repo_path is not None:
cfg = load_config(repo_path)

if provider_name is None:
provider_name = os.environ.get("REPOWISE_PROVIDER")

if provider_name is None and repo_path is not None:
cfg = load_config(repo_path)
if cfg.get("provider"):
provider_name = cfg["provider"]
if model is None and cfg.get("model"):
model = cfg["model"]
if provider_name is None and cfg.get("provider"):
provider_name = cfg["provider"]
if model is None and cfg.get("model"):
model = cfg["model"]

def _resolve_base_url(name: str) -> str | None:
"""Return base_url from env or repo config for the provider."""
env_vars = {
"anthropic": ["ANTHROPIC_BASE_URL"],
"openai": ["OPENAI_BASE_URL"],
"gemini": ["GEMINI_BASE_URL"],
"ollama": ["OLLAMA_BASE_URL"],
"litellm": ["LITELLM_BASE_URL", "LITELLM_API_BASE"],
}
for var in env_vars.get(name, []):
Comment on lines +245 to +252
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_resolve_base_url() duplicates the same provider→env-var mapping logic that also exists in the server code (packages/server/.../provider_config.py and .../tool_answer.py). This creates a drift risk (new providers/aliases require updating multiple tables). Consider centralizing this mapping/resolution in a shared helper (e.g., in repowise.core.providers), and reusing it from CLI/server to keep behavior consistent.

Suggested change
env_vars = {
"anthropic": ["ANTHROPIC_BASE_URL"],
"openai": ["OPENAI_BASE_URL"],
"gemini": ["GEMINI_BASE_URL"],
"ollama": ["OLLAMA_BASE_URL"],
"litellm": ["LITELLM_BASE_URL", "LITELLM_API_BASE"],
}
for var in env_vars.get(name, []):
normalized_name = name.upper().replace("-", "_")
env_vars = [f"{normalized_name}_BASE_URL"]
if normalized_name == "LITELLM":
env_vars.append("LITELLM_API_BASE")
for var in env_vars:

Copilot uses AI. Check for mistakes.
val = os.environ.get(var)
if val:
return val
section = cfg.get(name)
if isinstance(section, dict):
base_url = section.get("base_url")
if base_url:
return base_url
return None

if provider_name is not None:
# Validate configuration before attempting to create provider
Expand All @@ -250,6 +272,9 @@ def resolve_provider(
kwargs: dict[str, Any] = {}
if model:
kwargs["model"] = model
base_url = _resolve_base_url(provider_name)
if base_url:
kwargs["base_url"] = base_url

# Pass API key from environment if available
if provider_name == "anthropic" and os.environ.get("ANTHROPIC_API_KEY"):
Expand All @@ -272,13 +297,19 @@ def resolve_provider(
if model
else {"api_key": os.environ["ANTHROPIC_API_KEY"]}
)
base_url = _resolve_base_url("anthropic")
if base_url:
kwargs["base_url"] = base_url
return get_provider("anthropic", **kwargs)
if os.environ.get("OPENAI_API_KEY") and os.environ["OPENAI_API_KEY"].strip():
kwargs = (
{"model": model, "api_key": os.environ["OPENAI_API_KEY"]}
if model
else {"api_key": os.environ["OPENAI_API_KEY"]}
)
base_url = _resolve_base_url("openai")
if base_url:
kwargs["base_url"] = base_url
return get_provider("openai", **kwargs)
if os.environ.get("OLLAMA_BASE_URL") and os.environ["OLLAMA_BASE_URL"].strip():
kwargs = (
Expand All @@ -292,6 +323,9 @@ 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}
base_url = _resolve_base_url("gemini")
if base_url:
kwargs["base_url"] = base_url
return get_provider("gemini", **kwargs)

raise click.ClickException(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class OpenAIEmbedder:
Args:
api_key: OpenAI API key. Falls back to OPENAI_API_KEY env var.
model: Embedding model name. Default: "text-embedding-3-small".
base_url: Optional custom base URL for OpenAI-compatible endpoints.
"""

_DIMS: dict[str, int] = {
Expand All @@ -51,12 +52,14 @@ def __init__(
api_key: str | None = None,
model: str = "text-embedding-3-small",
timeout: float = _DEFAULT_TIMEOUT,
base_url: str | None = None,
) -> None:
self._api_key = api_key or os.environ.get("OPENAI_API_KEY")
if not self._api_key:
raise ValueError(
"OpenAI API key required. Pass api_key= or set OPENAI_API_KEY env var."
)
self._base_url = base_url or os.environ.get("OPENAI_BASE_URL")
self._model = model
self._timeout = timeout
self._client: object | None = None # cached; created once on first embed()
Expand Down Expand Up @@ -91,6 +94,7 @@ def _embed_sync() -> list[list[float]]:
self._client = openai.OpenAI(
api_key=self._api_key,
timeout=timeout,
base_url=self._base_url,
)
response = self._client.embeddings.create(model=model, input=texts) # type: ignore[union-attr]
raw_vectors = [list(item.embedding) for item in response.data]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class AnthropicProvider(BaseProvider):
Args:
api_key: Anthropic API key. Falls back to ANTHROPIC_API_KEY env var.
model: Model identifier. Defaults to claude-sonnet-4-6.
base_url: Optional custom API base URL (for proxies/self-hosted endpoints).
rate_limiter: Optional pre-configured RateLimiter. If None, no rate limiting
is applied (useful when the caller manages concurrency via semaphore).
"""
Expand All @@ -63,6 +64,7 @@ def __init__(
self,
api_key: str | None = None,
model: str = "claude-sonnet-4-6",
base_url: str | None = None,
rate_limiter: RateLimiter | None = None,
cost_tracker: CostTracker | None = None,
) -> None:
Expand All @@ -72,7 +74,8 @@ def __init__(
"anthropic",
"No API key provided. Pass api_key= or set ANTHROPIC_API_KEY.",
)
self._client = AsyncAnthropic(api_key=resolved_key)
resolved_base_url = base_url or os.environ.get("ANTHROPIC_BASE_URL")
self._client = AsyncAnthropic(api_key=resolved_key, base_url=resolved_base_url)
self._model = model
self._rate_limiter = rate_limiter
self._cost_tracker = cost_tracker
Expand Down
33 changes: 31 additions & 2 deletions packages/core/src/repowise/core/providers/llm/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class GeminiProvider(BaseProvider):
Args:
model: Gemini model name. Defaults to gemini-3.1-flash-lite-preview.
api_key: Google API key. Falls back to GEMINI_API_KEY or GOOGLE_API_KEY env var.
base_url: Optional custom base URL (e.g., for proxy/self-hosted endpoints).
rate_limiter: Optional RateLimiter instance.
cost_tracker: Optional CostTracker for recording token usage and cost.
"""
Expand All @@ -63,6 +64,7 @@ def __init__(
self,
model: str = "gemini-3.1-flash-lite-preview",
api_key: str | None = None,
base_url: str | None = None,
rate_limiter: RateLimiter | None = None,
cost_tracker: "CostTracker | None" = None,
) -> None:
Expand All @@ -77,6 +79,7 @@ def __init__(
"gemini",
"No API key found. Pass api_key= or set GEMINI_API_KEY / GOOGLE_API_KEY env var.",
)
self._base_url = base_url or os.environ.get("GEMINI_BASE_URL")
self._rate_limiter = rate_limiter
self._cost_tracker = cost_tracker
self._client: object | None = None # cached; created once on first call
Expand Down Expand Up @@ -138,13 +141,36 @@ async def _generate_with_retry(
# Capture self attrs for thread safety (avoids closing over self)
model = self._model
api_key = self._api_key
base_url = self._base_url

def _call_sync() -> GeneratedResponse:
from google import genai # type: ignore[import-untyped]
from google.genai import types as genai_types # type: ignore[import-untyped]

if self._client is None:
self._client = genai.Client(api_key=api_key)
client_kwargs: dict[str, Any] = {"api_key": api_key}
http_opts = None

if base_url:
try:
http_opts = genai_types.HttpOptions(base_url=base_url)
except TypeError:
log.warning(
"gemini.http_options.base_url_unsupported",
base_url=base_url,
)

if http_opts is not None:
try:
self._client = genai.Client(**client_kwargs, http_options=http_opts)
except TypeError:
log.warning(
"gemini.client.http_options_unsupported",
base_url=base_url,
)
self._client = genai.Client(**client_kwargs)
else:
self._client = genai.Client(**client_kwargs)
client = self._client
try:
response = client.models.generate_content(
Expand Down Expand Up @@ -235,13 +261,16 @@ async def stream_chat(

model_name = self._model
api_key = self._api_key
base_url = self._base_url

def _call_sync(contents, config):
"""Single Gemini generate_content call in thread."""
from google import genai # type: ignore[import-untyped]
from google.genai import types as genai_types # type: ignore[import-untyped]

if self._client is None:
self._client = genai.Client(api_key=api_key)
http_opts = genai_types.HttpOptions(base_url=base_url) if base_url else None
self._client = genai.Client(api_key=api_key, http_options=http_opts)
Comment on lines +272 to +273
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In stream_chat(), genai_types.HttpOptions(base_url=...) and genai.Client(..., http_options=...) are called without the TypeError fallbacks used in _generate_with_retry(). With older/newer google-genai versions that don’t support base_url in HttpOptions or the http_options kwarg on Client, this will raise at runtime and break chat streaming. Please mirror the defensive logic from _generate_with_retry() (or factor client construction into a shared helper) so stream_chat() degrades gracefully when base_url/http_options aren’t supported.

Suggested change
http_opts = genai_types.HttpOptions(base_url=base_url) if base_url else None
self._client = genai.Client(api_key=api_key, http_options=http_opts)
http_opts = None
if base_url:
try:
http_opts = genai_types.HttpOptions(base_url=base_url)
except TypeError:
http_opts = genai_types.HttpOptions()
try:
self._client = genai.Client(api_key=api_key, http_options=http_opts)
except TypeError:
self._client = genai.Client(api_key=api_key)

Copilot uses AI. Check for mistakes.
client = self._client
try:
response = client.models.generate_content(
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/repowise/core/providers/llm/litellm.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from __future__ import annotations

import os
import structlog
from tenacity import (
retry,
Expand Down Expand Up @@ -58,6 +59,7 @@ class LiteLLMProvider(BaseProvider):
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).
base_url: Alias for api_base for OpenAI-compatible proxies.
rate_limiter: Optional RateLimiter instance.
"""

Expand All @@ -66,12 +68,18 @@ def __init__(
model: str,
api_key: str | None = None,
api_base: str | None = None,
base_url: str | None = None,
rate_limiter: RateLimiter | None = None,
cost_tracker: "CostTracker | None" = None,
) -> None:
self._model = model
self._api_key = api_key
self._api_base = api_base
self._api_base = (
api_base
or base_url
or os.environ.get("LITELLM_API_BASE")
or os.environ.get("LITELLM_BASE_URL")
)
self._rate_limiter = rate_limiter
self._cost_tracker = cost_tracker

Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/repowise/core/providers/llm/ollama.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from __future__ import annotations

import os
import structlog
from openai import AsyncOpenAI
from openai import APIStatusError as _OpenAIAPIStatusError
Expand Down Expand Up @@ -76,10 +77,11 @@ class OllamaProvider(BaseProvider):
def __init__(
self,
model: str = "llama3.2",
base_url: str = _DEFAULT_BASE_URL,
base_url: str | None = None,
rate_limiter: RateLimiter | None = None,
) -> None:
self._client = AsyncOpenAI(api_key="ollama", base_url=_normalize_base_url(base_url))
resolved_base_url = base_url or os.environ.get("OLLAMA_BASE_URL") or _DEFAULT_BASE_URL
self._client = AsyncOpenAI(api_key="ollama", base_url=_normalize_base_url(resolved_base_url))
self._model = model
self._rate_limiter = rate_limiter

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/repowise/core/providers/llm/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ def __init__(
"openai",
"No API key provided. Pass api_key= or set OPENAI_API_KEY.",
)
self._client = AsyncOpenAI(api_key=resolved_key, base_url=base_url)
resolved_base_url = base_url or os.environ.get("OPENAI_BASE_URL")
self._client = AsyncOpenAI(api_key=resolved_key, base_url=resolved_base_url)
self._model = model
self._rate_limiter = rate_limiter
self._cost_tracker = cost_tracker
Expand Down
28 changes: 26 additions & 2 deletions packages/server/src/repowise/server/mcp_server/tool_answer.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,20 @@ def _try(provider_name: str, **kwargs: Any):
_log.debug("get_provider(%s) failed", provider_name, exc_info=True)
return None

def _resolve_base_url(provider_name: str) -> str | None:
mapping = {
"openai": ["OPENAI_BASE_URL"],
"anthropic": ["ANTHROPIC_BASE_URL"],
"gemini": ["GEMINI_BASE_URL"],
"ollama": ["OLLAMA_BASE_URL"],
"litellm": ["LITELLM_BASE_URL", "LITELLM_API_BASE"],
}
for env_var in mapping.get(provider_name, []):
val = os.environ.get(env_var)
if val:
return val
return None

# Explicit selection wins.
if name:
kw: dict[str, Any] = {}
Expand All @@ -184,20 +198,27 @@ def _try(provider_name: str, **kwargs: Any):
kw["api_key"] = os.environ.get("GEMINI_API_KEY") or os.environ.get(
"GOOGLE_API_KEY"
)
elif name == "ollama" and os.environ.get("OLLAMA_BASE_URL"):
kw["base_url"] = os.environ["OLLAMA_BASE_URL"]
base_url = _resolve_base_url(name)
if base_url:
kw["base_url"] = base_url
return _try(name, **kw)

# Auto-detect from API keys.
if os.environ.get("ANTHROPIC_API_KEY"):
kw = {"api_key": os.environ["ANTHROPIC_API_KEY"]}
if model:
kw["model"] = model
base_url = _resolve_base_url("anthropic")
if base_url:
kw["base_url"] = base_url
return _try("anthropic", **kw)
if os.environ.get("OPENAI_API_KEY"):
kw = {"api_key": os.environ["OPENAI_API_KEY"]}
if model:
kw["model"] = model
base_url = _resolve_base_url("openai")
if base_url:
kw["base_url"] = base_url
return _try("openai", **kw)
if os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY"):
kw = {
Expand All @@ -206,6 +227,9 @@ def _try(provider_name: str, **kwargs: Any):
}
if model:
kw["model"] = model
base_url = _resolve_base_url("gemini")
if base_url:
kw["base_url"] = base_url
return _try("gemini", **kw)
if os.environ.get("OLLAMA_BASE_URL"):
kw = {"base_url": os.environ["OLLAMA_BASE_URL"]}
Expand Down
Loading
Loading