Skip to content
This repository was archived by the owner on Oct 24, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion app/routers/billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,12 @@ async def create_agent_wallet(
agent_id=request.agent_id,
budget_credits=Decimal(str(request.budget_credits)),
daily_limit=(
Decimal(str(request.daily_limit)) if request.daily_limit else None
# `is not None`: the schema declares ge=0, so 0 is a valid cap
# meaning "spend nothing", and a truthiness test turned that
# into None -- which the engine reads as "no cap at all".
Decimal(str(request.daily_limit))
if request.daily_limit is not None
else None
),
auto_refill=request.auto_refill,
auto_refill_threshold=Decimal(str(request.auto_refill_threshold)),
Expand Down
21 changes: 19 additions & 2 deletions app/services/billing_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,14 @@ async def reverse_velocity_record() -> None:
message="Child wallet lifetime spend cap exceeded.",
)

if wallet.daily_limit and wallet.daily_spent > wallet.daily_limit:
# `is not None`, not truthiness: daily_limit is Optional where None
# means "no cap", so Decimal("0") is a real limit -- the one an
# operator sets to stop a runaway agent dead. Reading it as falsy
# turned the strictest possible cap into no cap at all, and this is
# the only hard daily-spend enforcement in the system (the velocity
# monitor only raises alerts). app/db/converters.py already draws the
# same distinction the same way.
if wallet.daily_limit is not None and wallet.daily_spent > wallet.daily_limit:
prior_daily_spent = wallet.daily_spent - charge_amount
remaining = wallet.daily_limit - prior_daily_spent
shortfall = charge_amount - remaining
Expand Down Expand Up @@ -537,7 +544,17 @@ async def reverse_velocity_record() -> None:
)
session.add(entry)

if wallet.balance < (wallet.auto_refill_threshold or Decimal("100")):
# Same falsy-zero trap as the daily cap. auto_refill_threshold defaults
# to 100 and is non-Optional, so `or` could only ever fire on exactly
# Decimal("0") -- the value a wallet sets to mean "never alert, never
# refill" -- and silently restored the 100 it opted out of, alerting on
# every charge below that.
low_balance_threshold = (
wallet.auto_refill_threshold
if wallet.auto_refill_threshold is not None
else Decimal("100")
)
if wallet.balance < low_balance_threshold:
session.add(
BillingAlertModel(
alert_id=str(uuid.uuid4())[:12],
Expand Down
13 changes: 11 additions & 2 deletions app/services/service_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,17 @@ def register_local(
input_schema = pydantic_to_mcp_schema(input_model)
output_schema = pydantic_to_mcp_schema(output_model)

if input_schema is None and output_schema is None:
input_schema, output_schema = extract_schema_from_callable(func)
# Each side falls back independently. Requiring *both* to be absent
# meant that supplying only an output_model -- a handler with plain
# typed arguments returning a model -- left the input schema empty, so
# the tool advertised "takes no arguments" and every obedient client
# call raised TypeError on the missing parameters.
if input_schema is None or output_schema is None:
derived_input, derived_output = extract_schema_from_callable(func)
if input_schema is None:
input_schema = derived_input
if output_schema is None:
output_schema = derived_output

service_record = {
"service_id": service_id,
Expand Down
94 changes: 94 additions & 0 deletions tests/test_billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -2212,3 +2212,97 @@ async def commit_before_the_write_back():
second = await ledger.commit_session(session_id, get_agent_money())
assert second.wallet_id == "", second
assert second.committed_charges == 0, second


# --- Zero is a value, not an absence ---


@pytest.mark.anyio
async def test_zero_daily_limit_blocks_spending_rather_than_unlocking_it(
client, api_headers
):
"""``daily_limit=0`` is the strictest cap there is, not the absence of one.

``daily_limit`` is Optional: ``None`` means "no cap". A truthiness test
conflates that with ``Decimal("0")`` -- the value an operator sets to halt
a runaway agent -- and hands it unlimited daily spend instead. The HTTP
schema advertises ``ge=0``, so zero is a documented input, and this line is
the only hard daily-spend enforcement in the system.
"""

sponsor_resp = await client.post(
"/v1/billing/wallets/sponsor",
json={
"sponsor_name": "Zero Cap Co",
"email": "ops@zerocap.example",
"initial_credits": 10000,
},
headers=api_headers,
)
sponsor_id = sponsor_resp.json()["wallet_id"]

agent_resp = await client.post(
"/v1/billing/wallets/agent",
json={
"sponsor_wallet_id": sponsor_id,
"agent_id": "halted-bot",
"budget_credits": 5000,
"daily_limit": 0,
},
headers=api_headers,
)
assert agent_resp.status_code == 201
agent_wallet_id = agent_resp.json()["wallet_id"]

charge_resp = await client.post(
f"/v1/billing/charge?wallet_id={agent_wallet_id}"
"&service=agent_comms&units=1",
headers=api_headers,
)
assert charge_resp.status_code == 402, (
"a zero daily limit was read as 'no limit' and the charge went through"
)

# The wallet keeps its funds: a refused charge must not debit.
wallet_resp = await client.get(
f"/v1/billing/wallets/{agent_wallet_id}", headers=api_headers
)
assert Decimal(wallet_resp.json()["balance_exact"]) == Decimal("5000")


def test_register_local_derives_an_input_schema_alongside_an_output_model() -> None:
"""Supplying only an output model must not erase the input contract.

The callable fallback used to run only when *both* schemas were absent, so
a handler with plain typed arguments plus an output model advertised
``inputSchema: {}``. A client that obeyed that contract called the tool
with no arguments and got a TypeError.
"""

from pydantic import BaseModel

from app.schemas.billing import ServiceCategory
from app.services.service_registry import get_service_registry

class Out(BaseModel):
echoed: str

registry = get_service_registry()

def handler(value: str) -> Out:
return Out(echoed=value)

record = registry.register_local(
service_id="schema-fallback-probe",
name="Schema fallback probe",
description="Input schema must still be derived from the callable",
category=ServiceCategory.AGENT_COMMS,
func=handler,
output_model=Out,
)

input_schema = record["input_schema"]
assert input_schema, "input schema was dropped because an output model was given"
assert "value" in (input_schema.get("properties") or {}), (
f"the handler's own parameter is missing from {input_schema!r}"
)