Skip to content

Commit 8f28c38

Browse files
derek-palmerclaude
andauthored
feat(generate): fall back to Ollama automatically when no API key is configured (#17)
When no provider is explicitly set (via --provider or config) and no API key is found, probe Ollama at localhost:11434. If reachable, switch to local mode and print an info line to stderr. If not reachable, include an Ollama hint in the error message. forerunner doctor surfaces the effective mode: "local mode" when Ollama is the active or auto-detected provider; hint about Ollama when no config and no API key are present. Tests: +13 (187 total, 1 skipped). Covers is_available(), CLI fallback paths, explicit-provider/config-provider non-fallback, model preservation, and all three doctor local-mode scenarios. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0e51dd5 commit 8f28c38

7 files changed

Lines changed: 257 additions & 6 deletions

File tree

‎src/codeforerunner/cli.py‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ def cmd_generate(args: argparse.Namespace) -> int:
111111
repo_root = Path(args.repo).resolve() if args.repo else Path.cwd()
112112
cfg = load_from_repo(repo_root)
113113

114-
provider_name = args.provider or (cfg.provider if cfg else "anthropic")
114+
explicit_provider = args.provider or (cfg.provider if cfg else None)
115+
provider_name = explicit_provider or "anthropic"
115116
model = args.model or (cfg.model if cfg else None)
116117
provider_cls = _providers.get(provider_name)
117118
provider = provider_cls()
@@ -132,8 +133,19 @@ def cmd_generate(args: argparse.Namespace) -> int:
132133
env_var = (cfg.api_key_env.get(provider_name) if cfg else None) or provider.default_env_var
133134
api_key = os.environ.get(env_var)
134135
if api_key is None and provider_name != "ollama":
135-
print(f"error: missing API key; set ${env_var}", file=sys.stderr)
136-
return 3
136+
if explicit_provider is None and _providers.ollama_available():
137+
provider_name = "ollama"
138+
provider_cls = _providers.get("ollama")
139+
provider = provider_cls()
140+
if not args.model:
141+
model = provider.default_model
142+
print("info: no API key; falling back to Ollama (local mode)", file=sys.stderr)
143+
else:
144+
msg = f"error: missing API key; set ${env_var}"
145+
if explicit_provider is None:
146+
msg += "\nhint: start Ollama for keyless local generation (https://ollama.com)"
147+
print(msg, file=sys.stderr)
148+
return 3
137149

138150
if getattr(args, "stream", False):
139151
try:

‎src/codeforerunner/doctor.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,13 +225,23 @@ def _check_config_loadable(repo: Path) -> list[Finding]:
225225

226226

227227
def _check_provider_api_key(repo: Path) -> list[Finding]:
228+
from codeforerunner.providers.ollama import is_available as _ollama_available
229+
228230
cfg_path = repo / CONFIG_FILENAME
229231
if not cfg_path.is_file():
232+
if _ollama_available():
233+
return [
234+
Finding(
235+
"ok",
236+
"provider-api-key",
237+
"no config; Ollama running — generate will use local mode automatically",
238+
)
239+
]
230240
return [
231241
Finding(
232242
"ok",
233243
"provider-api-key",
234-
f"no {CONFIG_FILENAME}; provider key not checked",
244+
f"no {CONFIG_FILENAME}; set an API key in config or start Ollama for keyless local generation",
235245
)
236246
]
237247
try:
@@ -259,7 +269,7 @@ def _check_provider_api_key(repo: Path) -> list[Finding]:
259269
Finding(
260270
"ok",
261271
"provider-api-key",
262-
"ollama needs no API key (OLLAMA_HOST optional)",
272+
"running in local mode (Ollama; no API key needed)",
263273
)
264274
]
265275
env_var = cfg.api_key_env.get(provider) or _DEFAULT_PROVIDER_ENV.get(provider, "")

‎src/codeforerunner/providers/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from codeforerunner.providers.anthropic import AnthropicProvider
66
from codeforerunner.providers.base import CompletionResult, Provider, ProviderError
77
from codeforerunner.providers.google import GoogleProvider
8-
from codeforerunner.providers.ollama import OllamaProvider
8+
from codeforerunner.providers.ollama import OllamaProvider, is_available as ollama_available
99
from codeforerunner.providers.openai import OpenAIProvider
1010

1111
__all__ = [
@@ -18,6 +18,7 @@
1818
"ProviderError",
1919
"REGISTRY",
2020
"get",
21+
"ollama_available",
2122
]
2223

