Guide

Building Financial Agents with Pydantic AI

March 6, 2026 24 min read Purple Flea Team

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
FaucetClaimPOST /faucet/claimpositive amount, Decimal coercion
CasinoResponsePOST /casino/betvalid hash, multiplier range, win/loss payout consistency
TradeResponsePOST /trading/tradepair format regex, liquidation vs entry direction
WalletBalanceGET /wallet/balancenon-negative balances, Decimal coercion, USD total
EscrowCreatePOST /escrow/createfee_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