Course ES
← back to chapter

Trip budgeter

"4 days in Tokyo and 3 in Paris, in Mexican pesos" takes five tool calls to

Showcase — Trip budgeter

"4 days in Tokyo and 3 in Paris, in Mexican pesos" takes five tool calls to answer honestly: two city-cost lookups, one exchange rate, and calculator calls for the totals. The model plans that chain itself — the loop just keeps executing until it stops asking. The calculator is the quiet star: an AST-whitelisted evaluator (numbers and + - * / only, no eval) so multi-step arithmetic — the thing models reliably fumble — always runs in Python.

Run

bash bootstrap-secrets.sh              # reads ../../../../.env, writes secrets/
docker compose up --build              # default: PROVIDER=openai

Open http://localhost:3000.

To run against Gemini instead:

PROVIDER=gemini docker compose up --build

What's where

  • backend/ai_openai.py — cost + fx tables, the AST-whitelisted calc, the dispatch dict, and the bounded loop
  • backend/ai_gemini.py — same three tools, Gemini spelling
  • backend/main.py — identical FastAPI loader; reads PROVIDER and dispatches
  • frontend/app/page.tsx — textarea + result
  • docker-compose.yml — two services, secrets mounted from ./secrets/

The cost and rate tables are deliberately static — the lesson is the loop, not a live data feed. Ask about a city that isn't in the table and the error comes back as data listing what is; watch the model recover and tell you.

Stop

docker compose down

Run locally

Download the project as a ZIP and run it with Docker. Brings up a FastAPI backend + Next.js frontend on localhost:3000.

Download trip-budgeter.zip

unzip trip-budgeter.zip
cd trip-budgeter
bash bootstrap-secrets.sh   # one-time: pulls API keys into ./secrets
docker compose up --build   # default provider: openai
# or:  PROVIDER=gemini docker compose up --build

Type some input, pick a provider, and run the same code shown in Source against the live API. Sign-in required.


  

The same modules the Run button hits. The whole project (frontend, Dockerfile, compose) is in the ZIP under README.

backend/ai_openai.py

"""Week 6 - Showcase 3 (OpenAI): budget a trip by chaining lookups and math.

Three tools, three kinds of work: look up a city's daily cost, look up an
exchange rate, and a calculator. The model plans the chain — costs, then
the rate, then the arithmetic — and the calc tool exists so the one thing
models reliably fumble, multi-step arithmetic, runs in Python. The
evaluator is AST-whitelisted: numbers and + - * / ( ) only, no eval().
"""
import ast
import json
import operator

from openai import OpenAI

_client = OpenAI()

_MODEL = "gpt-5.4-nano"

# Deliberately static tables — this is a tool-loop lesson, not a data feed.
_DAILY_COST_USD = {
    "tokyo": 210.0, "paris": 235.0, "mexico city": 120.0, "bangkok": 85.0,
    "new york": 310.0, "lisbon": 140.0, "buenos aires": 95.0,
}

_USD_TO = {"usd": 1.0, "mxn": 18.7, "eur": 0.92, "jpy": 155.3, "gbp": 0.79}

_ALLOWED_OPS = {
    ast.Add: operator.add, ast.Sub: operator.sub,
    ast.Mult: operator.mul, ast.Div: operator.truediv,
    ast.USub: operator.neg,
}


def _safe_eval(node):
    if isinstance(node, ast.Expression):
        return _safe_eval(node.body)
    if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
        return node.value
    if isinstance(node, ast.BinOp) and type(node.op) in _ALLOWED_OPS:
        return _ALLOWED_OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
    if isinstance(node, ast.UnaryOp) and type(node.op) in _ALLOWED_OPS:
        return _ALLOWED_OPS[type(node.op)](_safe_eval(node.operand))
    raise ValueError("only numbers and + - * / are allowed")


def _get_city_cost(city: str) -> dict:
    cost = _DAILY_COST_USD.get(city.lower().strip())
    if cost is None:
        return {"error": f"no data for {city!r}", "known_cities": sorted(_DAILY_COST_USD)}
    return {"city": city, "daily_cost_usd": cost,
            "includes": "mid-range lodging, food, local transport"}


def _get_exchange_rate(currency: str) -> dict:
    rate = _USD_TO.get(currency.lower().strip())
    if rate is None:
        return {"error": f"no rate for {currency!r}", "known": sorted(_USD_TO)}
    return {"currency": currency.upper(), "rate_from_usd": rate}


def _calc(expression: str) -> dict:
    try:
        value = _safe_eval(ast.parse(expression, mode="eval"))
        return {"expression": expression, "result": round(value, 2)}
    except (ValueError, SyntaxError, ZeroDivisionError) as exc:
        return {"error": str(exc)}


_DISPATCH = {"get_city_cost": _get_city_cost,
             "get_exchange_rate": _get_exchange_rate,
             "calc": _calc}

_TOOLS = [
    {
        "type": "function",
        "name": "get_city_cost",
        "description": "Typical daily travel cost for a city, in USD.",
        "parameters": {
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
        },
    },
    {
        "type": "function",
        "name": "get_exchange_rate",
        "description": "Exchange rate from USD to a currency (usd, mxn, eur, jpy, gbp).",
        "parameters": {
            "type": "object",
            "properties": {"currency": {"type": "string"}},
            "required": ["currency"],
        },
    },
    {
        "type": "function",
        "name": "calc",
        "description": "Evaluate an arithmetic expression exactly. "
                       "Numbers and + - * / ( ) only. Use this for ALL math.",
        "parameters": {
            "type": "object",
            "properties": {"expression": {"type": "string"}},
            "required": ["expression"],
        },
    },
]


