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-whitelistedcalc, the dispatch dict, and the bounded loopbackend/ai_gemini.py— same three tools, Gemini spellingbackend/main.py— identical FastAPI loader; reads PROVIDER and dispatchesfrontend/app/page.tsx— textarea + resultdocker-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.
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
.gitignoreREADME.mdbackend/Dockerfilebackend/ai_gemini.pybackend/ai_openai.pybackend/main.pybackend/requirements.txtbootstrap-secrets.shdocker-compose.ymlfrontend/Dockerfilefrontend/app/layout.tsxfrontend/app/page.tsxfrontend/next.config.tsfrontend/package.jsonfrontend/tsconfig.json