feat(kiwi): gate L3/L4 recipes behind orch budget check; fallback to L2 on exhaustion

This commit is contained in:
pyr0ball 2026-04-14 15:24:57 -07:00
parent 2071540a56
commit 01216b82c3
2 changed files with 167 additions and 2 deletions

View file

@ -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")

View 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()