2324
REGISTRY: dict[str, type] = {

‎src/codeforerunner/providers/ollama.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@
1313
DEFAULT_HOST = "http://localhost:11434"
1414

1515

16+
def is_available(host: str | None = None) -> bool:
17+
"""Return True if an Ollama instance is reachable at the configured host."""
18+
base = (host or os.environ.get("OLLAMA_HOST") or DEFAULT_HOST).rstrip("/")
19+
try:
20+
urllib.request.urlopen(f"{base}/api/tags", timeout=2)
21+
return True
22+
except Exception:
23+
return False
24+
25+
1626
class OllamaProvider:
1727
name = "ollama"
1828
default_env_var = "OLLAMA_HOST"

‎tests/test_cli.py‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,3 +307,133 @@ def complete(self, *, prompt, model=None, api_key=None):
307307
assert rc == 0
308308
assert cap.out == "ok\n"
309309
assert calls == [{"model": "claude-opus-4-7", "api_key": "override-secret"}]
310+
311+
312+
# ── Ollama local-mode fallback ─────────────────────────────────────────────────
313+
314+
def test_generate_falls_back_to_ollama_when_no_key_and_ollama_running(
315+
tmp_path, capsys, monkeypatch
316+
):
317+
"""No explicit provider, no API key, Ollama reachable → auto-switch to Ollama."""
318+
_seed_repo_with_config(tmp_path)
319+
(tmp_path / "forerunner.config.yaml").unlink()
320+
calls: list[dict] = []
321+
322+
class FakeOllamaProvider:
323+
default_env_var = "OLLAMA_HOST"
324+
default_model = "llama3"
325+
326+
def complete(self, *, prompt, model=None, api_key=None):
327+
calls.append({"model": model, "api_key": api_key})
328+
return CompletionResult(text="ollama output", model=model or "llama3")
329+
330+
from codeforerunner import providers
331+
from unittest.mock import patch
332+
333+
monkeypatch.setitem(providers.REGISTRY, "ollama", FakeOllamaProvider)
334+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
335+
336+
with patch("codeforerunner.providers.ollama_available", return_value=True):
337+
rc = main(["--repo", str(tmp_path), "generate", "readme"])
338+
339+
cap = capsys.readouterr()
340+
assert rc == 0
341+
assert "local mode" in cap.err
342+
assert cap.out == "ollama output\n"
343+
assert calls[0]["model"] == "llama3"
344+
345+
346+
def test_generate_no_fallback_when_provider_explicit_and_key_missing(
347+
tmp_path, capsys, monkeypatch
348+
):
349+
"""Explicit --provider means no auto-fallback even if Ollama is running."""
350+
_seed_repo_with_config(tmp_path)
351+
(tmp_path / "forerunner.config.yaml").unlink()
352+
353+
class FakeProvider:
354+
default_env_var = "FAKE_API_KEY"
355+
default_model = "fake-default"
356+
357+
def complete(self, *, prompt, model=None, api_key=None): # pragma: no cover
358+
raise AssertionError("should not be called")
359+
360+
from codeforerunner import providers
361+
from unittest.mock import patch
362+
363+
monkeypatch.setitem(providers.REGISTRY, "fake", FakeProvider)
364+
monkeypatch.delenv("FAKE_API_KEY", raising=False)
365+
366+
with patch("codeforerunner.providers.ollama_available", return_value=True):
367+
rc = main(["--repo", str(tmp_path), "generate", "readme", "--provider", "fake"])
368+
369+
cap = capsys.readouterr()
370+
assert rc == 3
371+
assert "missing API key" in cap.err
372+
373+
374+
def test_generate_no_fallback_when_config_provider_set_and_key_missing(
375+
tmp_path, capsys, monkeypatch
376+
):
377+
"""Provider in config file counts as explicit — no auto-fallback."""
378+
_seed_repo_with_config(tmp_path)
379+
(tmp_path / "forerunner.config.yaml").write_text(
380+
"provider: anthropic\n", encoding="utf-8"
381+
)
382+
383+
from unittest.mock import patch
384+
385+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
386+
387+
with patch("codeforerunner.providers.ollama_available", return_value=True):
388+
rc = main(["--repo", str(tmp_path), "generate", "readme"])
389+
390+
cap = capsys.readouterr()
391+
assert rc == 3
392+
assert "missing API key" in cap.err
393+
394+
395+
def test_generate_missing_key_includes_ollama_hint_when_ollama_absent(
396+
tmp_path, capsys, monkeypatch
397+
):
398+
"""No explicit provider, no API key, Ollama not running → error + Ollama hint."""
399+
_seed_repo_with_config(tmp_path)
400+
(tmp_path / "forerunner.config.yaml").unlink()
401+
402+
from unittest.mock import patch
403+
404+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
405+
406+
with patch("codeforerunner.providers.ollama_available", return_value=False):
407+
rc = main(["--repo", str(tmp_path), "generate", "readme"])
408+
409+
cap = capsys.readouterr()
410+
assert rc == 3
411+
assert "missing API key" in cap.err
412+
assert "Ollama" in cap.err
413+
414+
415+
def test_generate_ollama_fallback_uses_explicit_model(tmp_path, capsys, monkeypatch):
416+
"""--model flag is preserved when falling back to Ollama."""
417+
_seed_repo_with_config(tmp_path)
418+
(tmp_path / "forerunner.config.yaml").unlink()
419+
calls: list[dict] = []
420+
421+
class FakeOllamaProvider:
422+
default_env_var = "OLLAMA_HOST"
423+
default_model = "llama3"
424+
425+
def complete(self, *, prompt, model=None, api_key=None):
426+
calls.append({"model": model})
427+
return CompletionResult(text="ok", model=model or "llama3")
428+
429+
from codeforerunner import providers
430+
from unittest.mock import patch
431+
432+
monkeypatch.setitem(providers.REGISTRY, "ollama", FakeOllamaProvider)
433+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
434+
435+
with patch("codeforerunner.providers.ollama_available", return_value=True):
436+
rc = main(["--repo", str(tmp_path), "generate", "readme", "--model", "llama3.2"])
437+
438+
assert rc == 0
439+
assert calls[0]["model"] == "llama3.2"

‎tests/test_doctor.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,42 @@ def test_doctor_fix_does_not_overwrite_existing_config(tmp_path: Path, capsys):
174174
capsys.readouterr()
175175

176176
assert cfg_path.read_text(encoding="utf-8") == "# my custom config\n"
177+
178+
179+
# ── local-mode surfacing ───────────────────────────────────────────────────────
180+
181+
def test_provider_api_key_local_mode_when_ollama_running_no_config(tmp_path: Path):
182+
from unittest.mock import patch
183+
repo = _copy_repo_layout(tmp_path)
184+
# no forerunner.config.yaml
185+
with patch("codeforerunner.providers.ollama.is_available", return_value=True):
186+
findings = run(repo)
187+
matches = [f for f in findings if f.check == "provider-api-key"]
188+
assert len(matches) == 1
189+
assert matches[0].severity == "ok"
190+
assert "local mode" in matches[0].message
191+
192+
193+
def test_provider_api_key_hint_when_ollama_absent_no_config(tmp_path: Path):
194+
from unittest.mock import patch
195+
repo = _copy_repo_layout(tmp_path)
196+
# no forerunner.config.yaml
197+
with patch("codeforerunner.providers.ollama.is_available", return_value=False):
198+
findings = run(repo)
199+
matches = [f for f in findings if f.check == "provider-api-key"]
200+
assert len(matches) == 1
201+
assert matches[0].severity == "ok"
202+
assert "Ollama" in matches[0].message
203+
204+
205+
def test_provider_api_key_ollama_config_shows_local_mode(tmp_path: Path, monkeypatch):
206+
repo = _copy_repo_layout(tmp_path)
207+
(repo / "forerunner.config.yaml").write_text(
208+
"provider: ollama\nmodel: llama3\n", encoding="utf-8"
209+
)
210+
monkeypatch.delenv("OLLAMA_HOST", raising=False)
211+
findings = run(repo)
212+
matches = [f for f in findings if f.check == "provider-api-key"]
213+
assert len(matches) == 1
214+
assert matches[0].severity == "ok"
215+
assert "local mode" in matches[0].message

‎tests/test_providers.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,3 +479,52 @@ def test_ollama_stream_http_error_raises():
479479
with patch("urllib.request.urlopen", side_effect=_stream_http_error(500)):
480480
with pytest.raises(ProviderError, match="HTTP 500"):
481481
list(OllamaProvider().stream(prompt="hi"))
482+
483+
484+
# ── OllamaProvider.is_available ───────────────────────────────────────────────
485+
486+
def test_ollama_is_available_returns_true_when_reachable(monkeypatch):
487+
monkeypatch.delenv("OLLAMA_HOST", raising=False)
488+
with patch("urllib.request.urlopen", return_value=MagicMock()):
489+
from codeforerunner.providers.ollama import is_available
490+
assert is_available() is True
491+
492+
493+
def test_ollama_is_available_returns_false_on_connection_error(monkeypatch):
494+
import urllib.error
495+
monkeypatch.delenv("OLLAMA_HOST", raising=False)
496+
with patch("urllib.request.urlopen", side_effect=OSError("connection refused")):
497+
from codeforerunner.providers.ollama import is_available
498+
assert is_available() is False
499+
500+
501+
def test_ollama_is_available_uses_env_host(monkeypatch):
502+
monkeypatch.setenv("OLLAMA_HOST", "http://myhost:11434")
503+
captured_url: list[str] = []
504+
505+
def fake_open(url, timeout=None):
506+
captured_url.append(url)
507+
return MagicMock()
508+
509+
with patch("urllib.request.urlopen", side_effect=fake_open):
510+
from codeforerunner.providers.ollama import is_available
511+
assert is_available() is True
512+
assert captured_url[0].startswith("http://myhost:11434")
513+
514+
515+
def test_ollama_is_available_uses_explicit_host():
516+
captured_url: list[str] = []
517+
518+
def fake_open(url, timeout=None):
519+
captured_url.append(url)
520+
return MagicMock()
521+
522+
with patch("urllib.request.urlopen", side_effect=fake_open):
523+
from codeforerunner.providers.ollama import is_available
524+
assert is_available(host="http://custom:9999") is True
525+
assert captured_url[0].startswith("http://custom:9999")
526+
527+
528+
def test_ollama_available_exported_from_providers_package():
529+
from codeforerunner.providers import ollama_available
530+
assert callable(ollama_available)

0 commit comments

Comments
 (0)