diff --git a/app/api/endpoints/recipes.py b/app/api/endpoints/recipes.py index 557500c..f0ba2a5 100644 --- a/app/api/endpoints/recipes.py +++ b/app/api/endpoints/recipes.py @@ -29,7 +29,8 @@ from app.services.recipe.browser_domains import ( get_keywords_for_category, ) from app.services.recipe.recipe_engine import RecipeEngine -from app.tiers import can_use +from app.services.heimdall_orch import check_orch_budget +from app.tiers import LIFETIME_SOURCES, can_use router = APIRouter() @@ -68,7 +69,25 @@ async def suggest_recipes( ) if req.style_id and not can_use("style_picker", req.tier): raise HTTPException(status_code=403, detail="Style picker requires Paid tier.") - return await asyncio.to_thread(_suggest_in_thread, session.db, req) + + # Orch budget check for lifetime/founders keys — downgrade to L2 (local) if exhausted. + # Subscription and local/BYOK users skip this check entirely. + orch_fallback = False + if ( + req.level in (3, 4) + and session.license_key is not None + and not session.has_byok + and session.tier != "local" + ): + budget = check_orch_budget(session.license_key, "kiwi") + if not budget.get("allowed", True): + req = req.model_copy(update={"level": 2}) + orch_fallback = True + + result = await asyncio.to_thread(_suggest_in_thread, session.db, req) + if orch_fallback: + result = result.model_copy(update={"orch_fallback": True}) + return result @router.get("/browse/domains") diff --git a/tests/api/test_orch_budget_in_recipes.py b/tests/api/test_orch_budget_in_recipes.py new file mode 100644 index 0000000..426c325 --- /dev/null +++ b/tests/api/test_orch_budget_in_recipes.py @@ -0,0 +1,146 @@ +"""Tests that orch budget gating is wired into the suggest endpoint.""" +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.cloud_session import CloudUser, get_session +from app.main import app + + +def _make_session( + tier: str = "paid", + has_byok: bool = False, + license_key: str | None = "CFG-KIWI-TEST-TEST-TEST", +) -> CloudUser: + return CloudUser( + user_id="test-user", + tier=tier, + db=Path("/tmp/kiwi_test.db"), + has_byok=has_byok, + license_key=license_key, + ) + + +@patch("app.api.endpoints.recipes._suggest_in_thread") +@patch("app.services.heimdall_orch.requests.post") +def test_orch_budget_exhausted_downgrades_to_l2(mock_post, mock_suggest): + """When orch budget is denied, L3 request is downgraded to L2 and orch_fallback=True.""" + deny_resp = MagicMock() + deny_resp.ok = True + deny_resp.json.return_value = { + "allowed": False, "calls_used": 60, "calls_total": 60, + "topup_calls": 0, "period_start": "2026-04-14", "resets_on": "2026-05-14", + } + mock_post.return_value = deny_resp + + fallback_result = MagicMock() + fallback_result.suggestions = [] + fallback_result.element_gaps = [] + fallback_result.grocery_list = [] + fallback_result.grocery_links = [] + fallback_result.rate_limited = False + fallback_result.rate_limit_count = 0 + fallback_result.orch_fallback = False + fallback_result.model_copy.return_value = fallback_result + mock_suggest.return_value = fallback_result + + app.dependency_overrides[get_session] = lambda: _make_session(tier="paid") + client = TestClient(app) + resp = client.post("/api/v1/recipes/suggest", json={ + "pantry_items": ["chicken", "rice"], + "level": 3, + "tier": "paid", + }) + app.dependency_overrides.clear() + + assert resp.status_code == 200 + # The engine should have been called with level=2 (downgraded from 3) + called_req = mock_suggest.call_args[0][1] + assert called_req.level == 2 + + +@patch("app.api.endpoints.recipes._suggest_in_thread") +@patch("app.services.heimdall_orch.requests.post") +def test_orch_budget_allowed_passes_l3_through(mock_post, mock_suggest): + allow_resp = MagicMock() + allow_resp.ok = True + allow_resp.json.return_value = { + "allowed": True, "calls_used": 5, "calls_total": 60, + "topup_calls": 0, "period_start": "2026-04-14", "resets_on": "2026-05-14", + } + mock_post.return_value = allow_resp + + ok_result = MagicMock() + ok_result.suggestions = [] + ok_result.element_gaps = [] + ok_result.grocery_list = [] + ok_result.grocery_links = [] + ok_result.rate_limited = False + ok_result.rate_limit_count = 0 + ok_result.orch_fallback = False + mock_suggest.return_value = ok_result + + app.dependency_overrides[get_session] = lambda: _make_session(tier="paid") + client = TestClient(app) + resp = client.post("/api/v1/recipes/suggest", json={ + "pantry_items": ["chicken", "rice"], + "level": 3, + "tier": "paid", + }) + app.dependency_overrides.clear() + + assert resp.status_code == 200 + called_req = mock_suggest.call_args[0][1] + assert called_req.level == 3 + + +@patch("app.api.endpoints.recipes._suggest_in_thread") +def test_no_orch_check_for_local_tier(mock_suggest): + """Local sessions never hit the orch check.""" + ok_result = MagicMock() + ok_result.suggestions = [] + ok_result.element_gaps = [] + ok_result.grocery_list = [] + ok_result.grocery_links = [] + ok_result.rate_limited = False + ok_result.rate_limit_count = 0 + ok_result.orch_fallback = False + mock_suggest.return_value = ok_result + + app.dependency_overrides[get_session] = lambda: _make_session(tier="local", license_key=None) + client = TestClient(app) + with patch("app.services.heimdall_orch.requests.post") as mock_check: + client.post("/api/v1/recipes/suggest", json={ + "pantry_items": ["chicken"], + "level": 3, + "tier": "local", + }) + mock_check.assert_not_called() + app.dependency_overrides.clear() + + +@patch("app.api.endpoints.recipes._suggest_in_thread") +def test_no_orch_check_when_license_key_is_none(mock_suggest): + """Subscription users (no license_key) skip the orch check.""" + ok_result = MagicMock() + ok_result.suggestions = [] + ok_result.element_gaps = [] + ok_result.grocery_list = [] + ok_result.grocery_links = [] + ok_result.rate_limited = False + ok_result.rate_limit_count = 0 + ok_result.orch_fallback = False + mock_suggest.return_value = ok_result + + app.dependency_overrides[get_session] = lambda: _make_session(tier="paid", license_key=None) + client = TestClient(app) + with patch("app.services.heimdall_orch.requests.post") as mock_check: + client.post("/api/v1/recipes/suggest", json={ + "pantry_items": ["chicken"], + "level": 3, + "tier": "paid", + }) + mock_check.assert_not_called() + app.dependency_overrides.clear()