Skip to content

Commit 3d30287

Browse files
authored
feat: add provider configuration validation and doctor checks (#20)
1 parent 2cf365e commit 3d30287

3 files changed

Lines changed: 247 additions & 11 deletions

File tree

packages/cli/src/repowise/cli/commands/doctor_cmd.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,15 @@ async def _check_db():
102102
except Exception as e:
103103
checks.append(_check("Providers", False, str(e)))
104104

105-
# 6. Stale page count
105+
# 6. Provider configuration?
106+
from repowise.cli.helpers import validate_provider_config
107+
108+
config_warnings = validate_provider_config()
109+
config_ok = len(config_warnings) == 0
110+
config_detail = "All required API keys configured" if config_ok else "; ".join(config_warnings)
111+
checks.append(_check("Provider config", config_ok, config_detail))
112+
113+
# 7. Stale page count
106114
stale_count = 0
107115
if db_ok and page_count > 0:
108116
try:
@@ -133,7 +141,7 @@ async def _check_stale():
133141
except Exception:
134142
checks.append(_check("Stale pages", True, "Could not check"))
135143

136-
# 7-8. Three-store consistency (SQL vs Vector Store vs FTS)
144+
# 8-9. Three-store consistency (SQL vs Vector Store vs FTS)
137145
missing_from_vector: set[str] = set()
138146
orphaned_vector: set[str] = set()
139147
missing_from_fts: set[str] = set()

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

Lines changed: 132 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -235,26 +235,149 @@ def resolve_provider(
235235
model = cfg["model"]
236236

237237
if provider_name is not None:
238+
# Validate configuration before attempting to create provider
239+
warnings = validate_provider_config(provider_name)
240+
if warnings:
241+
for warning in warnings:
242+
err_console.print(f"[yellow]Warning:[/yellow] {warning}")
243+
# For explicit provider requests, we still try to create it
244+
# The provider constructor will fail if the API key is actually required
245+
238246
kwargs: dict[str, Any] = {}
239247
if model:
240248
kwargs["model"] = model
249+
250+
# Pass API key from environment if available
251+
if provider_name == "anthropic" and os.environ.get("ANTHROPIC_API_KEY"):
252+
kwargs["api_key"] = os.environ["ANTHROPIC_API_KEY"]
253+
elif provider_name == "openai" and os.environ.get("OPENAI_API_KEY"):
254+
kwargs["api_key"] = os.environ["OPENAI_API_KEY"]
255+
elif provider_name == "gemini" and (
256+
os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
257+
):
258+
kwargs["api_key"] = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
259+
elif provider_name == "ollama" and os.environ.get("OLLAMA_BASE_URL"):
260+
kwargs["base_url"] = os.environ["OLLAMA_BASE_URL"]
261+
241262
return get_provider(provider_name, **kwargs)
242263

243264
# Auto-detect from env vars
244-
if os.environ.get("ANTHROPIC_API_KEY"):
245-
kwargs = {"model": model} if model else {}
265+
if os.environ.get("ANTHROPIC_API_KEY") and os.environ["ANTHROPIC_API_KEY"].strip():
266+
kwargs = (
267+
{"model": model, "api_key": os.environ["ANTHROPIC_API_KEY"]}
268+
if model
269+
else {"api_key": os.environ["ANTHROPIC_API_KEY"]}
270+
)
246271
return get_provider("anthropic", **kwargs)
247-
if os.environ.get("OPENAI_API_KEY"):
248-
kwargs = {"model": model} if model else {}
272+
if os.environ.get("OPENAI_API_KEY") and os.environ["OPENAI_API_KEY"].strip():
273+
kwargs = (
274+
{"model": model, "api_key": os.environ["OPENAI_API_KEY"]}
275+
if model
276+
else {"api_key": os.environ["OPENAI_API_KEY"]}
277+
)
249278
return get_provider("openai", **kwargs)
250-
if os.environ.get("OLLAMA_BASE_URL"):
251-
kwargs = {"model": model} if model else {}
279+
if os.environ.get("OLLAMA_BASE_URL") and os.environ["OLLAMA_BASE_URL"].strip():
280+
kwargs = (
281+
{"model": model, "base_url": os.environ["OLLAMA_BASE_URL"]}
282+
if model
283+
else {"base_url": os.environ["OLLAMA_BASE_URL"]}
284+
)
252285
return get_provider("ollama", **kwargs)
253-
if os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY"):
254-
kwargs = {"model": model} if model else {}
286+
if (os.environ.get("GEMINI_API_KEY") and os.environ["GEMINI_API_KEY"].strip()) or (
287+
os.environ.get("GOOGLE_API_KEY") and os.environ["GOOGLE_API_KEY"].strip()
288+
):
289+
api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
290+
kwargs = {"model": model, "api_key": api_key} if model else {"api_key": api_key}
255291
return get_provider("gemini", **kwargs)
256292

257293
raise click.ClickException(
258294
"No provider configured. Use --provider, set REPOWISE_PROVIDER, "
259-
"or set ANTHROPIC_API_KEY / OPENAI_API_KEY / OLLAMA_BASE_URL / GEMINI_API_KEY."
295+
"or set ANTHROPIC_API_KEY / OPENAI_API_KEY / OLLAMA_BASE_URL / GEMINI_API_KEY / GOOGLE_API_KEY."
260296
)
297+
298+
299+
# ---------------------------------------------------------------------------
300+
# Provider validation
301+
# ---------------------------------------------------------------------------
302+
303+
304+
def validate_provider_config(provider_name: str | None = None) -> list[str]:
305+
"""Validate that required API keys/environment variables are set for the provider.
306+
307+
Args:
308+
provider_name: The provider name to validate. If None, checks all possible providers.
309+
310+
Returns:
311+
List of warning messages for missing or invalid configuration.
312+
Empty list means all required config is present.
313+
"""
314+
import os
315+
316+
warnings = []
317+
318+
def _is_env_var_set(var_name: str) -> bool:
319+
"""Check if environment variable is set and non-empty."""
320+
value = os.environ.get(var_name)
321+
return value is not None and value.strip() != ""
322+
323+
def _is_env_var_exists(var_name: str) -> bool:
324+
"""Check if environment variable exists (even if empty)."""
325+
return var_name in os.environ
326+
327+
# Define required environment variables for each provider
328+
provider_env_vars = {
329+
"anthropic": ["ANTHROPIC_API_KEY"],
330+
"openai": ["OPENAI_API_KEY"],
331+
"gemini": ["GEMINI_API_KEY", "GOOGLE_API_KEY"], # Either one
332+
"ollama": ["OLLAMA_BASE_URL"],
333+
"litellm": ["LITELLM_API_KEY"], # May need others depending on backend
334+
}
335+
336+
if provider_name:
337+
# Validate specific provider
338+
if provider_name not in provider_env_vars:
339+
warnings.append(f"Unknown provider '{provider_name}' - cannot validate configuration")
340+
return warnings
341+
342+
env_vars = provider_env_vars[provider_name]
343+
missing_vars = []
344+
345+
if provider_name == "gemini":
346+
# Special case: either GEMINI_API_KEY or GOOGLE_API_KEY
347+
if not (_is_env_var_set("GEMINI_API_KEY") or _is_env_var_set("GOOGLE_API_KEY")):
348+
missing_vars = env_vars
349+
else:
350+
for var in env_vars:
351+
if not _is_env_var_set(var):
352+
missing_vars.append(var)
353+
354+
if missing_vars:
355+
warnings.append(
356+
f"Provider '{provider_name}' requires environment variables: {', '.join(missing_vars)}"
357+
)
358+
else:
359+
# Check all providers - warn about any that could be configured but are missing keys
360+
for name, env_vars in provider_env_vars.items():
361+
if name == "gemini":
362+
if os.environ.get("REPOWISE_PROVIDER") == "gemini" and not (
363+
_is_env_var_set("GEMINI_API_KEY") or _is_env_var_set("GOOGLE_API_KEY")
364+
):
365+
# Only warn if it looks like they might be trying to use gemini
366+
warnings.append(
367+
"Provider 'gemini' requires GEMINI_API_KEY or GOOGLE_API_KEY environment variable"
368+
)
369+
continue
370+
371+
missing = [var for var in env_vars if not _is_env_var_set(var)]
372+
if missing:
373+
# Only warn if this provider is explicitly requested OR
374+
# if the env var exists but is invalid (empty)
375+
env_var_exists = any(_is_env_var_exists(var) for var in env_vars)
376+
explicitly_requested = os.environ.get("REPOWISE_PROVIDER") == name
377+
378+
if explicitly_requested or env_var_exists:
379+
warnings.append(
380+
f"Provider '{name}' requires environment variables: {', '.join(missing)}"
381+
)
382+
383+
return warnings

tests/unit/cli/test_helpers.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
resolve_repo_path,
1616
run_async,
1717
save_state,
18+
validate_provider_config,
1819
)
1920

2021
# ---------------------------------------------------------------------------
@@ -125,3 +126,107 @@ def test_save_creates_repowise_dir(self, tmp_path):
125126
class TestGetHeadCommit:
126127
def test_non_git_returns_none(self, tmp_path):
127128
assert get_head_commit(tmp_path) is None
129+
130+
131+
# ---------------------------------------------------------------------------
132+
# Provider validation
133+
# ---------------------------------------------------------------------------
134+
135+
136+
class TestValidateProviderConfig:
137+
def test_no_provider_returns_empty_warnings(self, monkeypatch):
138+
# Clear all provider env vars
139+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
140+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
141+
monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
142+
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
143+
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
144+
monkeypatch.delenv("REPOWISE_PROVIDER", raising=False)
145+
146+
assert validate_provider_config() == []
147+
148+
def test_anthropic_missing_key(self, monkeypatch):
149+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
150+
monkeypatch.setenv("REPOWISE_PROVIDER", "anthropic")
151+
152+
warnings = validate_provider_config()
153+
assert len(warnings) == 1
154+
assert "anthropic" in warnings[0]
155+
assert "ANTHROPIC_API_KEY" in warnings[0]
156+
157+
def test_anthropic_valid_key(self, monkeypatch):
158+
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
159+
monkeypatch.setenv("REPOWISE_PROVIDER", "anthropic")
160+
161+
assert validate_provider_config() == []
162+
163+
def test_anthropic_empty_key(self, monkeypatch):
164+
monkeypatch.setenv("ANTHROPIC_API_KEY", "")
165+
monkeypatch.setenv("REPOWISE_PROVIDER", "anthropic")
166+
167+
warnings = validate_provider_config()
168+
assert len(warnings) == 1
169+
assert "ANTHROPIC_API_KEY" in warnings[0]
170+
171+
def test_openai_missing_key(self, monkeypatch):
172+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
173+
monkeypatch.setenv("REPOWISE_PROVIDER", "openai")
174+
175+
warnings = validate_provider_config()
176+
assert len(warnings) == 1
177+
assert "openai" in warnings[0]
178+
assert "OPENAI_API_KEY" in warnings[0]
179+
180+
def test_gemini_with_gemini_key(self, monkeypatch):
181+
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
182+
monkeypatch.setenv("REPOWISE_PROVIDER", "gemini")
183+
184+
assert validate_provider_config() == []
185+
186+
def test_gemini_with_google_key(self, monkeypatch):
187+
monkeypatch.setenv("GOOGLE_API_KEY", "test-key")
188+
monkeypatch.setenv("REPOWISE_PROVIDER", "gemini")
189+
190+
assert validate_provider_config() == []
191+
192+
def test_gemini_missing_keys(self, monkeypatch):
193+
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
194+
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
195+
monkeypatch.setenv("REPOWISE_PROVIDER", "gemini")
196+
197+
warnings = validate_provider_config()
198+
assert len(warnings) == 1
199+
assert "gemini" in warnings[0]
200+
201+
def test_ollama_missing_url(self, monkeypatch):
202+
monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
203+
monkeypatch.setenv("REPOWISE_PROVIDER", "ollama")
204+
205+
warnings = validate_provider_config()
206+
assert len(warnings) == 1
207+
assert "ollama" in warnings[0]
208+
assert "OLLAMA_BASE_URL" in warnings[0]
209+
210+
def test_unknown_provider(self, monkeypatch):
211+
warnings = validate_provider_config("unknown")
212+
assert len(warnings) == 1
213+
assert "unknown provider" in warnings[0].lower()
214+
215+
def test_auto_detect_anthropic(self, monkeypatch):
216+
monkeypatch.setenv("ANTHROPIC_API_KEY", "test")
217+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
218+
monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
219+
220+
# Should not warn when env var is properly set
221+
assert validate_provider_config() == []
222+
223+
def test_anthropic_empty_key_auto_detect(self, monkeypatch):
224+
monkeypatch.setenv("ANTHROPIC_API_KEY", "")
225+
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
226+
monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
227+
228+
# Should warn when env var exists but is empty
229+
warnings = validate_provider_config()
230+
assert len(warnings) == 1
231+
assert "anthropic" in warnings[0]
232+
assert "ANTHROPIC_API_KEY" in warnings[0]

0 commit comments

Comments
 (0)