feat(ai): enforce local-first routing
Keep external providers behind server consent, task, and prompt-cost gates while persisting actual provider provenance.
This commit is contained in:
@@ -11,7 +11,17 @@ if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
def load_app_module(monkeypatch, *, skip_model_load=True, ollama_model=None, service_token=None, external_ai_enabled=False):
|
||||
def load_app_module(
|
||||
monkeypatch,
|
||||
*,
|
||||
skip_model_load=True,
|
||||
ollama_model=None,
|
||||
service_token=None,
|
||||
external_ai_enabled=False,
|
||||
routing_mode="local_first",
|
||||
circuit_threshold=3,
|
||||
external_prompt_limit=24000,
|
||||
):
|
||||
if skip_model_load:
|
||||
monkeypatch.setenv("AI_SERVICE_SKIP_MODEL_LOAD", "1")
|
||||
else:
|
||||
@@ -30,6 +40,10 @@ def load_app_module(monkeypatch, *, skip_model_load=True, ollama_model=None, ser
|
||||
monkeypatch.setenv("EXTERNAL_AI_ENABLED", "true")
|
||||
else:
|
||||
monkeypatch.delenv("EXTERNAL_AI_ENABLED", raising=False)
|
||||
monkeypatch.setenv("AI_ROUTING_MODE", routing_mode)
|
||||
monkeypatch.setenv("LOCAL_AI_CIRCUIT_FAILURE_THRESHOLD", str(circuit_threshold))
|
||||
monkeypatch.setenv("EXTERNAL_AI_MAX_PROMPT_CHARS", str(external_prompt_limit))
|
||||
monkeypatch.delenv("EXTERNAL_AI_ALLOWED_TASKS", raising=False)
|
||||
if "app" in sys.modules:
|
||||
del sys.modules["app"]
|
||||
module = importlib.import_module("app")
|
||||
@@ -227,56 +241,62 @@ def test_provider_defaults_to_ollama_and_is_unchanged(monkeypatch):
|
||||
assert captured["body"]["options"]["temperature"] == 0.1
|
||||
|
||||
|
||||
def test_provider_gemini_dispatch(monkeypatch):
|
||||
def test_local_success_wins_even_when_external_fallback_is_permitted(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, external_ai_enabled=True)
|
||||
module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b", external_ai_enabled=True)
|
||||
module._external_ai_allowed.set(True)
|
||||
module._ai_task_type.set("cv-normalize")
|
||||
|
||||
captured = {}
|
||||
payload = {"candidates": [{"content": {"parts": [{"text": '{"score": 9}'}]}}]}
|
||||
_install_fake_urlopen(monkeypatch, module, payload, captured)
|
||||
calls = []
|
||||
monkeypatch.setattr(module, "_ollama_generate", lambda *args, **kwargs: calls.append("ollama") or '{"score": 7}')
|
||||
monkeypatch.setattr(module, "_gemini_generate", lambda *args, **kwargs: calls.append("gemini") or '{"score": 9}')
|
||||
|
||||
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"
|
||||
assert module._ollama_generate_json("hi") == {"score": 7}
|
||||
assert calls == ["ollama"]
|
||||
|
||||
|
||||
def test_provider_groq_dispatch(monkeypatch):
|
||||
def test_local_failure_uses_permitted_groq_fallback_sequentially(monkeypatch):
|
||||
monkeypatch.setenv("AI_PROVIDER", "groq")
|
||||
monkeypatch.setenv("GROQ_API_KEY", "test-key")
|
||||
module = load_app_module(monkeypatch, external_ai_enabled=True)
|
||||
module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b", external_ai_enabled=True)
|
||||
module._external_ai_allowed.set(True)
|
||||
module._ai_task_type.set("cv-rewrite")
|
||||
|
||||
captured = {}
|
||||
payload = {"choices": [{"message": {"content": "rewritten CV text"}}]}
|
||||
_install_fake_urlopen(monkeypatch, module, payload, captured)
|
||||
calls = []
|
||||
|
||||
def local_failure(*args, **kwargs):
|
||||
calls.append("ollama")
|
||||
raise module.URLError("synthetic local outage")
|
||||
|
||||
monkeypatch.setattr(module, "_ollama_generate", local_failure)
|
||||
monkeypatch.setattr(module, "_groq_generate", lambda *args, **kwargs: calls.append("groq") or "rewritten CV text")
|
||||
|
||||
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"
|
||||
assert calls == ["ollama", "groq"]
|
||||
assert module._route_state.get()["provider"] == "groq"
|
||||
assert module._route_state.get()["fallback_reason"] == "local_provider_unavailable"
|
||||
|
||||
|
||||
def test_provider_missing_cloud_key_raises_503(monkeypatch):
|
||||
def test_missing_external_key_never_bypasses_local_failure(monkeypatch):
|
||||
monkeypatch.setenv("AI_PROVIDER", "gemini")
|
||||
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||
module = load_app_module(monkeypatch, external_ai_enabled=True)
|
||||
module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b", external_ai_enabled=True)
|
||||
module._external_ai_allowed.set(True)
|
||||
module._ai_task_type.set("cv-normalize")
|
||||
monkeypatch.setattr(module, "_ollama_generate", lambda *args, **kwargs: (_ for _ in ()).throw(module.URLError("synthetic outage")))
|
||||
monkeypatch.setattr(module, "_gemini_generate", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("must not call Gemini")))
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
try:
|
||||
module._ollama_generate_json("hi")
|
||||
except HTTPException as ex:
|
||||
except module.HTTPException as ex:
|
||||
assert ex.status_code == 503
|
||||
assert "GEMINI_API_KEY" in ex.detail
|
||||
assert "Ollama" in ex.detail
|
||||
else:
|
||||
raise AssertionError("expected HTTPException for missing GEMINI_API_KEY")
|
||||
raise AssertionError("expected the local failure")
|
||||
|
||||
|
||||
def test_health_reports_active_provider(monkeypatch):
|
||||
@@ -289,31 +309,161 @@ def test_health_reports_active_provider(monkeypatch):
|
||||
|
||||
assert payload["ai_provider"] == "gemini"
|
||||
assert payload["ai_provider_configured"] is True
|
||||
assert payload["ai_routing_mode"] == "local_first"
|
||||
assert payload["local_circuit_open"] is False
|
||||
|
||||
|
||||
def test_external_provider_requires_admin_gate_and_backend_consent_header(monkeypatch):
|
||||
def test_external_fallback_requires_admin_gate_and_backend_consent_header(monkeypatch):
|
||||
monkeypatch.setenv("AI_PROVIDER", "gemini")
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
||||
module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b", external_ai_enabled=True)
|
||||
captured = {}
|
||||
_install_fake_urlopen(monkeypatch, module, {"response": "rewritten locally"}, captured)
|
||||
calls = []
|
||||
|
||||
def fake_generate(provider, *args, **kwargs):
|
||||
calls.append(provider)
|
||||
if provider == "ollama":
|
||||
raise module._ProviderFailure("ollama", "provider_unavailable", 503)
|
||||
return "rewritten externally"
|
||||
|
||||
monkeypatch.setattr(module, "_generate_from_provider", fake_generate)
|
||||
client = TestClient(module.app)
|
||||
|
||||
local_response = client.post("/cv/rewrite", json={"instruction": "Rewrite", "text": "Synthetic CV"})
|
||||
assert local_response.status_code == 200
|
||||
assert captured["url"].startswith("http://127.0.0.1:11434/")
|
||||
assert local_response.status_code == 503
|
||||
assert calls == ["ollama"]
|
||||
assert local_response.headers["X-Ai-Provider"] == "ollama"
|
||||
|
||||
external_payload = {"candidates": [{"content": {"parts": [{"text": "rewritten externally"}]}}]}
|
||||
_install_fake_urlopen(monkeypatch, module, external_payload, captured)
|
||||
calls.clear()
|
||||
external_response = client.post(
|
||||
"/cv/rewrite",
|
||||
json={"instruction": "Rewrite", "text": "Synthetic CV"},
|
||||
headers={"X-Ai-External-Allowed": "true"},
|
||||
)
|
||||
assert external_response.status_code == 200
|
||||
assert "generativelanguage" in captured["url"]
|
||||
assert calls == ["ollama", "gemini"]
|
||||
assert external_response.headers["X-Ai-Provider"] == "gemini"
|
||||
assert external_response.headers["X-Ai-Model"] == "gemini-2.0-flash"
|
||||
assert external_response.headers["X-Ai-Fallback-Reason"] == "local_provider_unavailable"
|
||||
assert external_response.headers["X-Ai-Route-Reason"] == "external_fallback"
|
||||
|
||||
|
||||
def test_invalid_local_json_can_fallback_but_prompt_cost_cap_cannot(monkeypatch):
|
||||
monkeypatch.setenv("AI_PROVIDER", "gemini")
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
||||
module = load_app_module(
|
||||
monkeypatch,
|
||||
ollama_model="qwen2.5:7b",
|
||||
external_ai_enabled=True,
|
||||
external_prompt_limit=1000,
|
||||
)
|
||||
module._external_ai_allowed.set(True)
|
||||
module._ai_task_type.set("cv-normalize")
|
||||
calls = []
|
||||
monkeypatch.setattr(module, "_ollama_generate", lambda *args, **kwargs: calls.append("ollama") or "not json")
|
||||
monkeypatch.setattr(module, "_gemini_generate", lambda *args, **kwargs: calls.append("gemini") or '{"score": 9}')
|
||||
|
||||
assert module._ollama_generate_json("short") == {"score": 9}
|
||||
assert calls == ["ollama", "gemini"]
|
||||
assert module._route_state.get()["fallback_reason"] == "local_schema_invalid"
|
||||
|
||||
calls.clear()
|
||||
try:
|
||||
module._ollama_generate_json("x" * 1001)
|
||||
except module.HTTPException as ex:
|
||||
assert ex.status_code == 502
|
||||
else:
|
||||
raise AssertionError("expected local schema failure above the external prompt cap")
|
||||
assert calls == ["ollama"]
|
||||
assert "external_prompt_limit" in module._route_state.get()["route_reason"]
|
||||
|
||||
|
||||
def test_open_local_circuit_skips_local_only_when_fallback_is_permitted(monkeypatch):
|
||||
monkeypatch.setenv("AI_PROVIDER", "groq")
|
||||
monkeypatch.setenv("GROQ_API_KEY", "test-key")
|
||||
module = load_app_module(
|
||||
monkeypatch,
|
||||
ollama_model="qwen2.5:7b",
|
||||
external_ai_enabled=True,
|
||||
circuit_threshold=1,
|
||||
)
|
||||
module._external_ai_allowed.set(True)
|
||||
module._ai_task_type.set("cv-rewrite")
|
||||
calls = []
|
||||
|
||||
def local_failure(*args, **kwargs):
|
||||
calls.append("ollama")
|
||||
raise module.URLError("synthetic local outage")
|
||||
|
||||
monkeypatch.setattr(module, "_ollama_generate", local_failure)
|
||||
monkeypatch.setattr(module, "_groq_generate", lambda *args, **kwargs: calls.append("groq") or "external")
|
||||
|
||||
assert module._ollama_generate_text("first") == "external"
|
||||
assert module._ollama_generate_text("second") == "external"
|
||||
assert calls == ["ollama", "groq", "groq"]
|
||||
assert module._route_state.get()["fallback_reason"] == "local_circuit_open"
|
||||
|
||||
|
||||
def test_external_failure_is_clear_and_unapproved_background_task_stays_local(monkeypatch):
|
||||
monkeypatch.setenv("AI_PROVIDER", "gemini")
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
|
||||
module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b", external_ai_enabled=True)
|
||||
module._external_ai_allowed.set(True)
|
||||
calls = []
|
||||
|
||||
def provider_failure(provider, *args, **kwargs):
|
||||
calls.append(provider)
|
||||
raise module._ProviderFailure(provider, "provider_unavailable", 503)
|
||||
|
||||
monkeypatch.setattr(module, "_generate_from_provider", provider_failure)
|
||||
module._ai_task_type.set("cv-rewrite")
|
||||
try:
|
||||
module._ollama_generate_text("allowed")
|
||||
except module.HTTPException as ex:
|
||||
assert ex.status_code == 503
|
||||
assert "Gemini" in ex.detail
|
||||
else:
|
||||
raise AssertionError("expected external provider failure")
|
||||
assert calls == ["ollama", "gemini"]
|
||||
assert module._route_state.get()["fallback_reason"] == "local_provider_unavailable"
|
||||
|
||||
calls.clear()
|
||||
module._ai_task_type.set("strategy.snapshot")
|
||||
try:
|
||||
module._ollama_generate_text("not task-approved")
|
||||
except module.HTTPException as ex:
|
||||
assert ex.status_code == 503
|
||||
assert "Ollama" in ex.detail
|
||||
else:
|
||||
raise AssertionError("expected local provider failure")
|
||||
assert calls == ["ollama"]
|
||||
assert "task_not_allowed_external" in module._route_state.get()["route_reason"]
|
||||
|
||||
|
||||
def test_external_only_mode_still_requires_explicit_permission_and_task_allowlist(monkeypatch):
|
||||
monkeypatch.setenv("AI_PROVIDER", "groq")
|
||||
monkeypatch.setenv("GROQ_API_KEY", "test-key")
|
||||
module = load_app_module(
|
||||
monkeypatch,
|
||||
ollama_model="qwen2.5:7b",
|
||||
external_ai_enabled=True,
|
||||
routing_mode="external_only",
|
||||
)
|
||||
module._ai_task_type.set("cv-rewrite")
|
||||
calls = []
|
||||
monkeypatch.setattr(module, "_ollama_generate", lambda *args, **kwargs: calls.append("ollama") or "local")
|
||||
monkeypatch.setattr(module, "_groq_generate", lambda *args, **kwargs: calls.append("groq") or "external")
|
||||
|
||||
try:
|
||||
module._ollama_generate_text("without consent")
|
||||
except module.HTTPException as ex:
|
||||
assert ex.status_code == 403
|
||||
else:
|
||||
raise AssertionError("expected external permission denial")
|
||||
assert calls == []
|
||||
|
||||
module._external_ai_allowed.set(True)
|
||||
assert module._ollama_generate_text("with consent") == "external"
|
||||
assert calls == ["groq"]
|
||||
|
||||
|
||||
def test_service_token_rejects_calls_without_the_header(monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user