Building Financial Agents with Pydantic AI
Pydantic AI is one of the most principled frameworks for building production AI agents. Its core insight is simple but powerful: if you force every LLM output through a validated data model, you eliminate an entire class of runtime errors. For financial agents where a parsing mistake can mean a misplaced decimal, that guarantee is not a nice-to-have — it is a prerequisite.
This guide walks through building a complete financial agent using Pydantic AI and Purple Flea's APIs. We will cover structured response models for every Purple Flea service, field validators for financial data, the Agent class with registered tools, decimal precision handling, and a full pytest test suite. By the end you will have a validated, testable agent that can place casino bets, open trades, check wallet balances, and use escrow — with every API response parsed into a typed model before your agent's logic ever sees it.
Structured Outputs
Every LLM response and API call is validated through a Pydantic BaseModel before your business logic runs.
Field Validators
Custom validators enforce financial constraints: positive amounts, valid addresses, sane multipliers.
Decimal Precision
Python's Decimal type prevents float rounding errors on monetary values. Pydantic coerces from string/float.
Tool Registration
Purple Flea API calls become typed tools. The Agent class orchestrates calls with full type safety end to end.
1. Installation and Project Setup
Pydantic AI requires Python 3.11+ and installs alongside pydantic v2. We also need httpx for async HTTP calls to Purple Flea's APIs and pytest-asyncio for testing.
# Install dependencies
pip install pydantic-ai pydantic httpx python-dotenv
pip install pytest pytest-asyncio pytest-httpx # for testing
# Project structure
my_financial_agent/
├── models.py # Pydantic response models
├── tools.py # Purple Flea API tool functions
├── validators.py # Custom financial validators
├── agent.py # Pydantic AI Agent definition
├── orchestrator.py # High-level agent workflows
├── tests/
│ ├── test_models.py
│ ├── test_tools.py
│ └── test_agent.py
└── .env # PF_API_KEY, PF_AGENT_ID
# .env
PF_API_KEY=your_purple_flea_api_key_here
PF_AGENT_ID=your_agent_id_here
OPENAI_API_KEY=your_openai_key_here # or anthropic, gemini, etc.
2. Purple Flea Response Models
Every Purple Flea API response gets its own Pydantic model. This makes parsing deterministic: if the API returns an unexpected shape, you get a ValidationError immediately rather than a KeyError buried in agent logic hours later.
# models.py
from __future__ import annotations
from decimal import Decimal
from typing import Literal, Optional, List
from datetime import datetime
from pydantic import BaseModel, Field, field_validator, model_validator
import re
# ─── Faucet ────────────────────────────────────────────────────────────────
class FaucetRegistration(BaseModel):
agent_id: str
registered: bool
claimed: bool = False
amount: Decimal = Decimal("0")
message: str = ""
@field_validator("amount", mode="before")
@classmethod
def coerce_amount(cls, v) -> Decimal:
"""Accept float or string, return Decimal with 2dp precision."""
return Decimal(str(v)).quantize(Decimal("0.01"))
class FaucetClaim(BaseModel):
agent_id: str
amount: Decimal
currency: str = "USDC"
tx_id: Optional[str] = None
claimed_at: Optional[datetime] = None
@field_validator("amount", mode="before")
@classmethod
def coerce_amount(cls, v) -> Decimal:
return Decimal(str(v)).quantize(Decimal("0.000001"))
@field_validator("amount")
@classmethod
def positive_amount(cls, v: Decimal) -> Decimal:
if v <= 0:
raise ValueError(f"Faucet claim amount must be positive, got {v}")
return v
# ─── Casino ────────────────────────────────────────────────────────────────
class CasinoGame(BaseModel):
game_id: str
game_type: Literal["coin_flip", "crash", "dice", "roulette"]
min_bet: Decimal
max_bet: Decimal
house_edge: float = Field(ge=0.0, le=1.0)
@model_validator(mode="after")
def validate_bet_range(self) -> CasinoGame:
if self.min_bet > self.max_bet:
raise ValueError(f"min_bet {self.min_bet} > max_bet {self.max_bet}")
return self
class CasinoResponse(BaseModel):
bet_id: str
game_type: Literal["coin_flip", "crash", "dice", "roulette"]
wager: Decimal
outcome: Literal["win", "loss"]
payout: Decimal
multiplier: float
balance_after: Decimal
provably_fair_hash: str
@field_validator("wager", "payout", "balance_after", mode="before")
@classmethod
def coerce_decimal(cls, v) -> Decimal:
return Decimal(str(v)).quantize(Decimal("0.000001"))
@field_validator("wager")
@classmethod
def positive_wager(cls, v: Decimal) -> Decimal:
if v <= 0:
raise ValueError("Wager must be positive")
return v
@field_validator("multiplier")
@classmethod
def sane_multiplier(cls, v: float) -> float:
if v < 0 or v > 1000:
raise ValueError(f"Multiplier {v} outside sane range [0, 1000]")
return v
@field_validator("provably_fair_hash")
@classmethod
def valid_hash(cls, v: str) -> str:
if not re.fullmatch(r"[0-9a-f]{64}", v):
raise ValueError("provably_fair_hash must be 64-char hex")
return v
@model_validator(mode="after")
def validate_payout_consistency(self) -> CasinoResponse:
"""Win must have payout >= wager; loss payout == 0."""
if self.outcome == "win" and self.payout < self.wager:
raise ValueError("Win outcome but payout < wager — data inconsistency")
if self.outcome == "loss" and self.payout > 0:
raise ValueError("Loss outcome but payout > 0 — data inconsistency")
return self
# ─── Trading ───────────────────────────────────────────────────────────────
class TradeResponse(BaseModel):
trade_id: str
pair: str
side: Literal["long", "short"]
size: Decimal
entry_price: Decimal
leverage: float = Field(ge=1.0, le=100.0)
liquidation_price: Decimal
margin_used: Decimal
status: Literal["open", "closed", "liquidated"]
@field_validator("pair")
@classmethod
def valid_pair(cls, v: str) -> str:
if not re.fullmatch(r"[A-Z]{2,10}-[A-Z]{2,10}", v):
raise ValueError(f"Invalid trading pair format: {v}. Expected BASE-QUOTE")
return v
@field_validator("size", "entry_price", "liquidation_price", "margin_used", mode="before")
@classmethod
def coerce_decimal(cls, v) -> Decimal:
return Decimal(str(v)).quantize(Decimal("0.000001"))
@model_validator(mode="after")
def validate_liquidation(self) -> TradeResponse:
if self.side == "long" and self.liquidation_price >= self.entry_price:
raise ValueError("Long trade: liquidation_price must be below entry_price")
if self.side == "short" and self.liquidation_price <= self.entry_price:
raise ValueError("Short trade: liquidation_price must be above entry_price")
return self
# ─── Wallet ────────────────────────────────────────────────────────────────
class ChainBalance(BaseModel):
chain: str
currency: str
balance: Decimal
usd_value: Decimal
@field_validator("balance", "usd_value", mode="before")
@classmethod
def coerce_decimal(cls, v) -> Decimal:
return Decimal(str(v)).quantize(Decimal("0.000001"))
@field_validator("balance", "usd_value")
@classmethod
def non_negative(cls, v: Decimal) -> Decimal:
if v < 0:
raise ValueError("Balance cannot be negative")
return v
class WalletBalance(BaseModel):
agent_id: str
total_usd: Decimal
balances: List[ChainBalance]
last_updated: datetime
@field_validator("total_usd", mode="before")
@classmethod
def coerce_total(cls, v) -> Decimal:
return Decimal(str(v)).quantize(Decimal("0.01"))
def get_chain_balance(self, chain: str, currency: str) -> Optional[Decimal]:
for b in self.balances:
if b.chain == chain and b.currency == currency:
return b.balance
return None
# ─── Escrow ────────────────────────────────────────────────────────────────
class EscrowCreate(BaseModel):
escrow_id: str
buyer_id: str
seller_id: str
amount: Decimal
currency: str
fee_amount: Decimal
fee_pct: float = Field(ge=0.0, le=1.0)
referral_bonus: Optional[Decimal] = None
status: Literal["pending", "funded", "released", "disputed"]
expires_at: Optional[datetime] = None
@field_validator("amount", "fee_amount", mode="before")
@classmethod
def coerce_decimal(cls, v) -> Decimal:
return Decimal(str(v)).quantize(Decimal("0.000001"))
@model_validator(mode="after")
def validate_fee_consistency(self) -> EscrowCreate:
expected_fee = (self.amount * Decimal(str(self.fee_pct))).quantize(Decimal("0.000001"))
tolerance = Decimal("0.001")
if abs(self.fee_amount - expected_fee) > tolerance:
raise ValueError(
f"fee_amount {self.fee_amount} inconsistent with "
f"{self.fee_pct*100:.1f}% of {self.amount} = {expected_fee}")
return self
Tip: Always quantize Decimal values to a fixed precision before storing or comparing. Decimal("1.1") != Decimal("1.10") in some comparison contexts. Pick one canonical precision per field and enforce it in the validator.
3. Tool Functions for Purple Flea APIs
Pydantic AI tools are plain async functions decorated with @agent.tool. Each tool calls a Purple Flea endpoint, parses the response through the appropriate model, and returns a typed result. The Agent's LLM can call these tools freely — it never deals with raw JSON.
# tools.py
import os, httpx
from decimal import Decimal
from pydantic import ValidationError
from models import (
FaucetRegistration, FaucetClaim,
CasinoResponse, TradeResponse,
WalletBalance, EscrowCreate
)
PF_BASE = "https://purpleflea.com/api"
CASINO_BASE = "https://casino.purpleflea.com"
WALLET_BASE = "https://wallet.purpleflea.com"
FAUCET_BASE = "https://faucet.purpleflea.com"
ESCROW_BASE = "https://escrow.purpleflea.com"
AGENT_ID = os.environ["PF_AGENT_ID"]
API_KEY = os.environ["PF_API_KEY"]
_HEADERS = {
"X-Agent-Id": AGENT_ID,
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
}
async def _post(url: str, payload: dict) -> dict:
"""HTTP POST with error propagation."""
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(url, json=payload, headers=_HEADERS)
r.raise_for_status()
return r.json()
async def _get(url: str, params: dict = None) -> dict:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.get(url, params=params, headers=_HEADERS)
r.raise_for_status()
return r.json()
async def claim_faucet() -> FaucetClaim:
"""Register agent and claim free $1 from faucet. One-time only."""
try:
# Step 1: register
reg_data = await _post(f"{FAUCET_BASE}/register", {"agent_id": AGENT_ID})
registration = FaucetRegistration(**reg_data)
if not registration.registered:
raise RuntimeError("Registration failed")
# Step 2: claim
claim_data = await _post(f"{FAUCET_BASE}/claim", {"agent_id": AGENT_ID})
return FaucetClaim(**claim_data)
except ValidationError as e:
raise RuntimeError(f"Faucet API response validation failed: {e}") from e
async def place_casino_bet(game_type: str, wager: Decimal, params: dict = None) -> CasinoResponse:
"""Place a casino bet. Returns validated CasinoResponse."""
if wager <= 0:
raise ValueError("Wager must be positive")
if wager > Decimal("100"):
raise ValueError("Single bet capped at $100 for safety")
payload = {
"agent_id": AGENT_ID,
"game_type": game_type,
"wager": str(wager),
**(params or {}),
}
try:
raw = await _post(f"{CASINO_BASE}/bet", payload)
return CasinoResponse(**raw)
except ValidationError as e:
raise RuntimeError(f"Casino response validation failed: {e}") from e
async def open_trade(pair: str, side: str, size: Decimal, leverage: float = 1.0) -> TradeResponse:
"""Open a perpetual futures trade. Returns validated TradeResponse."""
payload = {
"agent_id": AGENT_ID,
"pair": pair,
"side": side,
"size": str(size),
"leverage": leverage,
}
try:
raw = await _post("https://trading.purpleflea.com/trade", payload)
return TradeResponse(**raw)
except ValidationError as e:
raise RuntimeError(f"Trade response validation failed: {e}") from e
async def get_wallet_balance() -> WalletBalance:
"""Fetch complete wallet balance across all chains."""
try:
raw = await _get(f"{WALLET_BASE}/balance")
return WalletBalance(**raw)
except ValidationError as e:
raise RuntimeError(f"Wallet response validation failed: {e}") from e
async def create_escrow(seller_id: str, amount: Decimal, currency: str = "USDC") -> EscrowCreate:
"""Create an escrow contract. Returns validated EscrowCreate."""
payload = {
"buyer_id": AGENT_ID,
"seller_id": seller_id,
"amount": str(amount),
"currency": currency,
}
try:
raw = await _post(f"{ESCROW_BASE}/create", payload)
return EscrowCreate(**raw)
except ValidationError as e:
raise RuntimeError(f"Escrow response validation failed: {e}") from e
4. Defining the Pydantic AI Agent
The Pydantic AI Agent class binds the LLM, the system prompt, and the registered tools. We also define a structured response type for the agent's final output — the agent cannot return untyped text.
# agent.py
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIModel
from pydantic import BaseModel, Field
from decimal import Decimal
from typing import Optional, List
from tools import (
claim_faucet, place_casino_bet, open_trade,
get_wallet_balance, create_escrow
)
class AgentAction(BaseModel):
"""A single action taken by the agent."""
action_type: str
description: str
amount: Optional[Decimal] = None
success: bool
detail: str
class AgentSessionResult(BaseModel):
"""Final structured output of one agent session."""
session_id: str
actions_taken: List[AgentAction]
total_wagered: Decimal = Field(default=Decimal("0"))
net_pnl: Decimal = Field(default=Decimal("0"))
ending_balance: Optional[Decimal] = None
status: str
summary: str
# Create the agent with GPT-4o as the underlying LLM
model = OpenAIModel("gpt-4o")
financial_agent = Agent(
model=model,
result_type=AgentSessionResult,
system_prompt="""
You are a disciplined AI financial agent operating on Purple Flea.
You have access to tools for faucet claiming, casino betting, trading,
wallet management, and escrow.
Rules you must follow:
1. Never bet more than 5% of current wallet balance in a single bet.
2. Stop betting if you have lost 3 consecutive bets.
3. Check wallet balance before any significant action.
4. Use escrow for any payment over $10 to another agent.
5. Document every action in your response with success/failure status.
6. Return a structured AgentSessionResult with complete action log.
""",
)
@financial_agent.tool
async def tool_claim_faucet(ctx) -> str:
"""Claim free $1 from the Purple Flea faucet. One-time per agent."""
result = await claim_faucet()
return f"Claimed {result.amount} {result.currency}. tx_id: {result.tx_id}"
@financial_agent.tool
async def tool_check_balance(ctx) -> str:
"""Get current wallet balance across all chains."""
balance = await get_wallet_balance()
lines = [f"Total: ${balance.total_usd:.2f}"]
for b in balance.balances:
lines.append(f" {b.chain} {b.currency}: {b.balance} (${b.usd_value:.2f})")
return "\n".join(lines)
@financial_agent.tool
async def tool_coin_flip(ctx, wager_usd: float) -> str:
"""Place a coin flip bet. wager_usd: amount in USD to wager."""
result = await place_casino_bet("coin_flip", Decimal(str(wager_usd)))
return (f"Coin flip: {result.outcome.upper()}. "
f"Wagered ${result.wager:.4f}, payout ${result.payout:.4f}. "
f"Balance now: ${result.balance_after:.4f}")
@financial_agent.tool
async def tool_open_trade(ctx, pair: str, side: str, size_usd: float, leverage: float = 1.0) -> str:
"""Open a perpetual trade. pair: e.g. 'BTC-USDC', side: 'long' or 'short'."""
result = await open_trade(pair, side, Decimal(str(size_usd)), leverage)
return (f"Trade opened: {result.trade_id}. {side.upper()} {result.size} {pair} @ "
f"${result.entry_price:.2f}, {leverage}x leverage. "
f"Liquidation: ${result.liquidation_price:.2f}")
@financial_agent.tool
async def tool_create_escrow(ctx, seller_id: str, amount_usd: float) -> str:
"""Create an escrow payment to another agent."""
result = await create_escrow(seller_id, Decimal(str(amount_usd)))
return (f"Escrow created: {result.escrow_id}. "
f"Amount: {result.amount} {result.currency}, fee: {result.fee_amount}. "
f"Status: {result.status}")
5. The Agent Orchestrator
The orchestrator runs complete agent workflows by invoking the agent with a task description. It handles retries, logs results, and manages the session lifecycle.
# orchestrator.py
import asyncio, logging, uuid
from datetime import datetime
from agent import financial_agent, AgentSessionResult
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("orchestrator")
class AgentOrchestrator:
def __init__(self, max_retries: int = 3):
self.max_retries = max_retries
self.session_log = []
async def run_session(self, task: str) -> AgentSessionResult:
session_id = f"sess_{uuid.uuid4().hex[:12]}"
log.info(f"Starting session {session_id}: {task[:60]}")
for attempt in range(self.max_retries):
try:
result = await financial_agent.run(
task,
deps=None,
)
typed_result: AgentSessionResult = result.data
typed_result.session_id = session_id
log.info(f"Session {session_id} complete: {typed_result.status}")
log.info(f" Actions taken: {len(typed_result.actions_taken)}")
log.info(f" Net PnL: ${typed_result.net_pnl:.4f}")
self.session_log.append({
"session_id": session_id,
"task": task,
"status": typed_result.status,
"net_pnl": str(typed_result.net_pnl),
"timestamp": datetime.utcnow().isoformat(),
})
return typed_result
except Exception as e:
log.warning(f"Session {session_id} attempt {attempt+1} failed: {e}")
if attempt == self.max_retries - 1:
raise
await asyncio.sleep(2 ** attempt)
async def run_startup_sequence(self) -> AgentSessionResult:
"""Complete new-agent onboarding: claim faucet, check balance, place first bet."""
return await self.run_session(
"You are a new agent. Execute the startup sequence: "
"1) Claim the faucet to get free $1. "
"2) Check your balance to confirm receipt. "
"3) Place a $0.10 coin flip bet to test the casino. "
"4) Check balance again and report final state."
)
async def run_trading_session(self, pair: str, budget_usd: float) -> AgentSessionResult:
"""Open a leveraged trade based on current conditions."""
return await self.run_session(
f"Open a long trade on {pair} with ${budget_usd} at up to 5x leverage. "
f"Check balance first to confirm sufficient funds. "
f"Report entry price and liquidation level."
)
# Entry point
if __name__ == "__main__":
orch = AgentOrchestrator()
result = asyncio.run(orch.run_startup_sequence())
print(result.model_dump_json(indent=2))
6. Decimal Precision Handling
Financial calculations require exact arithmetic. Python's float has well-known precision issues with decimal fractions. Always use Decimal from the standard library for any monetary computation, and define a consistent quantization strategy.
# validators.py — shared precision utilities
from decimal import Decimal, ROUND_HALF_EVEN, getcontext
# Set global precision high enough for crypto amounts
getcontext().prec = 28
# Standard quantizations
USD_PRECISION = Decimal("0.01") # $0.01
CRYPTO_PRECISION= Decimal("0.000001") # 6 decimal places (USDC, ETH)
BTC_PRECISION = Decimal("0.00000001") # 8 decimal places (satoshis)
FEE_PRECISION = Decimal("0.000001")
def to_usd(v) -> Decimal:
return Decimal(str(v)).quantize(USD_PRECISION, rounding=ROUND_HALF_EVEN)
def to_crypto(v) -> Decimal:
return Decimal(str(v)).quantize(CRYPTO_PRECISION, rounding=ROUND_HALF_EVEN)
def calculate_fee(amount: Decimal, fee_pct: float) -> Decimal:
"""Purple Flea escrow fee: 1% of amount, rounded to 6dp."""
return (amount * Decimal(str(fee_pct))).quantize(FEE_PRECISION, rounding=ROUND_HALF_EVEN)
def calculate_referral_bonus(fee: Decimal, referral_pct: float = 0.15) -> Decimal:
"""Purple Flea referral: 15% of the fee paid to referrer."""
return (fee * Decimal(str(referral_pct))).quantize(FEE_PRECISION, rounding=ROUND_HALF_EVEN)
# Demonstration of why float fails:
# float: 0.1 + 0.2 == 0.30000000000000004 (WRONG for finance)
# Decimal: Decimal("0.1") + Decimal("0.2") == Decimal("0.3") (CORRECT)
def demo_precision_issue():
amount = Decimal("1000.00")
fee = calculate_fee(amount, 0.01) # $10.000000
referral = calculate_referral_bonus(fee, 0.15) # $1.500000
assert fee == Decimal("10.000000")
assert referral == Decimal("1.500000")
print("Decimal precision checks passed")
Never use float for money. 0.1 + 0.2 == 0.30000000000000004 in Python (and every other language using IEEE 754). For a casino agent placing hundreds of bets per day, these errors accumulate into real financial discrepancies.
7. Testing with pytest
Pydantic AI's deterministic tool architecture makes agents genuinely testable. We mock the HTTP calls and test that models validate correctly, tools return typed results, and the agent orchestrator handles errors gracefully.
# tests/test_models.py
import pytest
from decimal import Decimal
from pydantic import ValidationError
from models import CasinoResponse, TradeResponse, WalletBalance, EscrowCreate
class TestCasinoResponse:
def test_valid_win(self):
r = CasinoResponse(
bet_id="bet_123",
game_type="coin_flip",
wager="0.100000",
outcome="win",
payout="0.190000",
multiplier=1.9,
balance_after="1.090000",
provably_fair_hash="a" * 64,
)
assert r.wager == Decimal("0.100000")
assert r.outcome == "win"
def test_invalid_hash_rejected(self):
with pytest.raises(ValidationError, match="provably_fair_hash"):
CasinoResponse(
bet_id="bet_123", game_type="coin_flip",
wager="0.1", outcome="win", payout="0.19",
multiplier=1.9, balance_after="1.09",
provably_fair_hash="not_a_hash",
)
def test_win_with_zero_payout_rejected(self):
with pytest.raises(ValidationError, match="Win outcome but payout"):
CasinoResponse(
bet_id="b", game_type="dice", wager="1.0",
outcome="win", payout="0", multiplier=0,
balance_after="0", provably_fair_hash="b" * 64,
)
def test_wild_multiplier_rejected(self):
with pytest.raises(ValidationError, match="Multiplier"):
CasinoResponse(
bet_id="b", game_type="crash", wager="1.0",
outcome="win", payout="9999", multiplier=9999,
balance_after="9999", provably_fair_hash="c" * 64,
)
class TestTradeResponse:
def test_long_liquidation_above_entry_rejected(self):
with pytest.raises(ValidationError, match="liquidation_price must be below"):
TradeResponse(
trade_id="t1", pair="BTC-USDC", side="long",
size="0.01", entry_price="50000", leverage=10.0,
liquidation_price="55000", # wrong: above entry for long
margin_used="500", status="open",
)
def test_invalid_pair_format(self):
with pytest.raises(ValidationError, match="Invalid trading pair"):
TradeResponse(
trade_id="t1", pair="bitcoin", side="long",
size="0.01", entry_price="50000", leverage=1.0,
liquidation_price="1000", margin_used="500", status="open",
)
class TestEscrowFeeConsistency:
def test_fee_inconsistency_rejected(self):
with pytest.raises(ValidationError, match="fee_amount"):
EscrowCreate(
escrow_id="esc_1", buyer_id="buyer", seller_id="seller",
amount="100", currency="USDC",
fee_amount="5.0", # should be 1.0 (1% of 100)
fee_pct=0.01, status="pending",
)
def test_valid_escrow(self):
e = EscrowCreate(
escrow_id="esc_2", buyer_id="buyer", seller_id="seller",
amount="100.000000", currency="USDC",
fee_amount="1.000000", fee_pct=0.01, status="funded",
)
assert e.amount == Decimal("100.000000")
assert e.fee_amount == Decimal("1.000000")
# tests/test_tools.py — mock HTTP calls
import pytest
from decimal import Decimal
from unittest.mock import AsyncMock, patch
from tools import place_casino_bet, get_wallet_balance
@pytest.mark.asyncio
async def test_casino_bet_validation():
mock_response = {
"bet_id": "bet_abc123", "game_type": "coin_flip",
"wager": "0.500000", "outcome": "win",
"payout": "0.950000", "multiplier": 1.9,
"balance_after": "1.450000",
"provably_fair_hash": "d" * 64,
}
with patch("tools._post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = mock_response
result = await place_casino_bet("coin_flip", Decimal("0.5"))
assert result.outcome == "win"
assert result.wager == Decimal("0.500000")
@pytest.mark.asyncio
async def test_negative_wager_blocked():
with pytest.raises(ValueError, match="positive"):
await place_casino_bet("coin_flip", Decimal("-1"))
8. Response Model Reference
| Model | API Endpoint | Key Validators |
|---|---|---|
| FaucetClaim | POST /faucet/claim | positive amount, Decimal coercion |
| CasinoResponse | POST /casino/bet | valid hash, multiplier range, win/loss payout consistency |
| TradeResponse | POST /trading/trade | pair format regex, liquidation vs entry direction |
| WalletBalance | GET /wallet/balance | non-negative balances, Decimal coercion, USD total |
| EscrowCreate | POST /escrow/create | fee_amount vs fee_pct consistency, amount positive |
Pydantic AI's result_type parameter on the Agent class enforces that the LLM's final answer also validates through a model. Combined with tool-level validation, this means no untyped data ever flows through your agent's logic — from raw HTTP response to final structured output.
Start Building with Pydantic AI + Purple Flea
Get your free $1 from the faucet to test every endpoint shown in this guide. Purple Flea APIs return consistent JSON that maps cleanly to Pydantic models.
Claim Free $1 API Reference