Course EN
← 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

Ejecútalo en tu máquina

Descarga el proyecto como ZIP y córrelo con Docker. Levanta un backend FastAPI y un frontend Next.js en localhost:3000.

Descargar 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

Escribe algo, elige un proveedor y ejecuta el mismo código de Código contra la API real. Requiere iniciar sesión.


  

Los mismos módulos que ejecuta el botón Run. El proyecto completo (frontend, Dockerfile, compose) está en el ZIP, pestaña 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 ""

Archivos del proyecto

  • .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