fix(budget): serialize model_max_budget before the /budget/update write

/budget/update handed prisma the raw update dict, so a model_max_budget
payload reached the Json? column as a nested python dict. prisma-client-py
renders that into the GraphQL mutation as bare object keys rather than a
JSON string, and the query engine rejects it, so every per-model budget
update returned a 500 and the cap was never stored. Model ids carrying
punctuation (glm-5.2) also produced an invalid GraphQL name.

/budget/new already ran its payload through jsonify_object for exactly this
reason. Do the same on the update path. Team member and organization member
budget updates route through this handler too, so they were failing the same
way.

The existing unit tests mocked the prisma table with an AsyncMock that
accepts any dict, which is why this never showed up outside a live proxy.
The new test asserts on what the endpoint hands prisma.
This commit is contained in:
Yuneng Jiang 2026-08-26 17:46:17 -07:00
parent f677292901
commit 4d6786d420
No known key found for this signature in database
2 changed files with 41 additions and 4 deletions

View file

@ -13,6 +13,7 @@ All /budget management endpoints
#### BUDGET TABLE MANAGEMENT ####
import math
from collections.abc import Mapping
from typing import Final
from fastapi import APIRouter, Depends, HTTPException
@ -178,13 +179,17 @@ async def update_budget(
else {}
)
response: Final = await BudgetRepository(prisma_client).table.update(
where={"budget_id": budget_obj.budget_id},
data={
budget_obj_jsonified: Final[Mapping[str, object]] = jsonify_object(
{
**budget_obj.model_dump(exclude_unset=True),
**recomputed_reset_at,
"updated_by": user_api_key_dict.user_id or litellm_proxy_admin_name,
},
}
)
response: Final = await BudgetRepository(prisma_client).table.update(
where={"budget_id": budget_obj.budget_id},
data=budget_obj_jsonified,
)
return response

View file

@ -1,5 +1,6 @@
# tests/test_budget_endpoints.py
import json
import types
from datetime import datetime, timedelta, timezone
import pytest
@ -388,3 +389,34 @@ async def test_update_budget_duration_none_does_not_recompute(client_and_mocks):
assert "budget_duration" in captured and captured["budget_duration"] is None
assert "budget_reset_at" not in captured
@pytest.mark.asyncio
async def test_update_budget_serializes_model_max_budget_for_prisma(
client_and_mocks, monkeypatch
):
monkeypatch.setattr(ps, "premium_user", True)
client, _, mock_table = client_and_mocks
captured = _capture_update_data(mock_table)
resp = client.post(
"/budget/update",
json={
"budget_id": "budget_per_model",
"model_max_budget": {
"gpt4o": {"budget_limit": 5.0, "time_period": "1d"},
"glm-5.2": {"budget_limit": 7.5, "time_period": "30d"},
},
},
)
assert resp.status_code == 200, resp.text
stored = captured["model_max_budget"]
assert isinstance(stored, str), (
f"model_max_budget must reach prisma as a JSON string, got {type(stored).__name__}"
)
assert json.loads(stored) == {
"gpt4o": {"max_budget": 5.0, "budget_duration": "1d"},
"glm-5.2": {"max_budget": 7.5, "budget_duration": "30d"},
}