Skip to content

Commit ded291f

Browse files
Tari PutraCodexveo3sz01-botCopilotCopilot
authored
Add base_url support for AI providers (#1) (#85)
* feat: add base url support for providers * Update packages/core/src/repowise/core/providers/llm/gemini.py * Document provider base_url env vars Agent-Logs-Url: https://github.com/veo3sz01-bot/repowise/sessions/19d8a471-8cf0-47ec-be83-37c705d7e832 * Remove server base_url config fallback Agent-Logs-Url: https://github.com/veo3sz01-bot/repowise/sessions/f1ae2603-6f6d-4530-b7e0-6d6cc811975c --------- Co-authored-by: Codex <242516109+Codex@users.noreply.github.com> Co-authored-by: veo3sz01-bot <271450703+veo3sz01-bot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Raghav Chamadiya <65403859+RaghavChamadiya@users.noreply.github.com>
1 parent b6b7e97 commit ded291f

11 files changed

Lines changed: 215 additions & 15 deletions

File tree

docs/USER_GUIDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,8 +903,14 @@ repowise watch --workspace # all workspace repos
903903
| Variable | Required | Description |
904904
|----------|----------|-------------|
905905
| `ANTHROPIC_API_KEY` | If using Anthropic | Anthropic API key |
906+
| `ANTHROPIC_BASE_URL` | No | Base URL override for Anthropic-compatible APIs |
906907
| `OPENAI_API_KEY` | If using OpenAI | OpenAI API key |
908+
| `OPENAI_BASE_URL` | No | Base URL override for OpenAI-compatible APIs |
907909
| `GEMINI_API_KEY` | If using Gemini | Google Gemini API key |
910+
| `GEMINI_BASE_URL` | No | Base URL override for Gemini-compatible APIs |
911+
| `OLLAMA_BASE_URL` | If using Ollama | Ollama server URL (default: `http://localhost:11434`) |
912+
| `LITELLM_BASE_URL` | No | Base URL override for LiteLLM proxy |
913+
| `LITELLM_API_BASE` | No | LiteLLM base URL alias (same as `LITELLM_BASE_URL`) |
908914
| `REPOWISE_DB_URL` | No | Database URL override (default: `.repowise/wiki.db`) |
909915
| `REPOWISE_EMBEDDER` | No | Embedder for semantic search: `gemini`, `openai`, `mock` |
910916
| `REPOWISE_API_URL` | Frontend only | Backend URL for the web UI (default: `http://localhost:7337`) |

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

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -228,15 +228,37 @@ def resolve_provider(
228228
"""
229229
from repowise.core.providers import get_provider
230230

231+
cfg: dict[str, Any] = {}
232+
if repo_path is not None:
233+
cfg = load_config(repo_path)
234+
231235
if provider_name is None:
232236
provider_name = os.environ.get("REPOWISE_PROVIDER")
233237

234-
if provider_name is None and repo_path is not None:
235-
cfg = load_config(repo_path)
236-
if cfg.get("provider"):
237-
provider_name = cfg["provider"]
238-
if model is None and cfg.get("model"):
239-
model = cfg["model"]
238+
if provider_name is None and cfg.get("provider"):
239+
provider_name = cfg["provider"]
240+
if model is None and cfg.get("model"):
241+
model = cfg["model"]
242+
243+
def _resolve_base_url(name: str) -> str | None:
244+
"""Return base_url from env or repo config for the provider."""
245+
env_vars = {
246+
"anthropic": ["ANTHROPIC_BASE_URL"],
247+
"openai": ["OPENAI_BASE_URL"],
248+
"gemini": ["GEMINI_BASE_URL"],
249+
"ollama": ["OLLAMA_BASE_URL"],
250+
"litellm": ["LITELLM_BASE_URL", "LITELLM_API_BASE"],
251+
}
252+
for var in env_vars.get(name, []):
253+
val = os.environ.get(var)
254+
if val:
255+
return val
256+
section = cfg.get(name)
257+
if isinstance(section, dict):
258+
base_url = section.get("base_url")
259+
if base_url:
260+
return base_url
261+
return None
240262

241263
if provider_name is not None:
242264
# Validate configuration before attempting to create provider
@@ -250,6 +272,9 @@ def resolve_provider(
250272
kwargs: dict[str, Any] = {}
251273
if model:
252274
kwargs["model"] = model
275+
base_url = _resolve_base_url(provider_name)
276+
if base_url:
277+
kwargs["base_url"] = base_url
253278

254279
# Pass API key from environment if available
255280
if provider_name == "anthropic" and os.environ.get("ANTHROPIC_API_KEY"):
@@ -274,13 +299,19 @@ def resolve_provider(
274299
if model
275300
else {"api_key": os.environ["ANTHROPIC_API_KEY"]}
276301
)
302+
base_url = _resolve_base_url("anthropic")
303+
if base_url:
304+
kwargs["base_url"] = base_url
277305
return get_provider("anthropic", **kwargs)
278306
if os.environ.get("OPENAI_API_KEY") and os.environ["OPENAI_API_KEY"].strip():
279307
kwargs = (
280308
{"model": model, "api_key": os.environ["OPENAI_API_KEY"]}
281309
if model
282310
else {"api_key": os.environ["OPENAI_API_KEY"]}
283311
)
312+
base_url = _resolve_base_url("openai")
313+
if base_url:
314+
kwargs["base_url"] = base_url
284315
return get_provider("openai", **kwargs)
285316
if os.environ.get("OPENROUTER_API_KEY") and os.environ["OPENROUTER_API_KEY"].strip():
286317
kwargs = (
@@ -301,6 +332,9 @@ def resolve_provider(
301332
):
302333
api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
303334
kwargs = {"model": model, "api_key": api_key} if model else {"api_key": api_key}
335+
base_url = _resolve_base_url("gemini")
336+
if base_url:
337+
kwargs["base_url"] = base_url
304338
return get_provider("gemini", **kwargs)
305339

306340
raise click.ClickException(

packages/core/src/repowise/core/providers/embedding/openai.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class OpenAIEmbedder:
3535
Args:
3636
api_key: OpenAI API key. Falls back to OPENAI_API_KEY env var.
3737
model: Embedding model name. Default: "text-embedding-3-small".
38+
base_url: Optional custom base URL for OpenAI-compatible endpoints.
3839
"""
3940

4041
_DIMS: dict[str, int] = {
@@ -51,12 +52,14 @@ def __init__(
5152
api_key: str | None = None,
5253
model: str = "text-embedding-3-small",
5354
timeout: float = _DEFAULT_TIMEOUT,
55+
base_url: str | None = None,
5456
) -> None:
5557
self._api_key = api_key or os.environ.get("OPENAI_API_KEY")
5658
if not self._api_key:
5759
raise ValueError(
5860
"OpenAI API key required. Pass api_key= or set OPENAI_API_KEY env var."
5961
)
62+
self._base_url = base_url or os.environ.get("OPENAI_BASE_URL")
6063
self._model = model
6164
self._timeout = timeout
6265
self._client: object | None = None # cached; created once on first embed()
@@ -91,6 +94,7 @@ def _embed_sync() -> list[list[float]]:
9194
self._client = openai.OpenAI(
9295
api_key=self._api_key,
9396
timeout=timeout,
97+
base_url=self._base_url,
9498
)
9599
response = self._client.embeddings.create(model=model, input=texts) # type: ignore[union-attr]
96100
raw_vectors = [list(item.embedding) for item in response.data]

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class AnthropicProvider(BaseProvider):
5555
Args:
5656
api_key: Anthropic API key. Falls back to ANTHROPIC_API_KEY env var.
5757
model: Model identifier. Defaults to claude-sonnet-4-6.
58+
base_url: Optional custom API base URL (for proxies/self-hosted endpoints).
5859
rate_limiter: Optional pre-configured RateLimiter. If None, no rate limiting
5960
is applied (useful when the caller manages concurrency via semaphore).
6061
"""
@@ -63,6 +64,7 @@ def __init__(
6364
self,
6465
api_key: str | None = None,
6566
model: str = "claude-sonnet-4-6",
67+
base_url: str | None = None,
6668
rate_limiter: RateLimiter | None = None,
6769
cost_tracker: CostTracker | None = None,
6870
) -> None:
@@ -72,7 +74,8 @@ def __init__(
7274
"anthropic",
7375
"No API key provided. Pass api_key= or set ANTHROPIC_API_KEY.",
7476
)
75-
self._client = AsyncAnthropic(api_key=resolved_key)
77+
resolved_base_url = base_url or os.environ.get("ANTHROPIC_BASE_URL")
78+
self._client = AsyncAnthropic(api_key=resolved_key, base_url=resolved_base_url)
7679
self._model = model
7780
self._rate_limiter = rate_limiter
7881
self._cost_tracker = cost_tracker

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

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class GeminiProvider(BaseProvider):
5555
Args:
5656
model: Gemini model name. Defaults to gemini-3.1-flash-lite-preview.
5757
api_key: Google API key. Falls back to GEMINI_API_KEY or GOOGLE_API_KEY env var.
58+
base_url: Optional custom base URL (e.g., for proxy/self-hosted endpoints).
5859
rate_limiter: Optional RateLimiter instance.
5960
cost_tracker: Optional CostTracker for recording token usage and cost.
6061
"""
@@ -63,6 +64,7 @@ def __init__(
6364
self,
6465
model: str = "gemini-3.1-flash-lite-preview",
6566
api_key: str | None = None,
67+
base_url: str | None = None,
6668
rate_limiter: RateLimiter | None = None,
6769
cost_tracker: "CostTracker | None" = None,
6870
) -> None:
@@ -77,6 +79,7 @@ def __init__(
7779
"gemini",
7880
"No API key found. Pass api_key= or set GEMINI_API_KEY / GOOGLE_API_KEY env var.",
7981
)
82+
self._base_url = base_url or os.environ.get("GEMINI_BASE_URL")
8083
self._rate_limiter = rate_limiter
8184
self._cost_tracker = cost_tracker
8285
self._client: object | None = None # cached; created once on first call
@@ -138,13 +141,36 @@ async def _generate_with_retry(
138141
# Capture self attrs for thread safety (avoids closing over self)
139142
model = self._model
140143
api_key = self._api_key
144+
base_url = self._base_url
141145

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

146150
if self._client is None:
147-
self._client = genai.Client(api_key=api_key)
151+
client_kwargs: dict[str, Any] = {"api_key": api_key}
152+
http_opts = None
153+
154+
if base_url:
155+
try:
156+
http_opts = genai_types.HttpOptions(base_url=base_url)
157+
except TypeError:
158+
log.warning(
159+
"gemini.http_options.base_url_unsupported",
160+
base_url=base_url,
161+
)
162+
163+
if http_opts is not None:
164+
try:
165+
self._client = genai.Client(**client_kwargs, http_options=http_opts)
166+
except TypeError:
167+
log.warning(
168+
"gemini.client.http_options_unsupported",
169+
base_url=base_url,
170+
)
171+
self._client = genai.Client(**client_kwargs)
172+
else:
173+
self._client = genai.Client(**client_kwargs)
148174
client = self._client
149175
try:
150176
response = client.models.generate_content(
@@ -235,13 +261,16 @@ async def stream_chat(
235261

236262
model_name = self._model
237263
api_key = self._api_key
264+
base_url = self._base_url
238265

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

243271
if self._client is None:
244-
self._client = genai.Client(api_key=api_key)
272+
http_opts = genai_types.HttpOptions(base_url=base_url) if base_url else None
273+
self._client = genai.Client(api_key=api_key, http_options=http_opts)
245274
client = self._client
246275
try:
247276
response = client.models.generate_content(

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from __future__ import annotations
2121

22+
import os
2223
import structlog
2324
from tenacity import (
2425
retry,
@@ -58,6 +59,7 @@ class LiteLLMProvider(BaseProvider):
5859
api_key: API key for the target provider. Some providers read from
5960
environment variables (e.g., GROQ_API_KEY, TOGETHER_API_KEY).
6061
api_base: Optional custom API base URL (e.g., for self-hosted deployments).
62+
base_url: Alias for api_base for OpenAI-compatible proxies.
6163
rate_limiter: Optional RateLimiter instance.
6264
"""
6365

@@ -66,12 +68,18 @@ def __init__(
6668
model: str,
6769
api_key: str | None = None,
6870
api_base: str | None = None,
71+
base_url: str | None = None,
6972
rate_limiter: RateLimiter | None = None,
7073
cost_tracker: "CostTracker | None" = None,
7174
) -> None:
7275
self._model = model
7376
self._api_key = api_key
74-
self._api_base = api_base
77+
self._api_base = (
78+
api_base
79+
or base_url
80+
or os.environ.get("LITELLM_API_BASE")
81+
or os.environ.get("LITELLM_BASE_URL")
82+
)
7583
self._rate_limiter = rate_limiter
7684
self._cost_tracker = cost_tracker
7785

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from __future__ import annotations
2222

23+
import os
2324
import structlog
2425
from openai import AsyncOpenAI
2526
from openai import APIStatusError as _OpenAIAPIStatusError
@@ -76,10 +77,11 @@ class OllamaProvider(BaseProvider):
7677
def __init__(
7778
self,
7879
model: str = "llama3.2",
79-
base_url: str = _DEFAULT_BASE_URL,
80+
base_url: str | None = None,
8081
rate_limiter: RateLimiter | None = None,
8182
) -> None:
82-
self._client = AsyncOpenAI(api_key="ollama", base_url=_normalize_base_url(base_url))
83+
resolved_base_url = base_url or os.environ.get("OLLAMA_BASE_URL") or _DEFAULT_BASE_URL
84+
self._client = AsyncOpenAI(api_key="ollama", base_url=_normalize_base_url(resolved_base_url))
8385
self._model = model
8486
self._rate_limiter = rate_limiter
8587

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ def __init__(
7272
"openai",
7373
"No API key provided. Pass api_key= or set OPENAI_API_KEY.",
7474
)
75-
self._client = AsyncOpenAI(api_key=resolved_key, base_url=base_url)
75+
resolved_base_url = base_url or os.environ.get("OPENAI_BASE_URL")
76+
self._client = AsyncOpenAI(api_key=resolved_key, base_url=resolved_base_url)
7677
self._model = model
7778
self._rate_limiter = rate_limiter
7879
self._cost_tracker = cost_tracker

packages/server/src/repowise/server/mcp_server/tool_answer.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,20 @@ def _try(provider_name: str, **kwargs: Any):
169169
_log.debug("get_provider(%s) failed", provider_name, exc_info=True)
170170
return None
171171

172+
def _resolve_base_url(provider_name: str) -> str | None:
173+
mapping = {
174+
"openai": ["OPENAI_BASE_URL"],
175+
"anthropic": ["ANTHROPIC_BASE_URL"],
176+
"gemini": ["GEMINI_BASE_URL"],
177+
"ollama": ["OLLAMA_BASE_URL"],
178+
"litellm": ["LITELLM_BASE_URL", "LITELLM_API_BASE"],
179+
}
180+
for env_var in mapping.get(provider_name, []):
181+
val = os.environ.get(env_var)
182+
if val:
183+
return val
184+
return None
185+
172186
# Explicit selection wins.
173187
if name:
174188
kw: dict[str, Any] = {}
@@ -184,20 +198,27 @@ def _try(provider_name: str, **kwargs: Any):
184198
kw["api_key"] = os.environ.get("GEMINI_API_KEY") or os.environ.get(
185199
"GOOGLE_API_KEY"
186200
)
187-
elif name == "ollama" and os.environ.get("OLLAMA_BASE_URL"):
188-
kw["base_url"] = os.environ["OLLAMA_BASE_URL"]
201+
base_url = _resolve_base_url(name)
202+
if base_url:
203+
kw["base_url"] = base_url
189204
return _try(name, **kw)
190205

191206
# Auto-detect from API keys.
192207
if os.environ.get("ANTHROPIC_API_KEY"):
193208
kw = {"api_key": os.environ["ANTHROPIC_API_KEY"]}
194209
if model:
195210
kw["model"] = model
211+
base_url = _resolve_base_url("anthropic")
212+
if base_url:
213+
kw["base_url"] = base_url
196214
return _try("anthropic", **kw)
197215
if os.environ.get("OPENAI_API_KEY"):
198216
kw = {"api_key": os.environ["OPENAI_API_KEY"]}
199217
if model:
200218
kw["model"] = model
219+
base_url = _resolve_base_url("openai")
220+
if base_url:
221+
kw["base_url"] = base_url
201222
return _try("openai", **kw)
202223
if os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY"):
203224
kw = {
@@ -206,6 +227,9 @@ def _try(provider_name: str, **kwargs: Any):
206227
}
207228
if model:
208229
kw["model"] = model
230+
base_url = _resolve_base_url("gemini")
231+
if base_url:
232+
kw["base_url"] = base_url
209233
return _try("gemini", **kw)
210234
if os.environ.get("OLLAMA_BASE_URL"):
211235
kw = {"base_url": os.environ["OLLAMA_BASE_URL"]}

0 commit comments

Comments
 (0)