def run(trip: str) -> str:
    input_list = [{"role": "user", "content": trip.strip()}]
    response = None
    for _ in range(10):  # bounded — never ship an open loop
        response = _client.responses.create(
            model=_MODEL,
            instructions="Budget trips using the tools. Look up each city's "
                         "cost and the target currency's rate, and do every "
                         "piece of arithmetic with calc — never in your head. "
                         "Show the breakdown in your final answer.",
            input=input_list,
            tools=_TOOLS,
        )
        calls = [item for item in response.output if item.type == "function_call"]
        if not calls:
            break
        input_list += response.output
        for call in calls:
            args = json.loads(call.arguments)
            fn = _DISPATCH.get(call.name)
            result = fn(**args) if fn else {"error": f"unknown tool {call.name}"}
            input_list.append({
                "type": "function_call_output",
                "call_id": call.call_id,
                "output": json.dumps(result),
            })
    return response.output_text if response else ""

backend/ai_gemini.py

"""Week 6 - Showcase 3 (Gemini): budget a trip by chaining lookups and math."""
import ast
import operator
import os

from google import genai
from google.genai import types

_client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])

_MODEL = "gemini-3.1-flash-lite"

_DAILY_COST_USD = {
    "tokyo": 210.0, "paris": 235.0, "mexico city": 120.0, "bangkok": 85.0,
    "new york": 310.0, "lisbon": 140.0, "buenos aires": 95.0,
}

_USD_TO = {"usd": 1.0, "mxn": 18.7, "eur": 0.92, "jpy": 155.3, "gbp": 0.79}

_ALLOWED_OPS = {
    ast.Add: operator.add, ast.Sub: operator.sub,
    ast.Mult: operator.mul, ast.Div: operator.truediv,
    ast.USub: operator.neg,
}


def _safe_eval(node):
    if isinstance(node, ast.Expression):
        return _safe_eval(node.body)
    if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
        return node.value
    if isinstance(node, ast.BinOp) and type(node.op) in _ALLOWED_OPS:
        return _ALLOWED_OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
    if isinstance(node, ast.UnaryOp) and type(node.op) in _ALLOWED_OPS:
        return _ALLOWED_OPS[type(node.op)](_safe_eval(node.operand))
    raise ValueError("only numbers and + - * / are allowed")


def _get_city_cost(city: str) -> dict:
    cost = _DAILY_COST_USD.get(city.lower().strip())
    if cost is None:
        return {"error": f"no data for {city!r}", "known_cities": sorted(_DAILY_COST_USD)}
    return {"city": city, "daily_cost_usd": cost,
            "includes": "mid-range lodging, food, local transport"}


def _get_exchange_rate(currency: str) -> dict:
    rate = _USD_TO.get(currency.lower().strip())
    if rate is None:
        return {"error": f"no rate for {currency!r}", "known": sorted(_USD_TO)}
    return {"currency": currency.upper(), "rate_from_usd": rate}


def _calc(expression: str) -> dict:
    try:
        value = _safe_eval(ast.parse(expression, mode="eval"))
        return {"expression": expression, "result": round(value, 2)}
    except (ValueError, SyntaxError, ZeroDivisionError) as exc:
        return {"error": str(exc)}


_DISPATCH = {"get_city_cost": _get_city_cost,
             "get_exchange_rate": _get_exchange_rate,
             "calc": _calc}

_CONFIG = types.GenerateContentConfig(
    system_instruction="Budget trips using the tools. Look up each city's "
                       "cost and the target currency's rate, and do every "
                       "piece of arithmetic with calc — never in your head. "
                       "Show the breakdown in your final answer.",
    tools=[types.Tool(function_declarations=[
        types.FunctionDeclaration(
            name="get_city_cost",
            description="Typical daily travel cost for a city, in USD.",
            parameters=types.Schema(
                type=types.Type.OBJECT,
                properties={"city": types.Schema(type=types.Type.STRING)},
                required=["city"],
            ),
        ),
        types.FunctionDeclaration(
            name="get_exchange_rate",
            description="Exchange rate from USD to a currency (usd, mxn, eur, jpy, gbp).",
            parameters=types.Schema(
                type=types.Type.OBJECT,
                properties={"currency": types.Schema(type=types.Type.STRING)},
                required=["currency"],
            ),
        ),
        types.FunctionDeclaration(
            name="calc",
            description="Evaluate an arithmetic expression exactly. "
                        "Numbers and + - * / ( ) only. Use this for ALL math.",
            parameters=types.Schema(
                type=types.Type.OBJECT,
                properties={"expression": types.Schema(type=types.Type.STRING)},
                required=["expression"],
            ),
        ),
    ])],
)


def run(trip: str) -> str:
    contents = [types.Content(role="user", parts=[types.Part(text=trip.strip())])]
    response = None
    for _ in range(10):  # bounded — never ship an open loop
        response = _client.models.generate_content(
            model=_MODEL, contents=contents, config=_CONFIG,
        )
        if not response.function_calls:
            break
        contents.append(response.candidates[0].content)
        parts = []
        for fc in response.function_calls:
            fn = _DISPATCH.get(fc.name)
            result = fn(**fc.args) if fn else {"error": f"unknown tool {fc.name}"}
            parts.append(types.Part.from_function_response(name=fc.name, response=result))
        contents.append(types.Content(role="user", parts=parts))
    return (response.text or "") if response else ""

Project files

  • .gitignore
  • README.md
  • backend/Dockerfile
  • backend/ai_gemini.py
  • backend/ai_openai.py
  • backend/main.py
  • backend/requirements.txt
  • bootstrap-secrets.sh
  • docker-compose.yml
  • frontend/Dockerfile
  • frontend/app/layout.tsx
  • frontend/app/page.tsx
  • frontend/next.config.ts
  • frontend/package.json
  • frontend/tsconfig.json