feat(kiwi): gate L3/L4 recipes behind orch budget check; fallback to L2 on exhaustion
This commit is contained in:
parent
2071540a56
commit
01216b82c3
2 changed files with 167 additions and 2 deletions
|
|
@ -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")
|
||||
|
|
|
|||
146
tests/api/test_orch_budget_in_recipes.py
Normal file
146
tests/api/test_orch_budget_in_recipes.py
Normal file
|
|
@ -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()
|
||||
Loading…
Reference in a new issue