feat(ai): provider router (ollama|gemini|groq) for heavy CV calls
The structured /cv/* calls funnel through a provider router so production can offload a weak local GPU (GTX 1060) to a cloud provider without any .NET change. Default stays "ollama" (keyless/local) and /summarize remains local distilbart. - AI_PROVIDER=ollama|gemini|groq dispatch inside _ollama_generate_json/_text (entry-point names kept, so no call sites change; Ollama path is byte-identical). - Gemini (x-goog-api-key header, not URL query) and Groq (OpenAI-compatible chat/completions) added via stdlib urllib — zero new dependencies. - /health reports ai_provider + ai_provider_configured. - Keys read from env only; never logged/committed. - Compose + .env.example pass AI_PROVIDER/GEMINI_*/GROQ_* through. Tests: 11 passed (default Ollama unchanged, Gemini/Groq dispatch, missing-key 503, health reports provider). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -141,3 +142,106 @@ def test_classify_block_defaults_missing_section_to_other(monkeypatch):
|
||||
assert payload["bullets"] == []
|
||||
assert payload["summary"] == []
|
||||
assert payload["skills"] == []
|
||||
|
||||
|
||||
# --- AI provider router -------------------------------------------------------
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload):
|
||||
self._data = json.dumps(payload).encode("utf-8")
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
def _install_fake_urlopen(monkeypatch, module, response_payload, captured):
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["url"] = req.full_url
|
||||
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
|
||||
captured["body"] = json.loads(req.data.decode("utf-8"))
|
||||
return _FakeResponse(response_payload)
|
||||
|
||||
monkeypatch.setattr(module.urllib_request, "urlopen", fake_urlopen)
|
||||
|
||||
|
||||
def test_provider_defaults_to_ollama_and_is_unchanged(monkeypatch):
|
||||
monkeypatch.delenv("AI_PROVIDER", raising=False)
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "http://ollama-host:11434")
|
||||
module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b")
|
||||
assert module.AI_PROVIDER == "ollama"
|
||||
|
||||
captured = {}
|
||||
_install_fake_urlopen(monkeypatch, module, {"response": '{"score": 7}'}, captured)
|
||||
|
||||
assert module._ollama_generate_json("hi") == {"score": 7}
|
||||
assert captured["url"] == "http://ollama-host:11434/api/generate"
|
||||
assert captured["body"]["model"] == "qwen2.5:7b"
|
||||
assert captured["body"]["format"] == "json"
|
||||
assert captured["body"]["options"]["temperature"] == 0.1
|
||||
|
||||
|
||||
def test_provider_gemini_dispatch(monkeypatch):
|
||||
monkeypatch.setenv("AI_PROVIDER", "gemini")
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
||||
monkeypatch.setenv("GEMINI_MODEL", "gemini-2.0-flash")
|
||||
module = load_app_module(monkeypatch)
|
||||
|
||||
captured = {}
|
||||
payload = {"candidates": [{"content": {"parts": [{"text": '{"score": 9}'}]}}]}
|
||||
_install_fake_urlopen(monkeypatch, module, payload, captured)
|
||||
|
||||
assert module._ollama_generate_json("hi") == {"score": 9}
|
||||
assert "generativelanguage" in captured["url"]
|
||||
assert "gemini-2.0-flash:generateContent" in captured["url"]
|
||||
assert "key=" not in captured["url"] # key must not be in the URL
|
||||
assert captured["headers"].get("x-goog-api-key") == "test-key"
|
||||
assert captured["body"]["generationConfig"]["responseMimeType"] == "application/json"
|
||||
|
||||
|
||||
def test_provider_groq_dispatch(monkeypatch):
|
||||
monkeypatch.setenv("AI_PROVIDER", "groq")
|
||||
monkeypatch.setenv("GROQ_API_KEY", "test-key")
|
||||
module = load_app_module(monkeypatch)
|
||||
|
||||
captured = {}
|
||||
payload = {"choices": [{"message": {"content": "rewritten CV text"}}]}
|
||||
_install_fake_urlopen(monkeypatch, module, payload, captured)
|
||||
|
||||
assert module._ollama_generate_text("rewrite this") == "rewritten CV text"
|
||||
assert captured["url"].endswith("/chat/completions")
|
||||
assert captured["headers"].get("authorization") == "Bearer test-key"
|
||||
assert captured["body"]["messages"][0]["content"] == "rewrite this"
|
||||
|
||||
|
||||
def test_provider_missing_cloud_key_raises_503(monkeypatch):
|
||||
monkeypatch.setenv("AI_PROVIDER", "gemini")
|
||||
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||
module = load_app_module(monkeypatch)
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
try:
|
||||
module._ollama_generate_json("hi")
|
||||
except HTTPException as ex:
|
||||
assert ex.status_code == 503
|
||||
assert "GEMINI_API_KEY" in ex.detail
|
||||
else:
|
||||
raise AssertionError("expected HTTPException for missing GEMINI_API_KEY")
|
||||
|
||||
|
||||
def test_health_reports_active_provider(monkeypatch):
|
||||
monkeypatch.setenv("AI_PROVIDER", "gemini")
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
||||
module = load_app_module(monkeypatch)
|
||||
client = TestClient(module.app)
|
||||
|
||||
payload = client.get("/health").json()
|
||||
|
||||
assert payload["ai_provider"] == "gemini"
|
||||
assert payload["ai_provider_configured"] is True
|
||||
|
||||
Reference in New Issue
Block a user