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,
|
get_keywords_for_category,
|
||||||
)
|
)
|
||||||
from app.services.recipe.recipe_engine import RecipeEngine
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -68,7 +69,25 @@ async def suggest_recipes(
|
||||||
)
|
)
|
||||||
if req.style_id and not can_use("style_picker", req.tier):
|
if req.style_id and not can_use("style_picker", req.tier):
|
||||||
raise HTTPException(status_code=403, detail="Style picker requires Paid 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")
|
@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