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
Run locally
Download the project as a ZIP and run it with Docker. Brings up a FastAPI backend + Next.js frontend on 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
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
.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